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,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',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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',
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -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\/\$\{[^}]+\}`\)/);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in new issue
Block a user