PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 1m15s
PR Checks / Backend Smoke (pull_request) Successful in 9s
PR Checks / Build Backend (no push) (pull_request) Successful in 20s
PR Checks / Build Frontend (no push) (pull_request) Successful in 1m21s
PR Checks / Build Pipeline (no push) (pull_request) Successful in 12s
PR Checks / AI Code Review (Claude) (pull_request) Successful in 1m39s
Both features ship dark. Neither is reachable in an environment where its flag is off, and every flag in this system starts off, so a deploy of this commit makes both disappear until someone turns them on deliberately. Two independent flags rather than one, which makes blog-on-about-off a reachable state. That state is the whole reason the change is larger than four notFound() calls: the blog leans on the About page for its author identity. The Person entity is anchored at /about#tudor, and that URL 404s while about_page is dark, so a post published in that state would claim an author resolving to nothing. Worse than having no named author. Both bylines fall back to unlinked text and the BlogPosting attributes to the publisher instead, so every combination of the two flags renders something correct. Gated: /about, /blog, /blog/[slug], the RSS feed, both footer links, and the matching content-sitemap entries. A sitemap must never advertise a URL that 404s. With both dark it emits a valid empty urlset rather than a 404, because robots.txt names it unconditionally. Not gated: /admin. Posts have to be writable before the blog is worth switching on, so flagging the panel would make the flag unflippable. getFlags takes a revalidate rather than always using the 300s constant. Reading a flag pins the calling route to the lowest revalidate among its fetches, and the footer links live in the root layout, so a naive gate there would have dropped every school and place page from a weekly cache to a 5-minute one. The layout passes 604800, the floor those routes already declare, and the build confirms all four SSG route families still prerender. The cost is one-way latency: pages follow a flip in minutes, footer links within a week. The e2e journeys follow the existing paired shape from the admission_distance flag: a lit journey and a dark one for each flag, reading state from whether /about and /blog respond rather than from /api/flags, which another journey asserts is not publicly reachable. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DXnXQKnPpZBBP61fBQiFkq
195 lines
6.0 KiB
TypeScript
195 lines
6.0 KiB
TypeScript
import { cache } from 'react';
|
|
import type { Metadata } from 'next';
|
|
import Link from 'next/link';
|
|
import { notFound } from 'next/navigation';
|
|
import { RichText } from '@payloadcms/richtext-lexical/react';
|
|
import type { JSXConvertersFunction } from '@payloadcms/richtext-lexical/react';
|
|
import { getCachedPayload } from '@/lib/payload';
|
|
import type { Post, Media } from '@/payload-types';
|
|
import { absoluteUrl } from '@/lib/site';
|
|
import { getFlags } from '@/lib/flags';
|
|
import {
|
|
blogPostingJsonLd,
|
|
breadcrumbJsonLd,
|
|
personJsonLd,
|
|
organizationJsonLd,
|
|
} from '@/lib/jsonld';
|
|
import { CalloutBlock } from '@/components/blog/CalloutBlock';
|
|
import styles from './Post.module.css';
|
|
|
|
/*
|
|
* ISR. Unlike the index, this route has a dynamic param and no
|
|
* generateStaticParams, so there is nothing for the build to prerender: each
|
|
* post is generated on first request and cached until the collection's
|
|
* afterChange hook revalidates it. That hook is what makes an edit to an
|
|
* already-published post appear immediately.
|
|
*/
|
|
export const revalidate = 3600;
|
|
|
|
/**
|
|
* heroImage is `number | Media | null`: an id when the query is shallow, the
|
|
* populated document at depth 1. Both pages query at depth 1, but narrowing
|
|
* rather than asserting keeps it correct if that ever changes.
|
|
*/
|
|
function heroOf(post: Post): Media | null {
|
|
return typeof post.heroImage === 'object' && post.heroImage !== null
|
|
? post.heroImage
|
|
: null;
|
|
}
|
|
|
|
/**
|
|
* Spreads the default converters and adds the one custom block.
|
|
*
|
|
* Without the spread, every default node type — paragraphs, headings, links —
|
|
* loses its renderer and the post body comes out empty.
|
|
*/
|
|
const calloutConverters: JSXConvertersFunction = ({ defaultConverters }) => ({
|
|
...defaultConverters,
|
|
blocks: {
|
|
// Annotated because the generic block converter cannot infer a custom
|
|
// block's field shape; String() guards the values regardless.
|
|
callout: ({ node }: { node: { fields: Record<string, unknown> } }) => (
|
|
<CalloutBlock
|
|
tone={String(node.fields.tone ?? 'caveat')}
|
|
body={String(node.fields.body ?? '')}
|
|
/>
|
|
),
|
|
},
|
|
});
|
|
|
|
/**
|
|
* Wrapped in React's cache() because Next calls generateMetadata and the page
|
|
* component separately for the same request — without it, every post view runs
|
|
* this query against Postgres twice. cache() dedupes within a single request
|
|
* only, so it never serves one visitor's request from another's.
|
|
*/
|
|
const findPost = cache(async (slug: string) => {
|
|
const payload = await getCachedPayload();
|
|
const { docs } = await payload.find({
|
|
collection: 'posts',
|
|
where: { slug: { equals: slug }, _status: { equals: 'published' } },
|
|
limit: 1,
|
|
depth: 1,
|
|
});
|
|
return docs[0] ?? null;
|
|
});
|
|
|
|
function summarise(post: Post) {
|
|
return {
|
|
title: post.title,
|
|
slug: post.slug,
|
|
excerpt: post.excerpt,
|
|
publishedAt: post.publishedAt,
|
|
};
|
|
}
|
|
|
|
export async function generateMetadata(
|
|
{ params }: { params: Promise<{ slug: string }> },
|
|
): Promise<Metadata> {
|
|
const { slug } = await params;
|
|
const post = await findPost(slug);
|
|
if (!post) return { title: 'Not found' };
|
|
|
|
const hero = heroOf(post);
|
|
|
|
return {
|
|
title: post.title,
|
|
description: post.excerpt,
|
|
alternates: { canonical: absoluteUrl(`/blog/${post.slug}`) },
|
|
openGraph: {
|
|
type: 'article',
|
|
title: post.title,
|
|
description: post.excerpt,
|
|
url: absoluteUrl(`/blog/${post.slug}`),
|
|
publishedTime: post.publishedAt,
|
|
// A post with a hero image shares that; one without falls through to the
|
|
// generated share card at app/opengraph-image.tsx.
|
|
...(hero?.url ? { images: [{ url: hero.url }] } : {}),
|
|
},
|
|
};
|
|
}
|
|
|
|
export default async function PostPage(
|
|
{ params }: { params: Promise<{ slug: string }> },
|
|
) {
|
|
const { slug } = await params;
|
|
|
|
/*
|
|
* Flags read at this route's own declared floor, so gating costs it nothing.
|
|
* Checked before the post is fetched: a dark blog should not query Payload.
|
|
*/
|
|
const flags = await getFlags(3600);
|
|
if (flags.blog !== true) notFound();
|
|
const namedAuthor = flags.about_page === true;
|
|
|
|
const post = await findPost(slug);
|
|
if (!post) notFound();
|
|
|
|
const summary = summarise(post);
|
|
const hero = heroOf(post);
|
|
|
|
const jsonLd = {
|
|
'@context': 'https://schema.org',
|
|
/*
|
|
* The Person entity is anchored at /about#tudor, so it is declared only
|
|
* when that page exists. Claiming an author whose URL 404s is a worse
|
|
* signal than attributing the post to the publisher.
|
|
*/
|
|
'@graph': [
|
|
blogPostingJsonLd(summary, { namedAuthor }),
|
|
breadcrumbJsonLd(summary),
|
|
...(namedAuthor ? [personJsonLd()] : []),
|
|
organizationJsonLd(),
|
|
],
|
|
};
|
|
|
|
return (
|
|
<article className={styles.page}>
|
|
<script
|
|
type="application/ld+json"
|
|
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
|
|
/>
|
|
|
|
<nav className={styles.crumb}>
|
|
<Link href="/blog" className={styles.link}>Blog</Link>
|
|
</nav>
|
|
|
|
<h1 className={styles.heading}>{summary.title}</h1>
|
|
|
|
<p className={styles.byline}>
|
|
{/* Unlinked while about_page is dark; the flags are independent. */}
|
|
By {namedAuthor
|
|
? <Link href="/about" className={styles.link}>Tudor</Link>
|
|
: 'Tudor'}
|
|
{' · '}
|
|
<time dateTime={summary.publishedAt}>
|
|
{new Date(summary.publishedAt).toLocaleDateString('en-GB', {
|
|
day: 'numeric',
|
|
month: 'long',
|
|
year: 'numeric',
|
|
})}
|
|
</time>
|
|
</p>
|
|
|
|
{/*
|
|
A plain <img>, not next/image: Payload already generated the sized
|
|
derivatives on upload (Media's imageSizes), so routing it through the
|
|
optimizer would resize an image that is already the right size.
|
|
*/}
|
|
{hero?.url && (
|
|
<img
|
|
className={styles.hero}
|
|
src={hero.url}
|
|
alt={hero.alt ?? ''}
|
|
width={hero.width ?? undefined}
|
|
height={hero.height ?? undefined}
|
|
/>
|
|
)}
|
|
|
|
<div className={styles.prose}>
|
|
<RichText data={post.content} converters={calloutConverters} />
|
|
</div>
|
|
</article>
|
|
);
|
|
}
|