diff --git a/nextjs-app/__tests__/payload/collections.test.ts b/nextjs-app/__tests__/payload/collections.test.ts new file mode 100644 index 0000000..c0cc79f --- /dev/null +++ b/nextjs-app/__tests__/payload/collections.test.ts @@ -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\]/); + }); +}); diff --git a/nextjs-app/blocks/Callout.ts b/nextjs-app/blocks/Callout.ts new file mode 100644 index 0000000..fd41654 --- /dev/null +++ b/nextjs-app/blocks/Callout.ts @@ -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 }, + ], +}; diff --git a/nextjs-app/collections/Media.ts b/nextjs-app/collections/Media.ts new file mode 100644 index 0000000..07b5205 --- /dev/null +++ b/nextjs-app/collections/Media.ts @@ -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.' }, + }, + ], +}; diff --git a/nextjs-app/collections/Posts.ts b/nextjs-app/collections/Posts.ts new file mode 100644 index 0000000..199055e --- /dev/null +++ b/nextjs-app/collections/Posts.ts @@ -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] }), + ], + }), + }, + ], +}; diff --git a/nextjs-app/payload.config.ts b/nextjs-app/payload.config.ts index f4343c8..6be8850 100644 --- a/nextjs-app/payload.config.ts +++ b/nextjs-app/payload.config.ts @@ -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') },