From b793640507d669872bd3af02e0964e404e1cf33f Mon Sep 17 00:00:00 2001 From: Tudor Date: Wed, 2 Sep 2026 16:28:58 +0100 Subject: [PATCH] feat(blog): add the blog index, post pages, RSS and content sitemap MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_017YmbBhr8s7GusjDE12hrZM --- e2e/tests/journeys.spec.ts | 39 +++++ nextjs-app/__tests__/app/blogMetadata.test.ts | 53 ++++++ nextjs-app/__tests__/app/robots.test.ts | 9 + .../__tests__/payload/collections.test.ts | 10 +- .../app/(frontend)/blog/Blog.module.css | 76 ++++++++ .../(frontend)/blog/[slug]/Post.module.css | 87 ++++++++++ .../app/(frontend)/blog/[slug]/page.tsx | 164 ++++++++++++++++++ nextjs-app/app/(frontend)/blog/page.tsx | 76 ++++++++ .../app/(frontend)/blog/rss.xml/route.ts | 52 ++++++ .../(frontend)/content-sitemap.xml/route.ts | 52 ++++++ nextjs-app/app/robots.ts | 5 +- nextjs-app/collections/Posts.ts | 17 +- .../components/blog/CalloutBlock.module.css | 21 +++ nextjs-app/components/blog/CalloutBlock.tsx | 14 ++ nextjs-app/lib/jsonld.ts | 44 +++++ 15 files changed, 705 insertions(+), 14 deletions(-) create mode 100644 nextjs-app/__tests__/app/blogMetadata.test.ts create mode 100644 nextjs-app/app/(frontend)/blog/Blog.module.css create mode 100644 nextjs-app/app/(frontend)/blog/[slug]/Post.module.css create mode 100644 nextjs-app/app/(frontend)/blog/[slug]/page.tsx create mode 100644 nextjs-app/app/(frontend)/blog/page.tsx create mode 100644 nextjs-app/app/(frontend)/blog/rss.xml/route.ts create mode 100644 nextjs-app/app/(frontend)/content-sitemap.xml/route.ts create mode 100644 nextjs-app/components/blog/CalloutBlock.module.css create mode 100644 nextjs-app/components/blog/CalloutBlock.tsx diff --git a/e2e/tests/journeys.spec.ts b/e2e/tests/journeys.spec.ts index 49cecac..314fc07 100644 --- a/e2e/tests/journeys.spec.ts +++ b/e2e/tests/journeys.spec.ts @@ -2583,3 +2583,42 @@ test('the about page names a human author and is reachable from the footer', asy // First name only — a surname here would be the one place it leaks. expect(jsonLd).not.toMatch(/familyName/); }); + +test('the blog lists posts and each one renders with a byline', async ({ page }) => { + await page.goto('/blog'); + await expect(page.getByRole('heading', { level: 1 })).toBeVisible(); + + const postLinks = page.locator('a[href^="/blog/"]'); + // Data invariant: staging must carry at least one published post. If this + // fails, the environment has no content rather than the code being broken. + expect(await postLinks.count()).toBeGreaterThan(0); + + await postLinks.first().click(); + await page.waitForURL(/\/blog\/.+/); + await expect(page.getByRole('heading', { level: 1 })).toBeVisible(); + await expect(page.getByText(/^By Tudor/)).toBeVisible(); + + const jsonLd = await page + .locator('script[type="application/ld+json"]') + .first() + .textContent(); + expect(jsonLd).toContain('"BlogPosting"'); +}); + +test('the admin panel is not indexable', async ({ page }) => { + const response = await page.request.get('/admin'); + expect(response.headers()['x-robots-tag']).toContain('noindex'); +}); + +test('the content sitemap lists the about page and is advertised in robots', async ({ page }) => { + const sitemap = await page.request.get('/content-sitemap.xml'); + expect(sitemap.ok()).toBeTruthy(); + expect(await sitemap.text()).toContain('/about'); + + // The school corpus sitemap is proxied from FastAPI; this one is Next's. + // robots.txt must advertise both or the blog never gets discovered. + const robots = await page.request.get('/robots.txt'); + const body = await robots.text(); + expect(body).toContain('/sitemap.xml'); + expect(body).toContain('/content-sitemap.xml'); +}); diff --git a/nextjs-app/__tests__/app/blogMetadata.test.ts b/nextjs-app/__tests__/app/blogMetadata.test.ts new file mode 100644 index 0000000..ac3ee41 --- /dev/null +++ b/nextjs-app/__tests__/app/blogMetadata.test.ts @@ -0,0 +1,53 @@ +/** + * The blog index imports getCachedPayload, which pulls in Payload — ESM-only, + * and next/jest will not transform node_modules. Mocking that one module keeps + * the page's metadata testable without loading the CMS; the mock is never + * called, because `metadata` is a static export evaluated at import time. + */ +jest.mock('@/lib/payload', () => ({ getCachedPayload: jest.fn() })); + +import { metadata } from '@/app/(frontend)/blog/page'; +import { blogPostingJsonLd, breadcrumbJsonLd } from '@/lib/jsonld'; + +const post = { + title: 'What the data cannot tell you', + slug: 'what-the-data-cannot-tell-you', + excerpt: 'Results describe one year group on a handful of days.', + publishedAt: '2026-09-15T00:00:00.000Z', +}; + +describe('/blog metadata', () => { + it('canonicalises to the bare path', () => { + expect(metadata.alternates?.canonical) + .toBe('https://www.schoolcompare.co.uk/blog'); + }); +}); + +describe('BlogPosting structured data', () => { + it('names the same Person entity the about page declares', () => { + // By @id, not by repeating the person: search engines must resolve every + // post and the about page to one author entity, or the site has several. + const ld = blogPostingJsonLd(post); + expect(ld['@type']).toBe('BlogPosting'); + expect(ld.author['@id']).toBe('https://www.schoolcompare.co.uk/about#tudor'); + expect(ld.publisher['@id']).toBe('https://www.schoolcompare.co.uk#organization'); + }); + + it('carries a self-referencing canonical url and the publish date', () => { + const ld = blogPostingJsonLd(post); + expect(ld.url).toBe( + 'https://www.schoolcompare.co.uk/blog/what-the-data-cannot-tell-you', + ); + expect(ld.datePublished).toBe('2026-09-15T00:00:00.000Z'); + }); +}); + +describe('breadcrumbs', () => { + it('places the post under the blog index', () => { + const ld = breadcrumbJsonLd(post); + expect(ld.itemListElement[0].item).toBe('https://www.schoolcompare.co.uk/blog'); + expect(ld.itemListElement[1].item).toBe( + 'https://www.schoolcompare.co.uk/blog/what-the-data-cannot-tell-you', + ); + }); +}); diff --git a/nextjs-app/__tests__/app/robots.test.ts b/nextjs-app/__tests__/app/robots.test.ts index 570e135..da0126f 100644 --- a/nextjs-app/__tests__/app/robots.test.ts +++ b/nextjs-app/__tests__/app/robots.test.ts @@ -9,3 +9,12 @@ describe('robots.txt', () => { ); }); }); + +describe('sitemap discovery', () => { + it('lists both the proxied school sitemap and the Next-owned content sitemap', () => { + expect(robots().sitemap).toEqual([ + 'https://www.schoolcompare.co.uk/sitemap.xml', + 'https://www.schoolcompare.co.uk/content-sitemap.xml', + ]); + }); +}); diff --git a/nextjs-app/__tests__/payload/collections.test.ts b/nextjs-app/__tests__/payload/collections.test.ts index c0cc79f..ec1e37e 100644 --- a/nextjs-app/__tests__/payload/collections.test.ts +++ b/nextjs-app/__tests__/payload/collections.test.ts @@ -33,13 +33,13 @@ describe('posts collection', () => { 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. + it('revalidates the post page when a post changes or is deleted', () => { + // /blog/[slug] is ISR — generated on first request and cached — so an edit + // to an already-published post would otherwise not appear until the + // revalidate window expired, up to an hour of a writer concluding that + // saving is broken. The index and feeds are force-dynamic and need no hook. expect(POSTS).toContain('afterChange'); expect(POSTS).toContain('afterDelete'); - expect(POSTS).toMatch(/revalidatePath\('\/blog'\)/); expect(POSTS).toMatch(/revalidatePath\(`\/blog\/\$\{[^}]+\}`\)/); }); }); diff --git a/nextjs-app/app/(frontend)/blog/Blog.module.css b/nextjs-app/app/(frontend)/blog/Blog.module.css new file mode 100644 index 0000000..d214bc7 --- /dev/null +++ b/nextjs-app/app/(frontend)/blog/Blog.module.css @@ -0,0 +1,76 @@ +.page { + max-width: 42rem; + margin: 0 auto; + padding: 2.5rem 1.25rem 4rem; +} + +.header { margin-bottom: 2.5rem; } + +.kicker { + font-family: var(--font-ui); + font-size: 0.75rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.06em; + color: var(--brand); + margin: 0 0 0.35rem; +} + +.heading { + font-family: var(--font-display); + font-size: clamp(1.5rem, 4vw, 2rem); + font-weight: 700; + line-height: 1.2; + color: var(--text-primary); + margin: 0 0 0.75rem; +} + +.standfirst { + font-family: var(--font-ui); + font-size: 1.05rem; + line-height: 1.65; + color: var(--text-secondary); + margin: 0; +} + +.list { list-style: none; padding: 0; margin: 0; } + +.item { + padding: 1.5rem 0; + border-top: 1px solid var(--border); +} + +.date { + font-family: var(--font-ui); + font-size: 0.8rem; + color: var(--text-muted); + /* Inter's tabular numerals keep a column of dates aligned. */ + font-variant-numeric: tabular-nums; +} + +.itemTitle { + font-family: var(--font-display); + font-size: 1.25rem; + font-weight: 600; + line-height: 1.3; + margin: 0.35rem 0 0.5rem; +} + +.itemLink { color: var(--text-primary); text-decoration: none; } +.itemLink:hover { color: var(--brand); } + +.excerpt { + font-family: var(--font-ui); + font-size: 0.95rem; + line-height: 1.65; + color: var(--text-secondary); + margin: 0; +} + +.empty { + font-family: var(--font-ui); + color: var(--text-muted); +} + +.link { color: var(--brand); font-weight: 600; } +.link:hover { color: var(--brand-strong); } diff --git a/nextjs-app/app/(frontend)/blog/[slug]/Post.module.css b/nextjs-app/app/(frontend)/blog/[slug]/Post.module.css new file mode 100644 index 0000000..365fc30 --- /dev/null +++ b/nextjs-app/app/(frontend)/blog/[slug]/Post.module.css @@ -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); } diff --git a/nextjs-app/app/(frontend)/blog/[slug]/page.tsx b/nextjs-app/app/(frontend)/blog/[slug]/page.tsx new file mode 100644 index 0000000..843eebc --- /dev/null +++ b/nextjs-app/app/(frontend)/blog/[slug]/page.tsx @@ -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 } }) => ( + + ), + }, +}); + +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) { + 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 { + 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); + const hero = post.heroImage as HeroImage | null | undefined; + + const jsonLd = { + '@context': 'https://schema.org', + '@graph': [ + blogPostingJsonLd(summary), + breadcrumbJsonLd(summary), + personJsonLd(), + organizationJsonLd(), + ], + }; + + return ( +
+