-
-
Save Livog/1c4673b264158d367ab2b423aacd7f4e to your computer and use it in GitHub Desktop.
| import type { CollectionSlug } from 'payload' | |
| export const PATH_UNIQUE_AGINST_COLLECTIONS = ['pages', 'posts'] as const satisfies CollectionSlug[] | |
| export const FIELD_TO_USE_FOR_PATH = 'slug' as const |
| export default function generateRandomString(length: number, characters = 'abcdefghijklmnopqrstuvwxyz0123456789'): string { | |
| let result = '' | |
| const charactersLength = characters.length | |
| for (let i = 0; i < length; i++) { | |
| result += characters.charAt(Math.floor(Math.random() * charactersLength)) | |
| } | |
| return result | |
| } |
| import { getPayload } from '@/lib/payload' | |
| import { PATH_UNIQUE_AGINST_COLLECTIONS } from '@/payload/fields/path/config' | |
| import normalizePath from '@/utilities/normalizePath' | |
| import { Config } from '@payload-types' | |
| import { draftMode } from 'next/headers' | |
| import type { CollectionSlug } from 'payload' | |
| import { cache } from 'react' | |
| type PathUniqueCollection = (typeof PATH_UNIQUE_AGINST_COLLECTIONS)[number] | |
| type CollectionDocument<K extends keyof Config['collections']> = Config['collections'][K] & { _collection: K } | |
| type CollectionDocuments = { | |
| [K in keyof Config['collections']]: CollectionDocument<K> | |
| }[keyof Config['collections']] | |
| type PathUniqueCollectionDocuments = { | |
| [K in PathUniqueCollection]: CollectionDocument<K> | |
| }[PathUniqueCollection] | |
| export async function getDocumentByPath<S extends keyof Config['collections']>( | |
| path: string | string[], | |
| collection: S | |
| ): Promise<CollectionDocument<S> | null> | |
| export async function getDocumentByPath(path: string | string[]): Promise<PathUniqueCollectionDocuments | null> | |
| export async function getDocumentByPath(path: string | string[], collection?: CollectionSlug): Promise<CollectionDocuments | null> { | |
| const { isEnabled: draft } = await draftMode() | |
| const payload = await getPayload() | |
| const normalizedPath = normalizePath(path, false) | |
| const collectionsToSearch = collection ? [collection] : PATH_UNIQUE_AGINST_COLLECTIONS | |
| const queries = collectionsToSearch.map((collectionSlug) => | |
| payload | |
| .find({ | |
| collection: collectionSlug, | |
| draft, | |
| limit: 1, | |
| overrideAccess: draft, | |
| where: { path: { equals: normalizedPath } } | |
| }) | |
| .then((result) => { | |
| const doc = result.docs.at(0) | |
| if (!doc) return null | |
| return { | |
| ...doc, | |
| _collection: collectionSlug | |
| } as CollectionDocuments | |
| }) | |
| .catch(() => null) | |
| ) | |
| const results = (await Promise.allSettled(queries)).filter( | |
| (v): v is PromiseFulfilledResult<CollectionDocuments | null> => v.status === 'fulfilled' | |
| ) | |
| return results.find((result) => result.value !== null)?.value ?? null | |
| } | |
| export const getCachedDocumentByPath = cache(getDocumentByPath) |
| import type { Collections, CollectionSlug } from '@/payload/types' | |
| import type { BasePayload } from 'payload' | |
| type GetParentsParams<S extends CollectionSlug> = { | |
| payload: BasePayload | |
| parentFieldSlug?: string | |
| collectionSlug: S | |
| doc: Collections[S] | |
| docs?: Array<Collections[S]> | |
| } | |
| export const getParents = async <S extends CollectionSlug>({ | |
| payload, | |
| parentFieldSlug = 'parent', | |
| collectionSlug, | |
| doc, | |
| docs = [] | |
| }: GetParentsParams<S>): Promise<Array<Collections[S]>> => { | |
| const parent = doc[parentFieldSlug] | |
| if (!parent) { | |
| return docs | |
| } | |
| let retrievedParent | |
| if (typeof parent === 'string' || typeof parent === 'number') { | |
| retrievedParent = await payload.findByID({ | |
| id: parent, | |
| collection: collectionSlug, | |
| depth: 0, | |
| disableErrors: true | |
| }) | |
| } else if (typeof parent === 'object') { | |
| retrievedParent = parent | |
| } else { | |
| return docs | |
| } | |
| if (!retrievedParent) { | |
| return docs | |
| } | |
| if (retrievedParent[parentFieldSlug]) { | |
| return getParents({ | |
| payload, | |
| parentFieldSlug, | |
| collectionSlug, | |
| doc: retrievedParent, | |
| docs: [retrievedParent, ...docs] | |
| }) | |
| } | |
| return [retrievedParent, ...docs] | |
| } |
| import generateBreadcrumbsUrl from '@/payload/utilities/generateBreadcrumbsUrl' | |
| import deepmerge from 'deepmerge' | |
| import type { BasePayload, Field, Payload, Where } from 'payload' | |
| import { APIError } from 'payload' | |
| import generateRandomString from '@/payload/utilities/generateRandomString' | |
| import { getParents } from './getParents' | |
| import type { CollectionSlug } from 'payload' | |
| type WillPathConflictParams = { | |
| payload: Payload | |
| path: string | |
| originalDoc?: { id?: string } | |
| collection: CollectionSlug | |
| uniquePathFieldCollections?: CollectionSlug[] | ReadonlyArray<CollectionSlug> | |
| } | |
| export const willPathConflict = async ({ | |
| payload, | |
| path, | |
| originalDoc, | |
| collection, | |
| uniquePathFieldCollections = [] | |
| }: WillPathConflictParams): Promise<boolean> => { | |
| if (!payload || !uniquePathFieldCollections.includes(collection)) return false | |
| const queries = uniquePathFieldCollections.map((targetCollection) => { | |
| const whereCondition: Where = { | |
| path: { equals: path } | |
| } | |
| if (originalDoc?.id && collection === targetCollection) { | |
| whereCondition.id = { not_equals: originalDoc.id } | |
| } | |
| return payload.find({ | |
| collection: targetCollection, | |
| where: whereCondition, | |
| limit: 1, | |
| pagination: false | |
| }) | |
| }) | |
| const results = await Promise.allSettled(queries) | |
| return results.some((result) => result.status === 'fulfilled' && (result as PromiseFulfilledResult<any>).value.docs.length > 0) | |
| } | |
| type GenerateDocumentPathParams = { | |
| payload: BasePayload | |
| collection: CollectionSlug | |
| currentDoc: any | |
| operation?: string | |
| fieldToUse: string | |
| } | |
| export async function generateDocumentPath({ | |
| payload, | |
| collection, | |
| currentDoc, | |
| operation, | |
| fieldToUse | |
| }: GenerateDocumentPathParams): Promise<string> { | |
| if (!currentDoc?.[fieldToUse] || !collection) { | |
| return `/${currentDoc?.id || generateRandomString(20)}` | |
| } | |
| const breadcrumbs = currentDoc?.breadcrumbs | |
| const newPath = breadcrumbs?.at(-1)?.url | |
| if (newPath) return newPath | |
| const docs = await getParents({ | |
| payload, | |
| parentFieldSlug: 'parent', | |
| collectionSlug: collection, | |
| doc: currentDoc, | |
| docs: [currentDoc] | |
| }) | |
| return generateBreadcrumbsUrl(docs, currentDoc) | |
| } | |
| const pathField = ( | |
| uniquePathFieldCollections: CollectionSlug[] | ReadonlyArray<CollectionSlug>, | |
| fieldToUse: string, | |
| overrides?: Partial<Field> | |
| ): Field[] => { | |
| return [ | |
| { | |
| name: '_collection', | |
| type: 'text', | |
| admin: { | |
| hidden: true | |
| }, | |
| virtual: true, | |
| hooks: { | |
| beforeValidate: [({ collection }) => collection?.slug || null] | |
| } | |
| }, | |
| deepmerge<Field>( | |
| { | |
| type: 'text', | |
| name: 'path', | |
| unique: true, | |
| index: true, | |
| hooks: { | |
| beforeDuplicate: [ | |
| () => { | |
| return `/${generateRandomString(20)}` | |
| } | |
| ], | |
| beforeChange: [ | |
| async ({ collection, data, req, siblingData, originalDoc, operation }) => { | |
| if (!collection) { | |
| throw new APIError( | |
| 'Collection is null.', | |
| 400, | |
| [ | |
| { | |
| field: fieldToUse, | |
| message: 'Collection is required.' | |
| } | |
| ], | |
| false | |
| ) | |
| } | |
| const currentDoc = { ...originalDoc, ...siblingData } | |
| const newPath = await generateDocumentPath({ | |
| payload: req.payload, | |
| collection: collection.slug as CollectionSlug, | |
| currentDoc, | |
| operation, | |
| fieldToUse | |
| }) | |
| const isNewPathConflicting = await willPathConflict({ | |
| payload: req.payload, | |
| path: newPath, | |
| originalDoc, | |
| collection: collection.slug as CollectionSlug, | |
| uniquePathFieldCollections | |
| }) | |
| if (isNewPathConflicting) { | |
| throw new APIError( | |
| `This ${fieldToUse} will create a conflict with an existing path.`, | |
| 400, | |
| [ | |
| { | |
| field: fieldToUse, | |
| message: `This ${fieldToUse} will create a conflict with an existing path.` | |
| } | |
| ], | |
| false | |
| ) | |
| } | |
| if (data) data.path = newPath | |
| return newPath | |
| } | |
| ] | |
| }, | |
| admin: { | |
| position: 'sidebar', | |
| readOnly: true | |
| } | |
| }, | |
| overrides || {} | |
| ) | |
| ] | |
| } | |
| export default pathField |
| const normalizePath = (path?: string | string[] | null | undefined, keepTrailingSlash = false): string => { | |
| if (!path) return '/' | |
| if (Array.isArray(path)) path = path.join('/') | |
| path = `/${path}/`.replace(/\/+/g, '/') | |
| path = path !== '/' && !keepTrailingSlash ? path.replace(/\/$/, '') : path | |
| return path | |
| } | |
| export default normalizePath |
| const config = { | |
| fields: [ | |
| ...pathField(PATH_UNIQUE_AGINST_COLLECTIONS, FIELD_TO_USE_FOR_PATH), | |
| ] | |
| } |
@notflip The PR I created is now very outdated, and I’ve repeatedly brought up that this field is missing in Payload but never received a solid answer from Payload Core. The solution you see here is my own approach to solving the problem, and it's the code I use in my personal Payload projects.
Thanks man! Let me try to get this working again, so I copy over all these files above here
- Where and how do you use the getDocument file here above? It doesn't seem to be used anywhere
- I see the
...pathField(PATH_UNIQUE_AGINST_COLLECTIONS, FIELD_TO_USE_FOR_PATH),I'll try and use that
EDIT: I think I understand now, we don't use this, as mentioned in the PR
const { docs } = await payload.find({
collection: 'pages',
where: { path: { equals: path } },
depth: 3
});
const page = docs?.at(0) || null
But we use
const page = await getCachedDocumentByPath(path)
Here is a part of my /app/(frontend)/[[...path]].ts file, hope it explains a bit more:
export default async function Page({ params, searchParams }: PageProps) {
const path = await extractPath(params)
const document = await getDocumentByPath(path)
const contentProps = {
searchParams: await searchParams,
path
}
if (!document) {
notFound()
}
if (!['pages', 'posts'].includes(document._collection)) return null
const { isEnabled: draft } = await draftMode()
return (
<>
{draft && <LivePreviewListener />}
{document._collection == 'pages' && <PageContent document={document} {...contentProps} />}
{document._collection == 'posts' && <PostContent document={document} {...contentProps} />}
</>
)
}
How you use the document is fully up to you.
@Livog It's working now! This is pretty amazing, my CmsLink component is now also a lot simpler (without mapping a slug to a collection). Thanks again for this.
@Livog One more question, you're using react cache, but how do you invalidate that? The nextjs cache can be revalidated using revalidateTag.
export const getCachedDocumentByPath = cache(getDocumentByPath)
@notflip Cache is to memorize a function call per request so it only lives once per request, and since I'm currently using Cloudflare I don't want to cache things both internally in .next folder and in the CDN.
I'm a bit confused is all,
I'm looking at your PR: https://github.com/payloadcms/payload/pull/6329/files
And it looks different from the files you're posting here, I also don't know what to do with getDocument, as for the path field, should I just include it inside my Pages.ts collection? As so:
...pathField(),Would it be easier to try and use your PR in my project, instead of me fiddling around with these files here? Thanks for your time, I really need this feature