feat: a named author, an About page and a Payload blog #140

Merged
tudor merged 12 commits from feat/about-and-blog into main 2026-09-02 16:04:59 +00:00
69 changed files with 9638 additions and 238 deletions

No files matched your search

+40
View File
@@ -23,6 +23,46 @@ Key files:
- `backend/data_loader.py` - Data queries, geocoding, legacy DataFrame compatibility - `backend/data_loader.py` - Data queries, geocoding, legacy DataFrame compatibility
- `backend/schemas.py` - Column mappings, metric definitions, LA code mappings - `backend/schemas.py` - Column mappings, metric definitions, LA code mappings
### Content / CMS (Payload)
Payload CMS runs **inside** the Next.js app — one image, one container, no
separate service. It powers `/blog`; `/about` is a plain coded page.
- **Admin panel:** `/admin`. The only authenticated surface on the site.
`noindex` via both `robots.txt` and `X-Robots-Tag`.
- **CMS API:** `/cms-api`, **not** `/api`. `/api/*` is a catch-all proxy to
FastAPI (`app/(frontend)/api/[...path]`) which would silently swallow every
admin call and forward it to the backend. Mount points are defined once in
`lib/payloadRoutes.ts`.
- **Database:** the existing Postgres, in its own `payload` schema, so no
pipeline operation on `public` — including
`scripts/migrate_csv_to_db.py --drop` — can reach blog content.
- **Uploads:** the `payload_media` Docker volume at `/app/media`. Not
reproducible from the pipeline; must be backed up.
- **New env vars:** `DATABASE_URL` and `PAYLOAD_SECRET` on the frontend service.
Staging must use a different `PAYLOAD_SECRET` from production.
- Publishing workflow and house style: `nextjs-app/docs/PUBLISHING.md`.
### Two route groups
`nextjs-app/app/` has no root `layout.tsx`. It cannot: Payload's admin panel
ships its own root layout rendering `<html>`/`<body>`, and Next permits
multiple root layouts only when no `app/layout.tsx` exists.
- `app/(frontend)/` — the site. Its `layout.tsx` is the site's root layout.
- `app/(payload)/` — the admin panel and `/cms-api`.
Route groups are invisible to routing, so every public URL is unchanged.
**The metadata file conventions stay at the `app/` root** — `robots.ts`,
`opengraph-image.tsx`, `icon.png`, `apple-icon.png`. Inside a route group Next
treats them as segment-scoped: it renames `/icon.png` to `/icon-<hash>.png` and
drops `/robots.txt` entirely. Route handlers are unaffected.
The build must succeed with `DATABASE_URL` unset, because CI builds it that
way. Never call `getCachedPayload()` at module scope, and never add
`generateStaticParams` to a DB-backed route.
### Frontend (Vanilla JS) ### Frontend (Vanilla JS)
- Single-page application with hash-based routing - Single-page application with hash-based routing
- Chart.js for data visualization - Chart.js for data visualization
+16
View File
@@ -18,6 +18,10 @@
# TYPESENSE_SEARCH_KEY — Typesense search-only key (exposed to frontend) # TYPESENSE_SEARCH_KEY — Typesense search-only key (exposed to frontend)
# UNLEASH_URL — http://<unleash-ip>:4242/api (empty = all flags off) # UNLEASH_URL — http://<unleash-ip>:4242/api (empty = all flags off)
# UNLEASH_API_TOKEN — Unleash *client* token, environment: development # UNLEASH_API_TOKEN — Unleash *client* token, environment: development
# PAYLOAD_SECRET — Payload CMS encryption secret. REQUIRED: long and
# random, and DIFFERENT from production's. Sharing
# it would let a staging session authenticate
# against production.
# AIRFLOW_ADMIN_USER — Airflow admin username (default: admin) # AIRFLOW_ADMIN_USER — Airflow admin username (default: admin)
# AIRFLOW_ADMIN_PASSWORD — Airflow admin password. REQUIRED: the api-server # AIRFLOW_ADMIN_PASSWORD — Airflow admin password. REQUIRED: the api-server
# refuses to start without it, rather than falling # refuses to start without it, rather than falling
@@ -89,9 +93,20 @@ services:
- FASTAPI_URL=http://backend:80/api - FASTAPI_URL=http://backend:80/api
- TYPESENSE_URL=http://typesense:8108 - TYPESENSE_URL=http://typesense:8108
- TYPESENSE_API_KEY=${TYPESENSE_SEARCH_KEY:-changeme} - TYPESENSE_API_KEY=${TYPESENSE_SEARCH_KEY:-changeme}
# Payload CMS runs inside this container, in the `payload` schema of the
# staging database. Staging has its own stack, its own Postgres and its
# own admin account — never production's.
- DATABASE_URL=postgresql://${DB_USERNAME}:${DB_PASSWORD}@sc_database:5432/${DB_DATABASE_NAME}
- PAYLOAD_SECRET=${PAYLOAD_SECRET:?set PAYLOAD_SECRET in the staging Portainer stack environment}
volumes:
# Portainer prefixes volume names with the stack name, so this is
# automatically isolated from production's media.
- payload_media:/app/media
depends_on: depends_on:
backend: backend:
condition: service_healthy condition: service_healthy
sc_database:
condition: service_healthy
networks: networks:
backend: {} backend: {}
macvlan: macvlan:
@@ -242,3 +257,4 @@ volumes:
typesense_data: typesense_data:
airflow_logs: airflow_logs:
unleash_cache: unleash_cache:
payload_media:
+16
View File
@@ -9,6 +9,9 @@
# TYPESENSE_SEARCH_KEY — Typesense search-only key (exposed to frontend) # TYPESENSE_SEARCH_KEY — Typesense search-only key (exposed to frontend)
# UNLEASH_URL — http://<unleash-ip>:4242/api (empty = all flags off) # UNLEASH_URL — http://<unleash-ip>:4242/api (empty = all flags off)
# UNLEASH_API_TOKEN — Unleash *client* token, environment: production # UNLEASH_API_TOKEN — Unleash *client* token, environment: production
# PAYLOAD_SECRET — Payload CMS encryption secret. REQUIRED: long and
# random. Changing it invalidates every admin
# session. Staging MUST use a different value.
# AIRFLOW_ADMIN_USER — Airflow admin username (default: admin) # AIRFLOW_ADMIN_USER — Airflow admin username (default: admin)
# AIRFLOW_ADMIN_PASSWORD — Airflow admin password. REQUIRED: the api-server # AIRFLOW_ADMIN_PASSWORD — Airflow admin password. REQUIRED: the api-server
# refuses to start without it, rather than falling # refuses to start without it, rather than falling
@@ -78,9 +81,21 @@ services:
- FASTAPI_URL=http://backend:80/api - FASTAPI_URL=http://backend:80/api
- TYPESENSE_URL=http://typesense:8108 - TYPESENSE_URL=http://typesense:8108
- TYPESENSE_API_KEY=${TYPESENSE_SEARCH_KEY:-changeme} - TYPESENSE_API_KEY=${TYPESENSE_SEARCH_KEY:-changeme}
# Payload CMS runs inside this container. It reaches Postgres over the
# `backend` network and keeps its tables in the `payload` schema, so no
# pipeline operation on `public` can touch blog content.
- DATABASE_URL=postgresql://${DB_USERNAME}:${DB_PASSWORD}@sc_database:5432/${DB_DATABASE_NAME}
# Same :? form as AIRFLOW_ADMIN_PASSWORD: refuse to start rather than
# boot with an empty secret and silently accept forged sessions.
- PAYLOAD_SECRET=${PAYLOAD_SECRET:?set PAYLOAD_SECRET in the Portainer stack environment}
volumes:
# Blog images. Not reproducible from the pipeline — must be backed up.
- payload_media:/app/media
depends_on: depends_on:
backend: backend:
condition: service_healthy condition: service_healthy
sc_database:
condition: service_healthy
networks: networks:
backend: {} backend: {}
macvlan: macvlan:
@@ -231,3 +246,4 @@ volumes:
typesense_data: typesense_data:
airflow_logs: airflow_logs:
unleash_cache: unleash_cache:
payload_media:
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,366 @@
# Giving schoolcompare a human author: an About page and a blog
**Date:** 2026-09-02
**Status:** Design — awaiting review
**Scope:** A named author for the site, an `/about` page, and a Payload-CMS-backed
blog at `/blog`.
## Why
The site reads as synthetic. Not because of its tone, but because of three
specific absences:
1. **Nobody is accountable for the numbers.** There is no author, no statement
of why the site exists, and no one who can be wrong. The only human trace on
the entire site is `contact@schoolcompare.co.uk` in the footer.
2. **No visible judgement.** Every figure is presented as though it fell out of
a machine. Hundreds of editorial decisions went into this codebase — which
metrics to show, when a benchmark is invalid, what to suppress — and not one
of them is visible to a reader. `isSpecialSchool()` silently drops the
England comparison for special schools and PRUs because that comparison is
meaningless; nowhere does the site *say* so.
3. **The voice is institutional third person.** "schoolcompare brings it all
into one place." "Built for parents, governors, journalists." That is
brochure register, and it is precisely the register that machine-generated
content defaults to.
There is a second, independent reason. The SEO programme
(`2026-08-20-seo-programme-design.md`) defines eight workstreams and none of
them address E-E-A-T or authorship. School performance data is YMYL territory;
an anonymous site republishing DfE figures has no authorship signal at all. This
work fills that hole, and the blog gives W6 (explainer content) somewhere to
live.
### The failure mode to avoid
The standard fix — a stock photo and "Hi, I'm Tudor, and I'm passionate about
education!" — reads as *more* synthetic than the current coldness. Manufactured
warmth is a stronger machine-tell than plain institutional voice. Everything
here has to be specific, occasionally awkward, and willing to be unflattering,
or it makes the problem worse.
## Positioning
The author is **Tudor**: first name only, real photograph, no surname, no
employer named.
The credibility claim is deliberately **not** educational expertise. The About
page states plainly: *"I'm not an education expert."* Authority comes from two
things that are actually true:
- **Experience.** A parent going through primary admissions in south-west London
right now. Google's E-E-A-T leads with Experience, and lived experience of the
thing is exactly what the DfE's own service lacks.
- **Method.** Every number's provenance is stated, so a reader can check the
site rather than trust it.
This is more durable than borrowed expertise: it cannot be undermined by someone
noticing the author has no teaching qualification.
**Consequence for the design.** A `Person` entity with no surname is a weak
search signal and cannot be corroborated off-site. The credibility load
therefore shifts onto the methodology being visibly rigorous. That is a design
constraint, not a caveat — it is why the About page carries a substantial
"how this is built and where it can be wrong" section rather than a short bio.
### Voice rules
Applied to About and every post. Recorded here so the voice does not drift.
- First person singular. "I built", not "we provide".
- Concrete over general. "when we were looking at schools in Wandsworth" beats
any amount of stated warmth.
- State limits before someone else finds them. Every post that presents a
metric says what it does not show.
- No mission statements, no "passionate about", no invented team.
- Short sentences. The existing code comments in this repo are already written
this way; the prose should match.
## Scope
**In:**
- `/about` — a coded page (not CMS-managed).
- `/blog` and `/blog/[slug]` — Payload-backed, with an index and post pages.
- Payload CMS installed into the existing Next application.
- Footer and navigation links to both.
- `Person`, `Organization`, `BlogPosting`, `BreadcrumbList` JSON-LD.
- RSS feed and sitemap integration.
- One first post, so the blog does not launch empty.
**Out (deliberately):**
- Rewriting existing homepage/how-it-works copy into first person. Worth doing,
but it would double the review surface of this PR. Separate change.
- In-product signed notes on school pages (the "distributed humanity" idea).
Revisit once About and the blog exist.
- Comments, newsletter, author accounts beyond one.
- A team page. There is no team.
## Architecture
### Topology
Payload 3 installs **into the existing Next application** and serves `/admin`
from the same container. One image, one deploy, no new service. This is
Payload 3's native model and it makes on-demand revalidation trivial, because
the CMS hooks run in the same process as the Next cache.
Accepted costs: the public site's image now carries Payload, so a CMS security
patch redeploys the whole site; and the image grows substantially.
### Two collisions that must be handled
**1. `/api` is already taken.** `app/api/[...path]/route.ts` is a catch-all that
proxies `/api/*` to FastAPI at runtime. Payload's default API route is also
`/api`. Left alone, these fight, and the failure is not clean — the catch-all
would swallow Payload's admin API calls and forward them to FastAPI.
Payload's API route is therefore remapped:
```ts
routes: { api: '/cms-api', admin: '/admin' }
```
with its route group at `app/(payload)/cms-api/[...slug]/route.ts`. The
`/cms-api` prefix must also be added to the FastAPI proxy's excluded-paths list
as a defensive second line.
**2. `next.config.js` is CommonJS.** Payload's `withPayload()` wrapper is ESM
only. The config must become `next.config.mjs`, converting `module.exports` to
`export default` and wrapping the export. All existing content — the standalone
output, `outputFileTracingIncludes`, the staging `X-Robots-Tag` header block,
the CSP — carries over unchanged. This is mechanical but it touches the file
that controls staging's noindex, so it needs care and an explicit test.
### Database
Payload uses the existing `sc_database` Postgres instance, in its **own
`payload` schema**:
```ts
db: postgresAdapter({
pool: { connectionString: process.env.DATABASE_URL },
schemaName: 'payload',
})
```
The frontend container is already on the `backend` Docker network, so it can
reach `sc_database:5432` with no networking change. It needs a new
`DATABASE_URL` environment variable.
Schema isolation is not cosmetic. `public` currently holds the application
tables and Airflow's metadata, and `scripts/migrate_csv_to_db.py --drop` exists
to drop and reimport. Blog content living in its own schema means no data
pipeline operation can destroy it.
**Verified 2026-09-02** (this was an open question when the spec was written).
`--drop` calls `run_full_migration()` in `backend/migration.py`, which drops
exactly two tables by name:
```python
ks2_tables = ["school_results", "schools"]
for tname in ks2_tables:
if tname in existing:
Base.metadata.tables[tname].drop(bind=engine)
```
There is no `Base.metadata.drop_all()` anywhere in `backend/`, and no
`DROP SCHEMA`. The only other drop is `_apply_schema_drops()`, a single
schema-qualified `DROP TABLE IF EXISTS marts.fact_parent_view CASCADE`.
Nothing sets `search_path`, so the SQLAlchemy metadata resolves to `public`,
and `inspector.get_table_names()` does not even enumerate other schemas.
So the guarantee is stronger than schema isolation alone: `--drop` targets two
named tables that Payload does not have, and would not reach `posts`, `media`
or `users` even if they shared a schema. The `payload` schema remains the right
choice — it protects against a *future* broadening of that script rather than
today's behaviour — but the safety claim rests on verified code, not on
assumption.
Putting CMS tables in this instance is consistent with existing practice —
Airflow already stores its metadata there.
### Migrations
Payload's Postgres adapter auto-pushes schema in development and requires
explicit migrations in production. Use `prodMigrations`, which runs pending
migrations during server initialisation:
```ts
db: postgresAdapter({ /* ... */, prodMigrations: migrations })
```
This is preferred over a one-shot init container (the `airflow-init` pattern)
because the app is a single long-running process and there is no ordering
problem to solve. Migration files are generated with `payload migrate:create`
and committed, so schema changes travel through the same PR and staging gate as
code.
### Media
Uploads go to a Docker named volume, consistent with `postgres_data`,
`typesense_data` and `airflow_logs`.
- `staticDir` must be an **absolute** path in Payload 3: `/app/media`.
- The container runs as `nextjs` (uid 1001). The Dockerfile must
`mkdir -p /app/media && chown nextjs:nodejs /app/media` **before** the volume
is mounted, or Docker will create the mountpoint root-owned and every upload
will fail with EACCES.
- `sharp` moves from `devDependencies` to `dependencies` — Payload needs it at
runtime to generate `imageSizes`.
- The volume must be added to the backup routine alongside Postgres. A blog
post's images are not reproducible from the pipeline.
### Rendering
**Constraint:** CI builds the image with no database reachable. Blog pages
therefore cannot use build-time `generateStaticParams` — that would either fail
the build or bake in an empty post list.
Instead: ISR. Post and index pages declare a `revalidate` window and render on
first request, with Payload `afterChange` / `afterDelete` hooks calling
`revalidatePath('/blog')` and `revalidatePath('/blog/' + slug)` for immediate
publication. Because Payload runs in the same process, the hook calls
`revalidatePath` from `next/cache` directly — no webhook, no shared secret.
The ISR cache lives on container disk and is cleared by a redeploy. For a
single container serving a handful of posts this is fine.
### Collections
- **`posts`** — `title`, `slug`, `publishedAt`, `excerpt`, `heroImage`
(relation to `media`), `content` (Lexical rich text), `seo` group
(`metaTitle`, `metaDescription`), `_status` (drafts enabled).
- **`media`** — upload collection, `alt` required, `imageSizes` for thumbnail
and hero widths, public read access.
- **`users`** — Payload's auth collection. One account. Public creation
disabled.
Drafts are enabled so posts can be written over several sittings and previewed
before publication.
**Payload Blocks** are how posts embed live product components — a real trend
chart or comparison table inside a post, rendered from live data rather than
screenshotted. This is the main thing the CMS has to earn back against
file-based MDX, and it directly serves the goal: showing judgement in context.
Ship with one block (a callout/aside for "what this number doesn't tell you");
add a live-chart block once a post needs it.
### Security
`/admin` is the first authenticated surface on this site. Public, hardened:
- `PAYLOAD_SECRET` — long, random, set in the Portainer stack environment, never
committed. The same variable must exist in staging with a *different* value.
- Strong unique password on the single admin account.
- Login rate limiting via Payload's `maxLoginAttempts` / `lockTime`.
- `X-Robots-Tag: noindex, nofollow` on `/admin/*` and `/cms-api/*`, and a
`robots.ts` disallow. The admin panel must never be indexed.
- Public user creation disabled; no open registration.
- Verify the existing CSP `frame-ancestors` directive does not break the admin
panel.
Residual risk, accepted: a future Payload authentication CVE is live against the
public internet. Mitigation is prompt patching, which the staging→prod pipeline
already supports. If this becomes uncomfortable, restricting `/admin` at the
proxy to LAN/VPN is a one-line change later.
Staging note: staging runs the same image on `stx.`, so it gets its own admin
panel and its own database. It must have its own `PAYLOAD_SECRET` and its own
credentials — never production's.
## Deployment changes
- `nextjs-app/Dockerfile` — create and chown `/app/media`; ensure Payload's
admin bundle and `sharp` survive standalone output file tracing.
- `docker-compose.portainer.yml` and the staging equivalent — add
`DATABASE_URL` and `PAYLOAD_SECRET` to the `frontend` service, add a
`payload_media` volume mounted at `/app/media`, and add
`depends_on: sc_database`.
- Document both new environment variables in the compose header comment block,
which is where this stack records its configuration.
## SEO
- `Person` (Tudor, with photo) and `Organization` JSON-LD on `/about`.
- `BlogPosting` + `BreadcrumbList` on post pages, with `author` referencing the
same `Person`.
- Canonical URLs on `/blog` and every post.
- Posts and `/about` added to the existing sitemap (`app/sitemap.xml/route.ts`
and `app/sitemaps/[...parts]`). Post URLs come from Payload at request time.
- RSS feed at `/blog/rss.xml`.
- Footer links to both pages, under a new "About" column.
**Navigation is deliberately left alone.** `Navigation.tsx` renders a bottom tab
bar on mobile that already carries four items (Search, Compare, Rankings,
Admissions). A fifth tab makes each one cramped at 320px, and About and Blog are
both lower-intent than any of the four. Both live in the footer; About
additionally gets a byline link from every post, which is where a reader who
cares actually asks the question. Revisit only if analytics show people hunting
for it.
## Testing
Unit (Jest):
- Post rendering, including a post with no hero image and one with no excerpt.
- Slug generation and collision handling.
- JSON-LD shape for `BlogPosting` and `Person`.
- The `next.config.mjs` conversion preserves the staging `X-Robots-Tag` rule —
this guards the riskiest mechanical change in the plan.
E2E (Playwright, `e2e/`, required by CLAUDE.md for user-facing change):
- `/about` renders, shows the author name and photo, and is reachable from the
footer and nav.
- `/blog` lists at least one post; clicking through reaches the post.
- A post page renders title, date, body and byline.
- `/admin` responds with `noindex` and does not leak a stack trace when
unauthenticated.
Note the known constraint: new journeys cannot be proven in PR checks, because
the staging E2E gate runs post-merge.
## Risks
| Risk | Mitigation |
|---|---|
| `next.config.mjs` conversion silently drops the staging noindex header, making staging a crawlable duplicate | Unit test asserting the header rule; verify on staging before promotion |
| Payload API route collides with the FastAPI `/api` proxy | Remap to `/cms-api`; add to the proxy's exclusion list |
| Media volume mounts root-owned; all uploads fail with EACCES | `mkdir`+`chown` in the Dockerfile before the mount; test an upload on staging |
| Build fails or bakes empty content because CI has no DB | No build-time DB access; ISR only |
| A pipeline `--drop` destroys blog content | Separate `payload` schema; verify `--drop` blast radius before building |
| Media volume not backed up; images unrecoverable | Add `payload_media` to the backup routine |
| Payload auth CVE exposed publicly | Prompt patching; proxy restriction available as a fallback |
| Blog launches empty or goes stale | Ship with one post; cadence is explicitly "a few times a year", so no cadence is promised anywhere on the page — no dates implying a schedule |
## Sequence
Each step is independently reviewable and mergeable.
1. **Payload foundation** — install, `next.config.mjs` conversion, `payload`
schema, `/cms-api` remap, `users` collection, `/admin` hardening, compose and
Dockerfile changes. No public-facing change yet. Verify on staging that the
site is unchanged and `/admin` works.
2. **`/about`** — coded page, photo, `Person`/`Organization` JSON-LD, footer and
nav links, e2e journey. Independently valuable and does not depend on the
blog.
3. **Blog** — `posts` and `media` collections, `/blog` index and post pages, ISR
plus revalidation hooks, RSS, sitemap, structured data, e2e journeys.
4. **First post** — written in the admin panel, published through the normal
flow, proving the whole path end to end.
Step 1 carries all the infrastructure risk and none of the visible benefit, so
it should be verified on staging carefully before step 2 starts.
## Dependencies on Tudor
- **A photograph.** Blocks step 2. Nothing else in the plan is blocked by it.
- **The first post's subject.** Blocks step 4 only. Suggested: what school
performance data cannot tell you — it demonstrates judgement, is genuinely
useful, and is the kind of thing an anonymous or machine-written site will not
publish.
- ~~Confirmation that `scripts/migrate_csv_to_db.py --drop` is schema-scoped.~~
**Resolved 2026-09-02** — verified in `backend/migration.py`; see the
Database section. No action needed.
+68
View File
@@ -2554,3 +2554,71 @@ test('the destinations section never claims a pupil stayed at this school', asyn
const text = (await section.textContent()) ?? ''; const text = (await section.textContent()) ?? '';
expect(text).not.toMatch(/stayed on (here|at this school)/i); expect(text).not.toMatch(/stayed on (here|at this school)/i);
}); });
/**
* The About page and the blog exist to give the site a named human author.
* These journeys assert the load-bearing parts of that — a name, a face, the
* honesty claim, and a resolvable Person entity — rather than exact copy,
* which will be edited.
*/
test('the about page names a human author and is reachable from the footer', async ({ page }) => {
await page.goto('/');
const aboutLink = page.locator('footer a[href="/about"]');
await expect(aboutLink).toBeVisible();
await aboutLink.click();
await page.waitForURL(/\/about$/);
await expect(page.getByRole('heading', { level: 1 })).toContainText('Tudor');
await expect(page.locator('img[alt*="Tudor"]')).toBeVisible();
// The credibility claim is lived experience plus stated provenance, not
// expertise. If this sentence ever disappears the positioning has drifted.
await expect(page.getByText(/not an education expert/i)).toBeVisible();
const jsonLd = await page
.locator('script[type="application/ld+json"]')
.first()
.textContent();
expect(jsonLd).toContain('"Person"');
// 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');
});
+3
View File
@@ -39,3 +39,6 @@ yarn-error.log*
# typescript # typescript
*.tsbuildinfo *.tsbuildinfo
next-env.d.ts next-env.d.ts
# Payload generates this from the config on every build
/payload-types.ts
+7
View File
@@ -53,6 +53,13 @@ COPY --from=builder /app/.next/static ./.next/static
# a miss here is a silent 500 on /opengraph-image, not a build failure. # a miss here is a silent 500 on /opengraph-image, not a build failure.
COPY --from=builder /app/assets ./assets COPY --from=builder /app/assets ./assets
# Payload writes uploads here, and the compose file mounts a named volume over
# it. The directory must exist and be owned by the runtime user BEFORE the
# mount: Docker seeds a fresh named volume from the image path, so a missing or
# root-owned directory here makes every upload fail with EACCES at runtime,
# long after the build passed. The chown below covers it.
RUN mkdir -p /app/media
# Set correct permissions # Set correct permissions
RUN chown -R nextjs:nodejs /app RUN chown -R nextjs:nodejs /app
@@ -8,7 +8,7 @@
// environment provides — under jsdom this suite fails on import, not on an // environment provides — under jsdom this suite fails on import, not on an
// assertion. // assertion.
import { NextRequest } from 'next/server'; import { NextRequest } from 'next/server';
import { GET } from '@/app/api/[...path]/route'; import { GET } from '@/app/(frontend)/api/[...path]/route';
function request(path: string) { function request(path: string) {
return new NextRequest(`http://localhost:3000/api/${path}`); return new NextRequest(`http://localhost:3000/api/${path}`);
@@ -0,0 +1,34 @@
import { metadata } from '@/app/(frontend)/about/page';
import { personJsonLd, organizationJsonLd } from '@/lib/jsonld';
describe('/about metadata', () => {
it('canonicalises to the bare path', () => {
expect(metadata.alternates?.canonical)
.toBe('https://www.schoolcompare.co.uk/about');
});
});
describe('author structured data', () => {
it('describes a Person with a first name and a photo', () => {
const person = personJsonLd();
expect(person['@type']).toBe('Person');
expect(person.name).toBe('Tudor');
expect(person.image).toBe('https://www.schoolcompare.co.uk/brand/tudor.jpg');
expect(person.url).toBe('https://www.schoolcompare.co.uk/about');
});
it('never publishes a surname or an employer', () => {
// Author identity constraint: first name only. A surname here would be
// the one place it leaks, since JSON-LD is machine-read and archived.
const serialised = JSON.stringify(personJsonLd());
expect(serialised).not.toMatch(/familyName|Sitaru/i);
expect(serialised).not.toMatch(/worksFor|affiliation/i);
});
it('describes the site as an Organization the Person authors for', () => {
const org = organizationJsonLd();
expect(org['@type']).toBe('Organization');
expect(org.name).toBe('schoolcompare');
expect(org.url).toBe('https://www.schoolcompare.co.uk');
});
});
@@ -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',
);
});
});
+4 -4
View File
@@ -1,7 +1,7 @@
import { metadata as homeMetadata } from '@/app/page'; import { metadata as homeMetadata } from '@/app/(frontend)/page';
import { metadata as rankingsMetadata } from '@/app/rankings/page'; import { metadata as rankingsMetadata } from '@/app/(frontend)/rankings/page';
import { metadata as admissionsMetadata } from '@/app/admissions/page'; import { metadata as admissionsMetadata } from '@/app/(frontend)/admissions/page';
import { generateMetadata as compareMetadata } from '@/app/compare/page'; import { generateMetadata as compareMetadata } from '@/app/(frontend)/compare/page';
describe('canonical URLs', () => { describe('canonical URLs', () => {
it('the homepage canonicalises to the bare root', () => { it('the homepage canonicalises to the bare root', () => {
@@ -0,0 +1,66 @@
/**
* 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.
*
* The non-null assertions are deliberate: every key asserted here is optional
* on NextConfig, and a missing one is precisely the regression under test, so
* the assertion below should fail the test rather than the compile.
*/
import nextConfig from '@/next.config.mjs';
async function headerRules() {
return nextConfig.headers!();
}
describe('next.config.mjs', () => {
it('keeps the staging host out of the index', async () => {
const headers = await headerRules();
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 headerRules();
const csp = headers
.flatMap((rule) => rule.headers)
.find((header) => header.key === 'Content-Security-Policy');
expect(csp).toBeDefined();
expect(csp!.value).toContain('https://analytics.schoolcompare.co.uk');
});
});
describe('admin surface', () => {
it('serves noindex on the admin panel and the CMS API', async () => {
// robots.txt disallows these too, but a Disallow only blocks crawling — a
// URL found from an external link can still be indexed without ever being
// fetched. This header is what actually keeps them out.
const headers = await headerRules();
for (const source of ['/admin/:path*', '/cms-api/:path*']) {
const rule = headers.find((entry) => entry.source === source);
expect(rule).toBeDefined();
expect(rule!.headers).toContainEqual({
key: 'X-Robots-Tag',
value: 'noindex, nofollow',
});
}
});
});
@@ -1,4 +1,4 @@
import { generateMetadata as placeMeta } from '@/app/schools/[place]/page'; import { generateMetadata as placeMeta } from '@/app/(frontend)/schools/[place]/page';
jest.mock('@/lib/places', () => ({ jest.mock('@/lib/places', () => ({
...jest.requireActual('@/lib/places'), ...jest.requireActual('@/lib/places'),
+20
View File
@@ -0,0 +1,20 @@
import robots from '@/app/robots';
describe('robots.txt', () => {
it('disallows the admin panel and the CMS API', () => {
const rules = robots().rules;
const rule = Array.isArray(rules) ? rules[0] : rules;
expect(rule.disallow).toEqual(
expect.arrayContaining(['/api/', '/_next/', '/admin/', '/cms-api/']),
);
});
});
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',
]);
});
});
@@ -109,7 +109,7 @@ describe('dark-theme safety', () => {
* simply missed. * simply missed.
*/ */
describe('third-party surfaces under themed text', () => { describe('third-party surfaces under themed text', () => {
const GLOBALS = path.join(__dirname, '..', '..', 'app', 'globals.css'); const GLOBALS = path.join(__dirname, '..', '..', 'app', '(frontend)', 'globals.css');
/** Leaflet surfaces our own code writes token-coloured text onto. */ /** Leaflet surfaces our own code writes token-coloured text onto. */
const LEAFLET_POPUP_SURFACES = [ const LEAFLET_POPUP_SURFACES = [
@@ -171,7 +171,7 @@ describe('third-party surfaces under themed text', () => {
*/ */
describe('destination tokens', () => { describe('destination tokens', () => {
const css = fs.readFileSync( const css = fs.readFileSync(
path.join(__dirname, '..', '..', 'app', 'globals.css'), 'utf8'); path.join(__dirname, '..', '..', 'app', '(frontend)', 'globals.css'), 'utf8');
const TOKENS = [ const TOKENS = [
'--dest-sixthform', '--dest-sfcollege', '--dest-fecollege', '--dest-sixthform', '--dest-sfcollege', '--dest-fecollege',
@@ -0,0 +1,70 @@
/**
* Payload is ESM-only and next/jest will not transform it, so the collections
* cannot be imported and their sanitised config inspected here (see
* lib/payloadRoutes.ts for the full reasoning). These assert the source of the
* collection definitions instead — enough to catch the settings whose loss is
* silent, and cheap. Behaviour is proved by the e2e journeys against staging.
*/
import fs from 'fs';
import path from 'path';
const read = (file: string) =>
fs.readFileSync(path.join(__dirname, '..', '..', 'collections', file), 'utf8');
const POSTS = read('Posts.ts');
const MEDIA = read('Media.ts');
const CONFIG = fs.readFileSync(
path.join(__dirname, '..', '..', 'payload.config.ts'),
'utf8',
);
describe('posts collection', () => {
it('supports drafts, so saving is not publishing', () => {
expect(POSTS).toMatch(/drafts:\s*true/);
});
it('has a unique, indexed slug for stable URLs', () => {
const slugField = POSTS.slice(POSTS.indexOf("name: 'slug'"));
expect(slugField).toMatch(/unique:\s*true/);
expect(slugField).toMatch(/index:\s*true/);
});
it('hides drafts from anonymous readers at the access layer', () => {
// Payload's docs are explicit: "The `draft` argument alone does not
// restrict documents with _status: 'draft' from being returned by the
// API." The blog pages' where-clause is not enforcement — a direct GET
// /cms-api/posts would return unpublished drafts to anyone. Access
// control returning a query constraint is the only thing that stops it.
expect(POSTS).toMatch(/_status:\s*\{\s*equals:\s*'published'\s*\}/);
expect(POSTS).toMatch(/if\s*\(req\.user\)\s*return true/);
});
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\/\$\{[^}]+\}`\)/);
});
});
describe('media collection', () => {
it('writes uploads to the mounted volume, by absolute path', () => {
// Must match the payload_media mount in docker-compose.portainer.yml.
// Payload 3 requires staticDir to be absolute.
expect(MEDIA).toMatch(/staticDir:\s*'\/app\/media'/);
});
it('requires alt text on every upload', () => {
const altField = MEDIA.slice(MEDIA.indexOf("name: 'alt'"));
expect(altField).toMatch(/required:\s*true/);
});
});
describe('payload config', () => {
it('registers every collection', () => {
expect(CONFIG).toMatch(/collections:\s*\[Users,\s*Posts,\s*Media\]/);
});
});
@@ -0,0 +1,45 @@
/**
* Guards the one thing about Payload's mounting that fails silently.
*
* payload.config.ts itself cannot be imported here — Payload is ESM-only and
* next/jest will not transform it — so this asserts the shared constants and
* that the config actually wires them in, by reading its source. The live
* proof that /api still reaches FastAPI is the e2e journeys, which call
* /api/schools against the running app.
*/
import fs from 'fs';
import path from 'path';
import { PAYLOAD_API_ROUTE, PAYLOAD_ADMIN_ROUTE } from '@/lib/payloadRoutes';
const CONFIG = fs.readFileSync(
path.join(__dirname, '..', '..', 'payload.config.ts'),
'utf8',
);
describe('payload mount points', () => {
it('serves the CMS API from /cms-api, never /api', () => {
// /api is the FastAPI proxy's catch-all. Payload's default would be
// swallowed by it and forwarded to the backend, silently.
expect(PAYLOAD_API_ROUTE).toBe('/cms-api');
expect(PAYLOAD_API_ROUTE).not.toBe('/api');
});
it('serves the admin panel from /admin', () => {
expect(PAYLOAD_ADMIN_ROUTE).toBe('/admin');
});
it('wires both constants into the Payload config', () => {
expect(CONFIG).toContain('PAYLOAD_API_ROUTE');
expect(CONFIG).toContain('PAYLOAD_ADMIN_ROUTE');
});
it('never hardcodes a routes block that could drift from the constants', () => {
expect(CONFIG).not.toMatch(/routes:\s*\{[^}]*api:\s*['"]/);
});
it('isolates CMS tables in their own postgres schema', () => {
// Blog content must sit outside `public`, where the app tables, Airflow's
// metadata and scripts/migrate_csv_to_db.py --drop all live.
expect(CONFIG).toMatch(/schemaName:\s*['"]payload['"]/);
});
});
@@ -21,7 +21,7 @@ import {
import { nationalAveragesFixture } from './schoolFixtures'; import { nationalAveragesFixture } from './schoolFixtures';
// The shell calls useComparison(), which throws outside the provider. In the // The shell calls useComparison(), which throws outside the provider. In the
// app this wrapper comes from app/layout.tsx. // app this wrapper comes from app/(frontend)/layout.tsx.
function withProviders(ui: ReactNode) { function withProviders(ui: ReactNode) {
return <ComparisonProvider>{ui}</ComparisonProvider>; return <ComparisonProvider>{ui}</ComparisonProvider>;
} }
@@ -0,0 +1,82 @@
.page {
max-width: 42rem;
margin: 0 auto;
padding: 2.5rem 1.25rem 4rem;
}
.header {
display: flex;
align-items: center;
gap: 1.25rem;
margin-bottom: 2rem;
}
.portrait {
border-radius: 50%;
border: 2px solid var(--border);
object-fit: cover;
flex-shrink: 0;
}
.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;
}
.subheading {
font-family: var(--font-display);
font-size: 1.15rem;
font-weight: 600;
color: var(--text-primary);
margin: 2.25rem 0 0.75rem;
}
.prose p {
font-family: var(--font-ui);
font-size: 1rem;
line-height: 1.7;
color: var(--text-secondary);
margin: 0 0 1.1rem;
}
/* The opening paragraph carries the page. Larger, and in the primary ink
rather than the secondary, so it reads as a voice rather than as body copy.
Must stay in the descendant form: `.prose p` scores (0,1,1) and would beat a
bare `.lede` at (0,1,0), so simplifying this selector silently reverts the
lede to ordinary body copy. */
.prose .lede {
font-size: 1.125rem;
color: var(--text-primary);
}
.link {
color: var(--brand);
font-weight: 600;
}
.link:hover {
color: var(--brand-strong);
}
@media (max-width: 480px) {
.header {
flex-direction: column;
align-items: flex-start;
gap: 1rem;
}
}
+129
View File
@@ -0,0 +1,129 @@
import type { Metadata } from 'next';
import Image from 'next/image';
import { absoluteUrl } from '@/lib/site';
import { personJsonLd, organizationJsonLd } from '@/lib/jsonld';
import styles from './About.module.css';
export const metadata: Metadata = {
title: 'About',
description:
'Who builds schoolcompare, why it exists, and where its numbers come from.',
alternates: { canonical: absoluteUrl('/about') },
};
export default function AboutPage() {
const jsonLd = {
'@context': 'https://schema.org',
'@graph': [personJsonLd(), organizationJsonLd()],
};
return (
<div className={styles.page}>
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
/>
<header className={styles.header}>
<Image
src="/brand/tudor.jpg"
alt="Tudor, who builds schoolcompare"
width={96}
height={96}
className={styles.portrait}
priority
/>
<div>
<p className={styles.kicker}>Who&apos;s behind this</p>
<h1 className={styles.heading}>I&apos;m Tudor. I built this site.</h1>
</div>
</header>
<div className={styles.prose}>
<p className={styles.lede}>
I&apos;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.
</p>
<p>
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.
</p>
<p>
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.
</p>
<h2 className={styles.subheading}>I&apos;m not an education expert</h2>
<p>
I want to be straightforward about that. I&apos;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.
</p>
<p>
What I do have is the problem itself — I&apos;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.
</p>
<h2 className={styles.subheading}>Where the numbers come from</h2>
<p>
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.
</p>
<p>
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.
</p>
<h2 className={styles.subheading}>What the data can&apos;t tell you</h2>
<p>
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.
</p>
<p>
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.
</p>
<h2 className={styles.subheading}>If something&apos;s wrong</h2>
<p>
Tell me and I&apos;ll fix it. Genuinely — if a figure looks wrong, or
a page gives a misleading impression of a school, I want to know.
It&apos;s the fastest way this gets better.
</p>
<p>
<a href="mailto:contact@schoolcompare.co.uk" className={styles.link}>
contact@schoolcompare.co.uk
</a>
</p>
</div>
</div>
);
}
@@ -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); }
@@ -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,171 @@
import { cache } from 'react';
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 ?? '')}
/>
),
},
});
/**
* 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) => {
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>
);
}
+76
View File
@@ -0,0 +1,76 @@
import type { Metadata } from 'next';
import Link from 'next/link';
import { getCachedPayload } from '@/lib/payload';
import { absoluteUrl } from '@/lib/site';
import styles from './Blog.module.css';
/*
* Dynamic, not ISR.
*
* This route has no dynamic params, so Next prerenders it at build time — and
* CI builds the image with no database reachable, which fails the build. It is
* a single indexed query against Postgres on the same Docker network, so
* rendering per request is cheap, and it means a newly published post appears
* here immediately rather than waiting on a revalidation.
*/
export const dynamic = 'force-dynamic';
export const metadata: Metadata = {
title: 'Blog',
description:
'Notes on what school performance data shows, and what it does not.',
alternates: { canonical: absoluteUrl('/blog') },
};
function formatDate(value: string) {
return new Date(value).toLocaleDateString('en-GB', {
day: 'numeric',
month: 'long',
year: 'numeric',
});
}
export default async function BlogIndexPage() {
const payload = await getCachedPayload();
const { docs } = await payload.find({
collection: 'posts',
where: { _status: { equals: 'published' } },
sort: '-publishedAt',
limit: 50,
depth: 0,
});
return (
<div className={styles.page}>
<header className={styles.header}>
<p className={styles.kicker}>Blog</p>
<h1 className={styles.heading}>Notes on the numbers</h1>
<p className={styles.standfirst}>
What school performance data shows, what it doesn&apos;t, and how to
read it without being misled. Written by{' '}
<Link href="/about" className={styles.link}>Tudor</Link>.
</p>
</header>
{docs.length === 0 ? (
<p className={styles.empty}>No posts yet.</p>
) : (
<ul className={styles.list}>
{docs.map((post) => (
<li key={post.id} className={styles.item}>
<time className={styles.date} dateTime={String(post.publishedAt)}>
{formatDate(String(post.publishedAt))}
</time>
<h2 className={styles.itemTitle}>
<Link href={`/blog/${post.slug}`} className={styles.itemLink}>
{post.title}
</Link>
</h2>
<p className={styles.excerpt}>{post.excerpt}</p>
</li>
))}
</ul>
)}
</div>
);
}
@@ -0,0 +1,52 @@
import { getCachedPayload } from '@/lib/payload';
import { absoluteUrl } from '@/lib/site';
/*
* Dynamic, not ISR.
*
* This route has no dynamic params, so Next prerenders it at build time — and
* CI builds the image with no database reachable, which fails the build. It is
* a single indexed query against Postgres on the same Docker network, so
* rendering per request is cheap, and it means a newly published post appears
* here immediately rather than waiting on a revalidation.
*/
export const dynamic = 'force-dynamic';
function escapeXml(value: string): string {
return value.replace(/[<>&'"]/g, (char) =>
({ '<': '&lt;', '>': '&gt;', '&': '&amp;', "'": '&apos;', '"': '&quot;' }[char]!));
}
export async function GET() {
const payload = await getCachedPayload();
const { docs } = await payload.find({
collection: 'posts',
where: { _status: { equals: 'published' } },
sort: '-publishedAt',
limit: 50,
depth: 0,
});
const items = docs.map((post) => `
<item>
<title>${escapeXml(String(post.title))}</title>
<link>${absoluteUrl(`/blog/${post.slug}`)}</link>
<guid isPermaLink="true">${absoluteUrl(`/blog/${post.slug}`)}</guid>
<description>${escapeXml(String(post.excerpt))}</description>
<pubDate>${new Date(String(post.publishedAt)).toUTCString()}</pubDate>
</item>`).join('');
const xml = `<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0">
<channel>
<title>schoolcompare blog</title>
<link>${absoluteUrl('/blog')}</link>
<description>What school performance data shows, and what it does not.</description>
<language>en-GB</language>${items}
</channel>
</rss>`;
return new Response(xml, {
headers: { 'Content-Type': 'application/rss+xml; charset=utf-8' },
});
}
File renamed without changes.
@@ -0,0 +1,52 @@
/*
* A second sitemap for the URLs Next owns.
*
* /sitemap.xml is proxied from FastAPI (app/(frontend)/sitemap.xml), which
* knows nothing about Payload — the backend and frontend ship as separate
* images. Rather than teach it, the Next-owned URLs get their own sitemap and
* robots.txt lists both.
*/
import { getCachedPayload } from '@/lib/payload';
import { absoluteUrl } from '@/lib/site';
/*
* Dynamic, not ISR.
*
* This route has no dynamic params, so Next prerenders it at build time — and
* CI builds the image with no database reachable, which fails the build. It is
* a single indexed query against Postgres on the same Docker network, so
* rendering per request is cheap, and it means a newly published post appears
* here immediately rather than waiting on a revalidation.
*/
export const dynamic = 'force-dynamic';
export async function GET() {
const payload = await getCachedPayload();
const { docs } = await payload.find({
collection: 'posts',
where: { _status: { equals: 'published' } },
sort: '-publishedAt',
limit: 500,
depth: 0,
});
const urls: Array<{ loc: string; lastmod: string | null }> = [
{ loc: absoluteUrl('/about'), lastmod: null },
{ loc: absoluteUrl('/blog'), lastmod: null },
...docs.map((post) => ({
loc: absoluteUrl(`/blog/${post.slug}`),
lastmod: new Date(String(post.updatedAt ?? post.publishedAt)).toISOString(),
})),
];
const xml = `<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
${urls.map(({ loc, lastmod }) =>
` <url><loc>${loc}</loc>${lastmod ? `<lastmod>${lastmod}</lastmod>` : ''}</url>`,
).join('\n')}
</urlset>`;
return new Response(xml, {
headers: { 'Content-Type': 'application/xml; charset=utf-8' },
});
}
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
@@ -0,0 +1,16 @@
import type { Metadata } from 'next';
import config from '@payload-config';
import { NotFoundPage, generatePageMetadata } from '@payloadcms/next/views';
import { importMap } from '../importMap.js';
type Args = {
params: Promise<{ segments: string[] }>;
searchParams: Promise<{ [key: string]: string | string[] }>;
};
export const generateMetadata = ({ params, searchParams }: Args): Promise<Metadata> =>
generatePageMetadata({ config, params, searchParams });
export default function NotFound({ params, searchParams }: Args) {
return NotFoundPage({ config, importMap, params, searchParams });
}
@@ -0,0 +1,16 @@
import type { Metadata } from 'next';
import config from '@payload-config';
import { RootPage, generatePageMetadata } from '@payloadcms/next/views';
import { importMap } from '../importMap.js';
type Args = {
params: Promise<{ segments: string[] }>;
searchParams: Promise<{ [key: string]: string | string[] }>;
};
export const generateMetadata = ({ params, searchParams }: Args): Promise<Metadata> =>
generatePageMetadata({ config, params, searchParams });
export default function Page({ params, searchParams }: Args) {
return RootPage({ config, importMap, params, searchParams });
}
@@ -0,0 +1,6 @@
import { CollectionCards as CollectionCards_f9c02e79a4aed9a3924487c0cd4cafb1 } from '@payloadcms/next/rsc'
/** @type import('payload').ImportMap */
export const importMap = {
"@payloadcms/next/rsc#CollectionCards": CollectionCards_f9c02e79a4aed9a3924487c0cd4cafb1
}
@@ -0,0 +1,20 @@
/*
* Payload's REST API, mounted at /cms-api rather than /api.
* See lib/payloadRoutes.ts — /api is the FastAPI proxy's catch-all.
*/
import config from '@payload-config';
import {
REST_DELETE,
REST_GET,
REST_OPTIONS,
REST_PATCH,
REST_POST,
REST_PUT,
} from '@payloadcms/next/routes';
export const GET = REST_GET(config);
export const POST = REST_POST(config);
export const DELETE = REST_DELETE(config);
export const PATCH = REST_PATCH(config);
export const PUT = REST_PUT(config);
export const OPTIONS = REST_OPTIONS(config);
@@ -0,0 +1,4 @@
import config from '@payload-config';
import { GRAPHQL_PLAYGROUND_GET } from '@payloadcms/next/routes';
export const GET = GRAPHQL_PLAYGROUND_GET(config);
@@ -0,0 +1,5 @@
import config from '@payload-config';
import { GRAPHQL_POST, REST_OPTIONS } from '@payloadcms/next/routes';
export const POST = GRAPHQL_POST(config);
export const OPTIONS = REST_OPTIONS(config);
+27
View File
@@ -0,0 +1,27 @@
/**
* Root layout for the Payload admin panel.
*
* This is a SECOND root layout: it renders its own <html>/<body>, as does
* app/(frontend)/layout.tsx. Next permits that only while no app/layout.tsx
* exists — which is why the site's routes were moved into (frontend). Adding
* an app/layout.tsx would nest the admin panel inside the site's nav, footer
* and providers and emit nested <html>.
*/
import type { ServerFunctionClient } from 'payload';
import config from '@payload-config';
import { RootLayout, handleServerFunctions } from '@payloadcms/next/layouts';
import { importMap } from './admin/importMap.js';
import '@payloadcms/next/css';
const serverFunction: ServerFunctionClient = async function (args) {
'use server';
return handleServerFunctions({ ...args, config, importMap });
};
export default function PayloadLayout({ children }: { children: React.ReactNode }) {
return (
<RootLayout config={config} importMap={importMap} serverFunction={serverFunction}>
{children}
</RootLayout>
);
}
+7 -2
View File
@@ -12,9 +12,14 @@ export default function robots(): MetadataRoute.Robots {
{ {
userAgent: '*', userAgent: '*',
allow: '/', allow: '/',
disallow: ['/api/', '/_next/'], // /admin and /cms-api are also served X-Robots-Tag: noindex by
// next.config.mjs. A Disallow alone blocks crawling, not indexing.
disallow: ['/api/', '/_next/', '/admin/', '/cms-api/'],
}, },
], ],
sitemap: absoluteUrl('/sitemap.xml'), // Two sitemaps: /sitemap.xml is proxied from FastAPI and carries the
// school corpus; /content-sitemap.xml is Next-owned and carries /about
// and the blog. The backend knows nothing about Payload.
sitemap: [absoluteUrl('/sitemap.xml'), absoluteUrl('/content-sitemap.xml')],
}; };
} }
+25
View File
@@ -0,0 +1,25 @@
import type { Block } from 'payload';
/**
* The house block: "what this number doesn't tell you".
*
* Blocks are the reason this site runs a CMS rather than flat files — a post
* can carry live product components, not screenshots of them. This is the
* first and simplest one; a live-chart block follows when a post needs it.
*/
export const Callout: Block = {
slug: 'callout',
labels: { singular: 'Callout', plural: 'Callouts' },
fields: [
{
name: 'tone',
type: 'select',
defaultValue: 'caveat',
options: [
{ label: 'Caveat — what this does not show', value: 'caveat' },
{ label: 'Note — useful aside', value: 'note' },
],
},
{ name: 'body', type: 'textarea', required: true },
],
};
+31
View File
@@ -0,0 +1,31 @@
import type { CollectionConfig } from 'payload';
/**
* Uploads land on a Docker named volume mounted at /app/media. The path is
* absolute because Payload 3 requires it, and it must match the payload_media
* mount in docker-compose.portainer.yml exactly — a mismatch writes into the
* container's own filesystem, where the next redeploy silently discards it.
*/
export const Media: CollectionConfig = {
slug: 'media',
access: { read: () => true },
upload: {
staticDir: '/app/media',
mimeTypes: ['image/*'],
imageSizes: [
{ name: 'thumbnail', width: 400 },
{ name: 'hero', width: 1200 },
],
adminThumbnail: 'thumbnail',
},
fields: [
{
name: 'alt',
type: 'text',
required: true,
// Required rather than optional: a decorative-by-default image is an
// accessibility regression on a site parents use under time pressure.
admin: { description: 'Describe the image for screen readers.' },
},
],
};
+99
View File
@@ -0,0 +1,99 @@
import type { CollectionConfig } from 'payload';
import { revalidatePath } from 'next/cache';
import { lexicalEditor, BlocksFeature } from '@payloadcms/richtext-lexical';
import { Callout } from '@/blocks/Callout';
/**
* Drop the cached copy of a post page when it changes.
*
* Only the post page needs this. The blog index, the RSS feed and the content
* sitemap are force-dynamic — they have no dynamic params, so Next would
* prerender them at build time, where CI has no database — which means they
* already reflect a change on the next request.
*
* /blog/[slug] is ISR: generated on first request and cached, so without this
* an edit to an already-published post would not appear until the revalidate
* window expired — up to an hour of a writer concluding that saving is broken.
*
* Payload runs in the same process as Next, so this is a direct revalidatePath
* call: no webhook, no shared secret, no network hop to get wrong.
*/
function revalidatePost(slug: string) {
revalidatePath(`/blog/${slug}`);
}
export const Posts: CollectionConfig = {
slug: 'posts',
access: {
/*
* Drafts must be hidden here, not in the pages that query this collection.
*
* From Payload's own documentation: "The `draft` argument alone does not
* restrict documents with `_status: 'draft'` from being returned by the
* API." The blog index and post page both filter on `_status`, but that
* is a convenience, not a control — a direct GET /cms-api/posts would
* hand every unpublished draft to any visitor.
*
* Returning a query constraint rather than a boolean is the documented
* mechanism: Payload merges it into every read for an anonymous caller.
*/
read: ({ req }) => {
if (req.user) return true;
return { _status: { equals: 'published' } };
},
},
admin: {
useAsTitle: 'title',
defaultColumns: ['title', 'publishedAt', '_status'],
},
versions: {
// Posts get written across several sittings and previewed before they go
// live. Without drafts, saving is publishing.
drafts: true,
},
hooks: {
afterChange: [({ doc }) => { revalidatePost(String(doc.slug)); }],
afterDelete: [({ doc }) => { revalidatePost(String(doc.slug)); }],
},
fields: [
{ name: 'title', type: 'text', required: true },
{
name: 'slug',
type: 'text',
required: true,
unique: true,
index: true,
admin: {
position: 'sidebar',
description: 'The URL segment. Never change it after publishing.',
},
},
{
name: 'publishedAt',
type: 'date',
required: true,
admin: { position: 'sidebar', date: { pickerAppearance: 'dayOnly' } },
},
{
name: 'excerpt',
type: 'textarea',
required: true,
maxLength: 200,
admin: {
description: 'Shown on the index and used as the meta description.',
},
},
{ name: 'heroImage', type: 'upload', relationTo: 'media' },
{
name: 'content',
type: 'richText',
required: true,
editor: lexicalEditor({
features: ({ defaultFeatures }) => [
...defaultFeatures,
BlocksFeature({ blocks: [Callout] }),
],
}),
},
],
};
+33
View File
@@ -0,0 +1,33 @@
import type { CollectionConfig } from 'payload';
/**
* The site's only authenticated surface. There is one account and no
* registration: `create` is closed to everyone, so the first user is seeded
* with `payload create-first-user` and no one can add another through the API.
*/
export const Users: CollectionConfig = {
slug: 'users',
auth: {
// Slows credential stuffing against a panel that is on the public
// internet. Five attempts, then a ten-minute lock.
maxLoginAttempts: 5,
lockTime: 10 * 60 * 1000,
},
access: {
create: () => false,
read: ({ req }) => Boolean(req.user),
update: ({ req }) => Boolean(req.user),
delete: () => false,
},
admin: { useAsTitle: 'email' },
fields: [
{
name: 'displayName',
type: 'text',
required: true,
// Rendered as the byline on every post. First name only — the site
// publishes no surname and no employer.
defaultValue: 'Tudor',
},
],
};
+10 -1
View File
@@ -22,7 +22,8 @@
.content { .content {
display: grid; display: grid;
grid-template-columns: 1.6fr 1fr 1fr; /* Brand column plus three link columns: Product, Resources, About. */
grid-template-columns: 1.6fr 1fr 1fr 1fr;
gap: 2rem; gap: 2rem;
margin-bottom: 3rem; margin-bottom: 3rem;
} }
@@ -193,6 +194,14 @@
color: var(--on-sunken); color: var(--on-sunken);
} }
/* Four columns crush between the tablet range and the 768px collapse, so
pair them up first rather than jumping straight to a single column. */
@media (max-width: 960px) {
.content {
grid-template-columns: 1fr 1fr;
}
}
@media (max-width: 768px) { @media (max-width: 768px) {
.container { .container {
padding: 2rem 1rem 1.5rem; padding: 2rem 1rem 1.5rem;
+12
View File
@@ -93,6 +93,18 @@ export function Footer() {
</li> </li>
</ul> </ul>
</div> </div>
<div className={styles.section}>
<h4 className={styles.sectionTitle}>About</h4>
<ul className={styles.links}>
{/* The only route to a named human. Deliberately not in the nav:
the mobile bottom bar already carries four items, and both of
these are lower intent than any of them. Post bylines link
here too, which is where a reader actually asks the question. */}
<li><a href="/about" className={styles.link}>Who&apos;s behind this</a></li>
<li><a href="/blog" className={styles.link}>Blog</a></li>
</ul>
</div>
</div> </div>
<div className={styles.bottom}> <div className={styles.bottom}>
@@ -0,0 +1,21 @@
.caveat,
.note {
border-left: 3px solid var(--brand);
background: var(--brand-bg);
padding: 1rem 1.15rem;
margin: 1.75rem 0;
border-radius: 0 8px 8px 0;
}
.note {
border-left-color: var(--border-strong);
background: var(--bg-secondary);
}
.body {
font-family: var(--font-ui);
font-size: 0.95rem;
line-height: 1.65;
color: var(--text-primary);
margin: 0;
}
@@ -0,0 +1,14 @@
import styles from './CalloutBlock.module.css';
/**
* Renders the Callout block from blocks/Callout.ts. The "caveat" tone is the
* one that matters: it is how a post says what a number does not show, in
* context, rather than burying it in a closing paragraph.
*/
export function CalloutBlock({ tone, body }: { tone: string; body: string }) {
return (
<aside className={tone === 'caveat' ? styles.caveat : styles.note}>
<p className={styles.body}>{body}</p>
</aside>
);
}
+82
View File
@@ -0,0 +1,82 @@
# Publishing to the blog
The blog is Payload CMS, running inside the Next.js app. There is no separate
service and no second deploy — writing a post is done in the browser and takes
effect on the live site within seconds.
## Signing in
`https://www.schoolcompare.co.uk/admin` — one account, no registration. If you
need the account seeded on a fresh environment, run against the container:
```bash
npx payload create-first-user
```
Staging has its own admin panel, its own database and its own credentials at
`https://stx.schoolcompare.co.uk/admin`. Never reuse production's secret or
password there.
## Writing a post
**Posts → Create New.** The fields:
| Field | Notes |
|---|---|
| **Title** | The `<h1>` and the browser tab. |
| **Slug** | The URL segment, in the sidebar. **Never change it after publishing** — it is the canonical URL, and changing it breaks every existing link and discards the page's accumulated search signal. |
| **Published at** | The date shown on the post and in the feed. |
| **Excerpt** | Max 200 characters. Shown on the index *and* used as the meta description, so write it as a standalone sentence rather than a teaser. |
| **Hero image** | Optional. Becomes the social share image; without one, the site's generated card is used. |
| **Content** | Rich text. `/` inserts a block. |
**Save as draft** while you're working — drafts are not public. **Publish** when
it's ready.
### The callout block
One custom block, `Callout`, with two tones:
- **Caveat** — what a number does *not* show. This is the one that matters: it
is how a post states a limitation in context rather than burying it in a
closing paragraph.
- **Note** — a useful aside.
### Images
Every image requires alt text; the editor will not let you save without it.
Uploads go to a Docker volume on the host, which is backed up separately from
Postgres — an image is not reproducible from the pipeline the way school data
is.
## How publishing reaches the live site
- `/blog`, `/blog/rss.xml` and `/content-sitemap.xml` are rendered per request,
so a new post appears immediately.
- `/blog/[slug]` is cached after its first request. Publishing or editing fires
a `revalidatePath` from the collection's `afterChange` hook, which drops that
cached copy — so edits appear immediately too.
If a change doesn't show, it is far more likely the post is still a draft than
that the cache is stale.
## House style
These rules are why the blog exists. A post that ignores them makes the site
read more machine-generated, not less.
- **First person singular.** "I built", "I found" — never "we provide".
- **Concrete over general.** "When we were looking at schools in Wandsworth"
beats any amount of stated warmth.
- **State limits before someone else finds them.** Every post that presents a
metric says what it does not show. This is the single strongest signal that a
human wrote it: generated content does not volunteer its own weaknesses.
- **No mission statements, no "passionate about", no invented team.** There is
one person here.
- **Short sentences.**
- **Never publish a surname, an employer, or a child's name.** The site's author
is "Tudor". See `/about`.
- **Never invent a figure**, even illustratively. On a site whose whole
proposition is official data, a made-up number attached to a real school is
the one thing it cannot do — and no illustrative intent survives being
screenshotted.
File renamed without changes.
+78
View File
@@ -0,0 +1,78 @@
import { SITE_URL, absoluteUrl } from '@/lib/site';
/**
* The site's author entity.
*
* First name only, by choice — see /about. That makes this a weaker search
* signal than a fully identified author would be, which is why the About page
* carries a substantial methodology section: the credibility has to come from
* stated provenance rather than from a corroborable identity.
*
* Everything that needs an author — the About page, every post byline —
* references this one shape, so search engines resolve them all to one entity.
*/
export function personJsonLd() {
return {
'@type': 'Person',
'@id': `${SITE_URL}/about#tudor`,
name: 'Tudor',
url: absoluteUrl('/about'),
image: absoluteUrl('/brand/tudor.jpg'),
description:
'Parent in south-west London who built schoolcompare while looking for a primary school.',
} as const;
}
export function organizationJsonLd() {
return {
'@type': 'Organization',
'@id': `${SITE_URL}#organization`,
name: 'schoolcompare',
url: SITE_URL,
logo: absoluteUrl('/icon-512.png'),
} as const;
}
interface PostSummary {
title: string;
slug: string;
excerpt: string;
publishedAt: string;
}
/**
* References the Person and Organization by @id rather than repeating them, so
* search engines resolve every post and the About page to the one author
* entity. Repeating the shape would declare several people with one name.
*/
export function blogPostingJsonLd(post: PostSummary) {
return {
'@type': 'BlogPosting',
headline: post.title,
description: post.excerpt,
url: absoluteUrl(`/blog/${post.slug}`),
datePublished: post.publishedAt,
author: { '@id': `${SITE_URL}/about#tudor` },
publisher: { '@id': `${SITE_URL}#organization` },
} as const;
}
export function breadcrumbJsonLd(post: PostSummary) {
return {
'@type': 'BreadcrumbList',
itemListElement: [
{
'@type': 'ListItem',
position: 1,
name: 'Blog',
item: absoluteUrl('/blog'),
},
{
'@type': 'ListItem',
position: 2,
name: post.title,
item: absoluteUrl(`/blog/${post.slug}`),
},
],
} as const;
}
+15
View File
@@ -0,0 +1,15 @@
import { getPayload } from 'payload';
import config from '@payload-config';
import type { Payload } from 'payload';
/**
* One Payload instance per process. getPayload() is itself memoised by
* Payload, but routing every caller through here keeps the config import in a
* single place and gives page code one name to mock in tests.
*
* Never call this at module scope: CI builds the image with no database
* reachable, so a build-time connection attempt fails the build.
*/
export function getCachedPayload(): Promise<Payload> {
return getPayload({ config });
}
+23
View File
@@ -0,0 +1,23 @@
/**
* Where Payload mounts, defined once.
*
* These are imported by payload.config.ts and asserted by
* __tests__/payload/routes.test.ts. They live in their own module because
* payload.config.ts cannot be imported from a Jest test: Payload ships
* ESM-only, and next/jest's transformIgnorePatterns skips node_modules — you
* cannot un-ignore a package by appending patterns, and forcing it through
* `transpilePackages` would change how the production build bundles Payload
* to serve a test. Keeping the values here makes them testable without
* loading Payload at all.
*/
/**
* Payload's API base. It must NOT be '/api': that path belongs to
* app/(frontend)/api/[...path]/route.ts, a catch-all that proxies to FastAPI.
* It would swallow every admin API call and forward it to the backend, and
* the failure is silent — no error, just wrong responses.
*/
export const PAYLOAD_API_ROUTE = '/cms-api';
/** The admin panel. Kept out of the index by robots.txt and X-Robots-Tag. */
export const PAYLOAD_ADMIN_ROUTE = '/admin';
@@ -1,3 +1,5 @@
import { withPayload } from '@payloadcms/next/withPayload';
/** @type {import('next').NextConfig} */ /** @type {import('next').NextConfig} */
const nextConfig = { const nextConfig = {
// Enable standalone output for Docker // Enable standalone output for Docker
@@ -86,6 +88,23 @@ const nextConfig = {
}, },
], ],
}, },
{
/*
* The admin panel and the CMS API must never be indexed.
*
* X-Robots-Tag, not just the robots.txt Disallow, for the same reason
* the staging rule above uses one: a Disallow blocks crawling, which
* is not indexing. A disallowed URL found from an external link can
* still be indexed without ever being fetched — and worse, blocking
* the crawl means the noindex is never seen.
*/
source: '/admin/:path*',
headers: [{ key: 'X-Robots-Tag', value: 'noindex, nofollow' }],
},
{
source: '/cms-api/:path*',
headers: [{ key: 'X-Robots-Tag', value: 'noindex, nofollow' }],
},
{ {
source: '/:path*', source: '/:path*',
headers: [ headers: [
@@ -127,4 +146,4 @@ const nextConfig = {
}, },
}; };
module.exports = nextConfig; export default withPayload(nextConfig);
+5184 -223
View File
File diff suppressed because it is too large. Load diff
+8 -2
View File
@@ -2,6 +2,7 @@
"name": "nextjs-app", "name": "nextjs-app",
"version": "0.1.0", "version": "0.1.0",
"private": true, "private": true,
"type": "module",
"description": "SchoolCompare Next.js Application", "description": "SchoolCompare Next.js Application",
"scripts": { "scripts": {
"dev": "next dev", "dev": "next dev",
@@ -14,18 +15,24 @@
}, },
"dependencies": { "dependencies": {
"@floating-ui/react": "^0.27.20", "@floating-ui/react": "^0.27.20",
"@payloadcms/db-postgres": "^3.88.0",
"@payloadcms/next": "^3.88.0",
"@payloadcms/richtext-lexical": "^3.88.0",
"@types/node": "^25.2.0", "@types/node": "^25.2.0",
"@types/react": "^19.2.10", "@types/react": "^19.2.10",
"@types/react-dom": "^19.2.3", "@types/react-dom": "^19.2.3",
"chart.js": "^4.5.1", "chart.js": "^4.5.1",
"eslint": "^9.39.2", "eslint": "^9.39.2",
"eslint-config-next": "^16.1.6", "eslint-config-next": "^16.1.6",
"graphql": "^16.14.2",
"leaflet": "^1.9.4", "leaflet": "^1.9.4",
"next": "^16.1.6", "next": "^16.1.6",
"payload": "^3.88.0",
"react": "^19.2.4", "react": "^19.2.4",
"react-chartjs-2": "^5.3.1", "react-chartjs-2": "^5.3.1",
"react-dom": "^19.2.4", "react-dom": "^19.2.4",
"react-leaflet": "^5.0.0", "react-leaflet": "^5.0.0",
"sharp": "^0.35.4",
"typescript": "^5.9.3", "typescript": "^5.9.3",
"zod": "^4.3.6" "zod": "^4.3.6"
}, },
@@ -36,7 +43,6 @@
"@types/jest": "^30.0.0", "@types/jest": "^30.0.0",
"@types/leaflet": "^1.9.21", "@types/leaflet": "^1.9.21",
"jest": "^30.2.0", "jest": "^30.2.0",
"jest-environment-jsdom": "^30.2.0", "jest-environment-jsdom": "^30.2.0"
"sharp": "^0.34.5"
} }
} }
+32
View File
@@ -0,0 +1,32 @@
import path from 'path';
import { fileURLToPath } from 'url';
import { buildConfig } from 'payload';
import { postgresAdapter } from '@payloadcms/db-postgres';
import { lexicalEditor } from '@payloadcms/richtext-lexical';
import sharp from 'sharp';
import { Users } from '@/collections/Users';
import { Posts } from '@/collections/Posts';
import { Media } from '@/collections/Media';
import { PAYLOAD_API_ROUTE, PAYLOAD_ADMIN_ROUTE } from '@/lib/payloadRoutes';
const filename = fileURLToPath(import.meta.url);
const dirname = path.dirname(filename);
export default buildConfig({
admin: { user: Users.slug },
// Defined in lib/payloadRoutes.ts, which carries the reasoning and is what
// the test asserts. Never inline these — /api belongs to the FastAPI proxy.
routes: { api: PAYLOAD_API_ROUTE, admin: PAYLOAD_ADMIN_ROUTE },
collections: [Users, Posts, Media],
editor: lexicalEditor(),
secret: process.env.PAYLOAD_SECRET || '',
typescript: { outputFile: path.resolve(dirname, 'payload-types.ts') },
db: postgresAdapter({
pool: { connectionString: process.env.DATABASE_URL },
// Its own schema, so no pipeline operation on `public` can reach blog
// content. scripts/migrate_csv_to_db.py --drop lives in that blast radius,
// as does Airflow's metadata.
schemaName: 'payload',
}),
sharp,
});
+3
View File
@@ -25,6 +25,9 @@
"paths": { "paths": {
"@/*": [ "@/*": [
"./*" "./*"
],
"@payload-config": [
"./payload.config.ts"
] ]
} }
}, },