Drafts are on so a post can be written across sittings without saving being publishing. afterChange and afterDelete revalidate every path a post appears on. Blog pages are ISR because CI builds with no database, so without these a published post would not appear until the revalidate window expired — up to an hour of a writer concluding that publishing is broken. Payload runs in the same process as Next, so these are direct revalidatePath calls with no webhook and no shared secret. Media writes to an absolute /app/media matching the compose mount; a mismatch would write into the container filesystem, where the next redeploy silently discards it. Alt text is required rather than optional. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017YmbBhr8s7GusjDE12hrZM
82 lines
2.4 KiB
TypeScript
82 lines
2.4 KiB
TypeScript
import type { CollectionConfig } from 'payload';
|
|
import { revalidatePath } from 'next/cache';
|
|
import { lexicalEditor, BlocksFeature } from '@payloadcms/richtext-lexical';
|
|
import { Callout } from '@/blocks/Callout';
|
|
|
|
/**
|
|
* Revalidate everything a post appears on.
|
|
*
|
|
* Blog pages are ISR: CI builds the image with no database reachable, so they
|
|
* cannot be statically generated at build time. Without this, a published post
|
|
* would not appear until the revalidate window expired — up to an hour of a
|
|
* writer reasonably concluding that publishing 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');
|
|
revalidatePath(`/blog/${slug}`);
|
|
revalidatePath('/content-sitemap.xml');
|
|
revalidatePath('/blog/rss.xml');
|
|
}
|
|
|
|
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] }),
|
|
],
|
|
}),
|
|
},
|
|
],
|
|
};
|