-
-
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), | |
] | |
} |
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.
@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.