2026-09-02 16:48:59 +01:00
|
|
|
import { cache } from 'react';
|
2026-09-02 16:28:58 +01:00
|
|
|
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';
|
2026-09-02 18:31:38 +01:00
|
|
|
import type { Post, Media } from '@/payload-types';
|
2026-09-02 16:28:58 +01:00
|
|
|
import { absoluteUrl } from '@/lib/site';
|
|
|
|
|
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;
|
|
|
|
|
|
2026-09-02 18:31:38 +01:00
|
|
|
/**
|
|
|
|
|
* 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;
|
2026-09-02 16:28:58 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 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 ?? '')}
|
|
|
|
|
/>
|
|
|
|
|
),
|
|
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
|
2026-09-02 16:48:59 +01:00
|
|
|
/**
|
|
|
|
|
* 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) => {
|
2026-09-02 16:28:58 +01:00
|
|
|
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;
|
2026-09-02 16:48:59 +01:00
|
|
|
});
|
2026-09-02 16:28:58 +01:00
|
|
|
|
2026-09-02 18:31:38 +01:00
|
|
|
function summarise(post: Post) {
|
2026-09-02 16:28:58 +01:00
|
|
|
return {
|
2026-09-02 18:31:38 +01:00
|
|
|
title: post.title,
|
|
|
|
|
slug: post.slug,
|
|
|
|
|
excerpt: post.excerpt,
|
|
|
|
|
publishedAt: post.publishedAt,
|
2026-09-02 16:28:58 +01:00
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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' };
|
|
|
|
|
|
2026-09-02 18:31:38 +01:00
|
|
|
const hero = heroOf(post);
|
2026-09-02 16:28:58 +01:00
|
|
|
|
|
|
|
|
return {
|
2026-09-02 18:31:38 +01:00
|
|
|
title: post.title,
|
|
|
|
|
description: post.excerpt,
|
2026-09-02 16:28:58 +01:00
|
|
|
alternates: { canonical: absoluteUrl(`/blog/${post.slug}`) },
|
|
|
|
|
openGraph: {
|
|
|
|
|
type: 'article',
|
2026-09-02 18:31:38 +01:00
|
|
|
title: post.title,
|
|
|
|
|
description: post.excerpt,
|
2026-09-02 16:28:58 +01:00
|
|
|
url: absoluteUrl(`/blog/${post.slug}`),
|
2026-09-02 18:31:38 +01:00
|
|
|
publishedTime: post.publishedAt,
|
2026-09-02 16:28:58 +01:00
|
|
|
// 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;
|
|
|
|
|
const post = await findPost(slug);
|
|
|
|
|
if (!post) notFound();
|
|
|
|
|
|
2026-09-02 18:31:38 +01:00
|
|
|
const summary = summarise(post);
|
|
|
|
|
const hero = heroOf(post);
|
2026-09-02 16:28:58 +01:00
|
|
|
|
|
|
|
|
const jsonLd = {
|
|
|
|
|
'@context': 'https://schema.org',
|
|
|
|
|
'@graph': [
|
|
|
|
|
blogPostingJsonLd(summary),
|
|
|
|
|
breadcrumbJsonLd(summary),
|
|
|
|
|
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}>
|
|
|
|
|
By <Link href="/about" className={styles.link}>Tudor</Link>
|
|
|
|
|
{' · '}
|
|
|
|
|
<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}>
|
2026-09-02 18:31:38 +01:00
|
|
|
<RichText data={post.content} converters={calloutConverters} />
|
2026-09-02 16:28:58 +01:00
|
|
|
</div>
|
|
|
|
|
</article>
|
|
|
|
|
);
|
|
|
|
|
}
|