The rendering split is dictated by CI building with no database. /blog, /blog/rss.xml and /content-sitemap.xml have no dynamic params, so Next prerenders them at build time and the build fails on a missing Payload secret — caught here, not on staging. They are force-dynamic instead: one indexed query against Postgres on the same Docker network, and a newly published post appears immediately rather than waiting on a revalidation. /blog/[slug] keeps ISR, because with no generateStaticParams there is nothing to prerender; it is generated on first request and cached, which is exactly what the collection's afterChange hook exists to invalidate. RichText takes `converters`, not `blocks`, in Payload 3.88, and the default converters must be spread or every paragraph and heading loses its renderer and the body comes out empty. BlogPosting references the Person and Organization by @id rather than repeating them, so every post and the About page resolve to one author entity instead of declaring several people with the same name. /sitemap.xml is proxied from FastAPI, which knows nothing about Payload, so the Next-owned URLs get their own sitemap and robots.txt lists both. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017YmbBhr8s7GusjDE12hrZM
83 lines
2.5 KiB
TypeScript
83 lines
2.5 KiB
TypeScript
import type { CollectionConfig } from 'payload';
|
|
import { revalidatePath } from 'next/cache';
|
|
import { lexicalEditor, BlocksFeature } from '@payloadcms/richtext-lexical';
|
|
import { Callout } from '@/blocks/Callout';
|
|
|
|
/**
|
|
* Drop the cached copy of a post page when it changes.
|
|
*
|
|
* Only the post page needs this. The blog index, the RSS feed and the content
|
|
* sitemap are force-dynamic — they have no dynamic params, so Next would
|
|
* prerender them at build time, where CI has no database — which means they
|
|
* already reflect a change on the next request.
|
|
*
|
|
* /blog/[slug] is ISR: generated on first request and cached, so without this
|
|
* an edit to an already-published post would not appear until the revalidate
|
|
* window expired — up to an hour of a writer concluding that saving is broken.
|
|
*
|
|
* Payload runs in the same process as Next, so this is a direct revalidatePath
|
|
* call: no webhook, no shared secret, no network hop to get wrong.
|
|
*/
|
|
function revalidatePost(slug: string) {
|
|
revalidatePath(`/blog/${slug}`);
|
|
}
|
|
|
|
export const Posts: CollectionConfig = {
|
|
slug: 'posts',
|
|
access: { read: () => true },
|
|
admin: {
|
|
useAsTitle: 'title',
|
|
defaultColumns: ['title', 'publishedAt', '_status'],
|
|
},
|
|
versions: {
|
|
// Posts get written across several sittings and previewed before they go
|
|
// live. Without drafts, saving is publishing.
|
|
drafts: true,
|
|
},
|
|
hooks: {
|
|
afterChange: [({ doc }) => { revalidatePost(String(doc.slug)); }],
|
|
afterDelete: [({ doc }) => { revalidatePost(String(doc.slug)); }],
|
|
},
|
|
fields: [
|
|
{ name: 'title', type: 'text', required: true },
|
|
{
|
|
name: 'slug',
|
|
type: 'text',
|
|
required: true,
|
|
unique: true,
|
|
index: true,
|
|
admin: {
|
|
position: 'sidebar',
|
|
description: 'The URL segment. Never change it after publishing.',
|
|
},
|
|
},
|
|
{
|
|
name: 'publishedAt',
|
|
type: 'date',
|
|
required: true,
|
|
admin: { position: 'sidebar', date: { pickerAppearance: 'dayOnly' } },
|
|
},
|
|
{
|
|
name: 'excerpt',
|
|
type: 'textarea',
|
|
required: true,
|
|
maxLength: 200,
|
|
admin: {
|
|
description: 'Shown on the index and used as the meta description.',
|
|
},
|
|
},
|
|
{ name: 'heroImage', type: 'upload', relationTo: 'media' },
|
|
{
|
|
name: 'content',
|
|
type: 'richText',
|
|
required: true,
|
|
editor: lexicalEditor({
|
|
features: ({ defaultFeatures }) => [
|
|
...defaultFeatures,
|
|
BlocksFeature({ blocks: [Callout] }),
|
|
],
|
|
}),
|
|
},
|
|
],
|
|
};
|