feat: a named author, an About page and a Payload blog #140
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