{post.title}
+ ++ By Tudor + {' · '} + +
+ + {/* + A plaindiff --git a/docs/superpowers/plans/2026-09-02-about-and-blog.md b/docs/superpowers/plans/2026-09-02-about-and-blog.md new file mode 100644 index 0000000..ec3249a --- /dev/null +++ b/docs/superpowers/plans/2026-09-02-about-and-blog.md @@ -0,0 +1,2278 @@ +# About Page and Payload Blog Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Give schoolcompare a named human author via an `/about` page and a +Payload-CMS-backed blog at `/blog`, both served from the existing Next.js app. + +**Architecture:** Payload 3 installs into the existing Next application and +serves `/admin` from the same container, storing content in its own `payload` +schema on the existing Postgres instance. Because Payload's admin panel needs +to be a *root* layout, the existing site routes first move into an +`app/(frontend)/` route group (URLs are unchanged — route groups are invisible +to routing). Blog pages use ISR with on-demand revalidation, because CI builds +the image with no database reachable. + +**Tech Stack:** Next.js 16 (App Router), React 19, TypeScript, Payload CMS 3 +(`@payloadcms/db-postgres`, `@payloadcms/next`, `@payloadcms/richtext-lexical`), +PostgreSQL, CSS Modules, Jest, Playwright, Docker. + +**Spec:** `docs/superpowers/specs/2026-09-02-about-and-blog-design.md` + +## Global Constraints + +These apply to every task. Copied from the spec. + +- **Author identity:** first name **Tudor** only. Never a surname. Never an + employer name. Never children's names. Location is "south-west London". +- **Credibility claim:** the About page must state plainly *"I'm not an + education expert."* Authority comes from lived experience (a parent going + through primary admissions) and from stated provenance of every number — + never from claimed educational qualifications. +- **Voice:** first person singular ("I built", never "we provide"); concrete + over general; state limits before someone else finds them; no mission + statements, no "passionate about", no invented team; short sentences. +- **Navigation is not touched.** `Navigation.tsx`'s mobile bottom tab bar + already carries four items; About and Blog are footer-only. Post pages link + to `/about` via the byline. +- **Design tokens only.** Use the existing CSS custom properties from + `app/globals.css` (`--bg-primary`, `--bg-card`, `--text-primary`, + `--text-secondary`, `--text-muted`, `--border`, `--brand`, `--brand-strong`, + `--brand-bg`, `--surface-sunken`). Never hardcode a hex value. Every new + surface must work in both light and dark themes. +- **Typography:** `var(--font-display)` (Manrope) for headings, + `var(--font-ui)` (Inter) for body. Never introduce a third family. +- **Absolute URLs** come from `absoluteUrl()` in `lib/site.ts`. Never hardcode + `https://www.schoolcompare.co.uk`. +- **No secrets committed.** `PAYLOAD_SECRET` and `DATABASE_URL` are supplied by + the environment. Staging must use different values from production. +- **CLAUDE.md rule:** user-facing behaviour changes ship with `e2e/` journey + updates in the same PR. +- **CLAUDE.md rule:** do not attempt to start a local server to test the + application — it does not work. Verification is via Jest, `npm run build`, + `npm run typecheck`, and the post-merge staging e2e gate. + +--- + +## Two structural findings that changed this plan + +Read these before starting. They were discovered while planning, not while +writing the spec, and they are the two places this work can go badly wrong. + +**1. The existing routes must move into `app/(frontend)/`.** +Next.js requires the *root* layout to render `` and `
`. Payload's +admin panel ships its own root layout that also renders ``/``. With +today's `app/layout.tsx` in place, Payload's layout would nest inside the +site's — inheriting the nav, footer, fonts and `ComparisonProvider` — and +produce nested `` elements. The supported pattern is multiple root +layouts via route groups, which requires that **no `app/layout.tsx` exists**. +So every current route moves into `app/(frontend)/`. Route groups do not appear +in URLs, so every public URL is byte-identical afterwards. The move is +mechanical but it touches every route in the app, which is why it is its own +task (Task 2) with the full test suite as its gate. + +**2. The `/api` collision is avoided by the remap alone.** +The spec proposed also adding `/cms-api` to the FastAPI proxy's exclusion list +as a second line of defence. That is unnecessary and would be dead code: the +proxy is `app/api/[...path]/route.ts`, which only ever matches `/api/*`, and +`/cms-api` is a sibling path that never reaches it. Remapping Payload's API +route to `/cms-api` is sufficient and complete. Do not add the exclusion. + +--- + +## File Structure + +**Moved (Task 2)** — `app/*` → `app/(frontend)/*`, unchanged in content: +`layout.tsx`, `page.tsx`, `globals.css`, `robots.ts`, `opengraph-image.tsx`, +`icon.png`, `apple-icon.png`, `rankings/`, `admissions/`, `compare/`, +`schools/`, `school/`, `api/`, `sitemaps/`, `sitemap.xml/`. + +**Created:** + +| Path | Responsibility | +|---|---| +| `nextjs-app/next.config.mjs` | Replaces `next.config.js`; ESM so it can wrap `withPayload()` | +| `nextjs-app/payload.config.ts` | Payload's single source of truth: DB, routes, collections | +| `nextjs-app/collections/Users.ts` | Admin auth collection, lockout policy, closed registration | +| `nextjs-app/collections/Posts.ts` | Blog post schema, drafts, revalidation hooks | +| `nextjs-app/collections/Media.ts` | Upload collection writing to `/app/media` | +| `nextjs-app/blocks/Callout.ts` | The "what this number doesn't tell you" block | +| `nextjs-app/app/(payload)/**` | Generated Payload admin + `/cms-api` routes | +| `nextjs-app/migrations/**` | Committed Payload schema migrations | +| `nextjs-app/app/(frontend)/about/page.tsx` + `.module.css` | The About page | +| `nextjs-app/app/(frontend)/blog/page.tsx` + `.module.css` | Blog index | +| `nextjs-app/app/(frontend)/blog/[slug]/page.tsx` + `.module.css` | Post page | +| `nextjs-app/app/(frontend)/blog/rss.xml/route.ts` | RSS feed | +| `nextjs-app/app/(frontend)/content-sitemap.xml/route.ts` | Sitemap for Next-owned URLs | +| `nextjs-app/lib/payload.ts` | Cached `getPayload()` accessor | +| `nextjs-app/lib/jsonld.ts` | `Person` / `Organization` / `BlogPosting` builders | + +**Modified:** `package.json`, `tsconfig.json`, `Dockerfile`, +`docker-compose.portainer.yml`, `docker-compose.portainer.staging.yml`, +`components/Footer.tsx`, `__tests__/app/metadata.test.ts`, +`__tests__/api/proxyDenylist.test.ts`, `__tests__/app/placeMetadata.test.ts`, +`e2e/tests/journeys.spec.ts`. + +--- + +### Task 1: Convert `next.config.js` to ESM + +`withPayload()` is ESM-only, so the config must become `.mjs`. This file also +carries the rule that keeps staging out of Google's index — the highest-value +thing in the repo to break silently — so it is converted first, on its own, +behind a test. + +**Files:** +- Create: `nextjs-app/__tests__/app/nextConfig.test.ts` +- Create: `nextjs-app/next.config.mjs` +- Delete: `nextjs-app/next.config.js` + +**Interfaces:** +- Consumes: nothing. +- Produces: `next.config.mjs` default-exporting the Next config object. Task 3 + wraps this export in `withPayload()`. + +- [ ] **Step 1: Write the failing test** + +Create `nextjs-app/__tests__/app/nextConfig.test.ts`: + +```ts +/** + * next.config.mjs carries the staging noindex rule. Breaking it turns + * stx.schoolcompare.co.uk into a fully crawlable duplicate of production, + * and nothing else in the suite would notice. + */ +import nextConfig from '@/next.config.mjs'; + +describe('next.config.mjs', () => { + it('keeps the staging host out of the index', async () => { + const headers = await nextConfig.headers(); + const stagingRule = headers.find((rule) => + rule.has?.some( + (cond) => cond.type === 'host' && cond.value === 'stx.schoolcompare.co.uk', + ), + ); + expect(stagingRule).toBeDefined(); + expect(stagingRule.headers).toContainEqual({ + key: 'X-Robots-Tag', + value: 'noindex, nofollow', + }); + }); + + it('still emits standalone output for the Docker runner', () => { + expect(nextConfig.output).toBe('standalone'); + }); + + it('still traces the share-card fonts into the standalone bundle', () => { + expect(nextConfig.outputFileTracingIncludes['/opengraph-image']).toEqual([ + './assets/**', + ]); + }); + + it('still allows the analytics subdomain to frame the site', async () => { + const headers = await nextConfig.headers(); + const csp = headers + .flatMap((rule) => rule.headers) + .find((header) => header.key === 'Content-Security-Policy'); + expect(csp.value).toContain('https://analytics.schoolcompare.co.uk'); + }); +}); +``` + +- [ ] **Step 2: Run the test to verify it fails** + +```bash +cd nextjs-app && npm test -- __tests__/app/nextConfig.test.ts +``` + +Expected: FAIL — cannot resolve `@/next.config.mjs` (the file does not exist yet). + +If it instead fails with an ESM parse error after Step 3, add `'mjs'` to +`moduleFileExtensions` in `jest.config.js`: +```js +moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'mjs', 'json', 'node'], +``` + +- [ ] **Step 3: Convert the config** + +```bash +cd nextjs-app && git mv next.config.js next.config.mjs +``` + +Then edit `next.config.mjs`: change the final line from +`module.exports = nextConfig;` to `export default nextConfig;`. + +Change nothing else. Every comment block in that file documents a past +production incident — the baked-in `FASTAPI_URL`, the staging `X-Robots-Tag` +reasoning, the `/icon.png` cache 404 — and all of it must survive verbatim. + +- [ ] **Step 4: Run the test to verify it passes** + +```bash +cd nextjs-app && npm test -- __tests__/app/nextConfig.test.ts +``` + +Expected: PASS, 4 tests. + +- [ ] **Step 5: Verify the whole suite and the build still pass** + +```bash +cd nextjs-app && npm test && npm run typecheck && npm run build +``` + +Expected: all green. `next/jest` resolves `.mjs` configs, so the existing +suite is unaffected. + +- [ ] **Step 6: Commit** + +```bash +git add nextjs-app/next.config.mjs nextjs-app/__tests__/app/nextConfig.test.ts nextjs-app/jest.config.js +git commit -m "build(next): convert the config to ESM so Payload can wrap it + +withPayload() is ESM-only. This file also carries the rule that keeps +staging out of the index, so the conversion goes in on its own behind a +test that asserts the rule survived." +``` + +--- + +### Task 2: Move site routes into an `app/(frontend)` route group + +This creates room for Payload's admin panel to be its own root layout. No URL +changes: route groups are invisible to routing. + +**Files:** +- Move: everything currently under `nextjs-app/app/` → `nextjs-app/app/(frontend)/` +- Modify: `nextjs-app/__tests__/app/metadata.test.ts:1-4` +- Modify: `nextjs-app/__tests__/app/placeMetadata.test.ts:1` +- Modify: `nextjs-app/__tests__/api/proxyDenylist.test.ts:11` + +**Interfaces:** +- Consumes: Task 1's `next.config.mjs`. +- Produces: no `app/layout.tsx` at the app root — the precondition Task 3 + requires. Site routes importable as `@/app/(frontend)/Who's behind this
++ I'm a parent in south-west London. When we started looking at + primary schools, I found the information I needed was all published — + and almost impossible to hold in one place. +
+ ++ SATs results were in one government table. Ofsted judgements were in a + separate service, in a format that had just changed. Admissions + distances were buried in council PDFs, a different one per borough, + each with its own layout. I ended up building a spreadsheet, and then + I got tired of the spreadsheet. +
+ ++ So I built this instead. It pulls the official figures into one place + and puts them side by side, which is what I wanted and could not find. +
+ ++ I want to be straightforward about that. I'm not a teacher, a + governor, or an education researcher. I have no qualification that + makes my opinion about a school worth more than yours. +
+ ++ What I do have is the problem itself — I'm going through primary + admissions right now — and a working knowledge of data, which is what + I do for a living. That combination is enough to take published + figures and present them honestly. It is not enough to tell you which + school is right for your child, and this site never tries to. +
+ ++ Everything here is official published data: Key Stage 2 and Key Stage + 4 results and school characteristics from the Department for + Education, inspection outcomes from Ofsted, and admissions data from + local authorities. Nothing is estimated, modelled or filled in. Where + a figure is missing, the page says so rather than showing a guess. +
+ ++ This is an independent site. It is not affiliated with the Department + for Education or with Ofsted, and nobody pays to appear on it or to + rank higher. +
+ ++ A school is not its results. The figures here describe one year group, + on a handful of days, measured in a way that suits national statistics + rather than your child. A small cohort makes percentages swing wildly + — in a class of thirty, one pupil is more than three points. Results + say nothing at all about whether a child will be happy somewhere. +
+ ++ I try to build that honesty into the site rather than just say it + here. Special schools and pupil referral units are never compared + against a mainstream national average, because that comparison is + meaningless and makes good schools look like failing ones. Where a + number is unreliable, the aim is for the page to tell you before you + draw a conclusion from it. +
+ ++ Tell me and I'll fix it. Genuinely — if a figure looks wrong, or + a page gives a misleading impression of a school, I want to know. + It's the fastest way this gets better. +
+ + +Blog
++ What school performance data shows, what it doesn't, and how to + read it without being misled. Written by{' '} + Tudor. +
+No posts yet.
+ ) : ( +{post.excerpt}
++ By Tudor + {' · '} + +
+ + {/* + A plain