feat(blog): add the posts and media collections
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
This commit is contained in:
1 parent
f614414070
commit
21a5d18f59
5 files changed
+204
-1
No files matched your search
@@ -0,0 +1,64 @@
|
||||
/**
|
||||
* Payload is ESM-only and next/jest will not transform it, so the collections
|
||||
* cannot be imported and their sanitised config inspected here (see
|
||||
* lib/payloadRoutes.ts for the full reasoning). These assert the source of the
|
||||
* collection definitions instead — enough to catch the settings whose loss is
|
||||
* silent, and cheap. Behaviour is proved by the e2e journeys against staging.
|
||||
*/
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
const read = (file: string) =>
|
||||
fs.readFileSync(path.join(__dirname, '..', '..', 'collections', file), 'utf8');
|
||||
|
||||
const POSTS = read('Posts.ts');
|
||||
const MEDIA = read('Media.ts');
|
||||
const CONFIG = fs.readFileSync(
|
||||
path.join(__dirname, '..', '..', 'payload.config.ts'),
|
||||
'utf8',
|
||||
);
|
||||
|
||||
describe('posts collection', () => {
|
||||
it('supports drafts, so saving is not publishing', () => {
|
||||
expect(POSTS).toMatch(/drafts:\s*true/);
|
||||
});
|
||||
|
||||
it('has a unique, indexed slug for stable URLs', () => {
|
||||
const slugField = POSTS.slice(POSTS.indexOf("name: 'slug'"));
|
||||
expect(slugField).toMatch(/unique:\s*true/);
|
||||
expect(slugField).toMatch(/index:\s*true/);
|
||||
});
|
||||
|
||||
it('is publicly readable', () => {
|
||||
expect(POSTS).toMatch(/access:\s*\{\s*read:\s*\(\)\s*=>\s*true/);
|
||||
});
|
||||
|
||||
it('revalidates the blog paths when a post changes or is deleted', () => {
|
||||
// Blog pages are ISR because CI builds with no database. Without these
|
||||
// hooks a published post would not appear until the revalidate window
|
||||
// expired — up to an hour of a writer thinking publishing is broken.
|
||||
expect(POSTS).toContain('afterChange');
|
||||
expect(POSTS).toContain('afterDelete');
|
||||
expect(POSTS).toMatch(/revalidatePath\('\/blog'\)/);
|
||||
expect(POSTS).toMatch(/revalidatePath\(`\/blog\/\$\{[^}]+\}`\)/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('media collection', () => {
|
||||
it('writes uploads to the mounted volume, by absolute path', () => {
|
||||
// Must match the payload_media mount in docker-compose.portainer.yml.
|
||||
// Payload 3 requires staticDir to be absolute.
|
||||
expect(MEDIA).toMatch(/staticDir:\s*'\/app\/media'/);
|
||||
});
|
||||
|
||||
it('requires alt text on every upload', () => {
|
||||
const altField = MEDIA.slice(MEDIA.indexOf("name: 'alt'"));
|
||||
expect(altField).toMatch(/required:\s*true/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('payload config', () => {
|
||||
it('registers every collection', () => {
|
||||
expect(CONFIG).toMatch(/collections:\s*\[Users,\s*Posts,\s*Media\]/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,25 @@
|
||||
import type { Block } from 'payload';
|
||||
|
||||
/**
|
||||
* The house block: "what this number doesn't tell you".
|
||||
*
|
||||
* Blocks are the reason this site runs a CMS rather than flat files — a post
|
||||
* can carry live product components, not screenshots of them. This is the
|
||||
* first and simplest one; a live-chart block follows when a post needs it.
|
||||
*/
|
||||
export const Callout: Block = {
|
||||
slug: 'callout',
|
||||
labels: { singular: 'Callout', plural: 'Callouts' },
|
||||
fields: [
|
||||
{
|
||||
name: 'tone',
|
||||
type: 'select',
|
||||
defaultValue: 'caveat',
|
||||
options: [
|
||||
{ label: 'Caveat — what this does not show', value: 'caveat' },
|
||||
{ label: 'Note — useful aside', value: 'note' },
|
||||
],
|
||||
},
|
||||
{ name: 'body', type: 'textarea', required: true },
|
||||
],
|
||||
};
|
||||
@@ -0,0 +1,31 @@
|
||||
import type { CollectionConfig } from 'payload';
|
||||
|
||||
/**
|
||||
* Uploads land on a Docker named volume mounted at /app/media. The path is
|
||||
* absolute because Payload 3 requires it, and it must match the payload_media
|
||||
* mount in docker-compose.portainer.yml exactly — a mismatch writes into the
|
||||
* container's own filesystem, where the next redeploy silently discards it.
|
||||
*/
|
||||
export const Media: CollectionConfig = {
|
||||
slug: 'media',
|
||||
access: { read: () => true },
|
||||
upload: {
|
||||
staticDir: '/app/media',
|
||||
mimeTypes: ['image/*'],
|
||||
imageSizes: [
|
||||
{ name: 'thumbnail', width: 400 },
|
||||
{ name: 'hero', width: 1200 },
|
||||
],
|
||||
adminThumbnail: 'thumbnail',
|
||||
},
|
||||
fields: [
|
||||
{
|
||||
name: 'alt',
|
||||
type: 'text',
|
||||
required: true,
|
||||
// Required rather than optional: a decorative-by-default image is an
|
||||
// accessibility regression on a site parents use under time pressure.
|
||||
admin: { description: 'Describe the image for screen readers.' },
|
||||
},
|
||||
],
|
||||
};
|
||||
@@ -0,0 +1,81 @@
|
||||
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] }),
|
||||
],
|
||||
}),
|
||||
},
|
||||
],
|
||||
};
|
||||
@@ -5,6 +5,8 @@ import { postgresAdapter } from '@payloadcms/db-postgres';
|
||||
import { lexicalEditor } from '@payloadcms/richtext-lexical';
|
||||
import sharp from 'sharp';
|
||||
import { Users } from '@/collections/Users';
|
||||
import { Posts } from '@/collections/Posts';
|
||||
import { Media } from '@/collections/Media';
|
||||
import { PAYLOAD_API_ROUTE, PAYLOAD_ADMIN_ROUTE } from '@/lib/payloadRoutes';
|
||||
|
||||
const filename = fileURLToPath(import.meta.url);
|
||||
@@ -15,7 +17,7 @@ export default buildConfig({
|
||||
// Defined in lib/payloadRoutes.ts, which carries the reasoning and is what
|
||||
// the test asserts. Never inline these — /api belongs to the FastAPI proxy.
|
||||
routes: { api: PAYLOAD_API_ROUTE, admin: PAYLOAD_ADMIN_ROUTE },
|
||||
collections: [Users],
|
||||
collections: [Users, Posts, Media],
|
||||
editor: lexicalEditor(),
|
||||
secret: process.env.PAYLOAD_SECRET || '',
|
||||
typescript: { outputFile: path.resolve(dirname, 'payload-types.ts') },
|
||||
|
||||
Reference in new issue
Block a user