feat(blog): add the blog index, post pages, RSS and content sitemap
The rendering split is dictated by CI building with no database. /blog, /blog/rss.xml and /content-sitemap.xml have no dynamic params, so Next prerenders them at build time and the build fails on a missing Payload secret — caught here, not on staging. They are force-dynamic instead: one indexed query against Postgres on the same Docker network, and a newly published post appears immediately rather than waiting on a revalidation. /blog/[slug] keeps ISR, because with no generateStaticParams there is nothing to prerender; it is generated on first request and cached, which is exactly what the collection's afterChange hook exists to invalidate. RichText takes `converters`, not `blocks`, in Payload 3.88, and the default converters must be spread or every paragraph and heading loses its renderer and the body comes out empty. BlogPosting references the Person and Organization by @id rather than repeating them, so every post and the About page resolve to one author entity instead of declaring several people with the same name. /sitemap.xml is proxied from FastAPI, which knows nothing about Payload, so the Next-owned URLs get their own sitemap and robots.txt lists both. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017YmbBhr8s7GusjDE12hrZM
This commit is contained in:
1 parent
21a5d18f59
commit
b793640507
15 files changed
+705
-14
No files matched your search
@@ -0,0 +1,87 @@
|
||||
.page {
|
||||
max-width: 42rem;
|
||||
margin: 0 auto;
|
||||
padding: 2.5rem 1.25rem 4rem;
|
||||
}
|
||||
|
||||
.crumb {
|
||||
font-family: var(--font-ui);
|
||||
font-size: 0.85rem;
|
||||
margin-bottom: 1.25rem;
|
||||
}
|
||||
|
||||
.heading {
|
||||
font-family: var(--font-display);
|
||||
font-size: clamp(1.6rem, 5vw, 2.25rem);
|
||||
font-weight: 700;
|
||||
line-height: 1.2;
|
||||
color: var(--text-primary);
|
||||
margin: 0 0 0.75rem;
|
||||
}
|
||||
|
||||
.byline {
|
||||
font-family: var(--font-ui);
|
||||
font-size: 0.9rem;
|
||||
color: var(--text-muted);
|
||||
margin: 0 0 2rem;
|
||||
}
|
||||
|
||||
.hero {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
border-radius: 10px;
|
||||
border: 1px solid var(--border);
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
/* Rich-text output: the editor emits plain elements, so these are styled by
|
||||
descendant selector rather than by class. */
|
||||
.prose p {
|
||||
font-family: var(--font-ui);
|
||||
font-size: 1rem;
|
||||
line-height: 1.7;
|
||||
color: var(--text-secondary);
|
||||
margin: 0 0 1.1rem;
|
||||
}
|
||||
|
||||
.prose h2 {
|
||||
font-family: var(--font-display);
|
||||
font-size: 1.25rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
margin: 2.25rem 0 0.75rem;
|
||||
}
|
||||
|
||||
.prose h3 {
|
||||
font-family: var(--font-display);
|
||||
font-size: 1.05rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
margin: 1.75rem 0 0.6rem;
|
||||
}
|
||||
|
||||
.prose ul,
|
||||
.prose ol {
|
||||
font-family: var(--font-ui);
|
||||
font-size: 1rem;
|
||||
line-height: 1.7;
|
||||
color: var(--text-secondary);
|
||||
padding-left: 1.35rem;
|
||||
margin: 0 0 1.1rem;
|
||||
}
|
||||
|
||||
.prose li { margin-bottom: 0.4rem; }
|
||||
|
||||
.prose a { color: var(--brand); font-weight: 500; }
|
||||
.prose a:hover { color: var(--brand-strong); }
|
||||
|
||||
.prose blockquote {
|
||||
border-left: 3px solid var(--border-strong);
|
||||
padding-left: 1rem;
|
||||
margin: 1.5rem 0;
|
||||
color: var(--text-muted);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.link { color: var(--brand); font-weight: 600; }
|
||||
.link:hover { color: var(--brand-strong); }
|
||||
@@ -0,0 +1,164 @@
|
||||
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 { 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;
|
||||
|
||||
interface HeroImage {
|
||||
url?: string | null;
|
||||
alt?: string | null;
|
||||
width?: number | null;
|
||||
height?: number | 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 ?? '')}
|
||||
/>
|
||||
),
|
||||
},
|
||||
});
|
||||
|
||||
async function findPost(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: Record<string, unknown>) {
|
||||
return {
|
||||
title: String(post.title),
|
||||
slug: String(post.slug),
|
||||
excerpt: String(post.excerpt),
|
||||
publishedAt: String(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 = post.heroImage as HeroImage | null | undefined;
|
||||
|
||||
return {
|
||||
title: String(post.title),
|
||||
description: String(post.excerpt),
|
||||
alternates: { canonical: absoluteUrl(`/blog/${post.slug}`) },
|
||||
openGraph: {
|
||||
type: 'article',
|
||||
title: String(post.title),
|
||||
description: String(post.excerpt),
|
||||
url: absoluteUrl(`/blog/${post.slug}`),
|
||||
publishedTime: String(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;
|
||||
const post = await findPost(slug);
|
||||
if (!post) notFound();
|
||||
|
||||
const summary = summarise(post as Record<string, unknown>);
|
||||
const hero = post.heroImage as HeroImage | null | undefined;
|
||||
|
||||
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}>
|
||||
<RichText data={post.content as never} converters={calloutConverters} />
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user