Compare commits
10
Commits
748ef32180
...
07d586d0ad
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
07d586d0ad | ||
|
|
b793640507 | ||
|
|
21a5d18f59 | ||
|
|
f614414070 | ||
|
|
310b63b0cb | ||
|
|
c2c76c5817 | ||
|
|
c5a4d106da | ||
|
|
2437ffce42 | ||
|
|
eb648f3f76 | ||
|
|
74e5fffc10 |
No files matched your search
@@ -23,6 +23,46 @@ Key files:
|
||||
- `backend/data_loader.py` - Data queries, geocoding, legacy DataFrame compatibility
|
||||
- `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)
|
||||
- Single-page application with hash-based routing
|
||||
- Chart.js for data visualization
|
||||
|
||||
@@ -18,6 +18,10 @@
|
||||
# TYPESENSE_SEARCH_KEY — Typesense search-only key (exposed to frontend)
|
||||
# UNLEASH_URL — http://<unleash-ip>:4242/api (empty = all flags off)
|
||||
# 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_PASSWORD — Airflow admin password. REQUIRED: the api-server
|
||||
# refuses to start without it, rather than falling
|
||||
@@ -89,9 +93,20 @@ services:
|
||||
- FASTAPI_URL=http://backend:80/api
|
||||
- TYPESENSE_URL=http://typesense:8108
|
||||
- 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:
|
||||
backend:
|
||||
condition: service_healthy
|
||||
sc_database:
|
||||
condition: service_healthy
|
||||
networks:
|
||||
backend: {}
|
||||
macvlan:
|
||||
@@ -242,3 +257,4 @@ volumes:
|
||||
typesense_data:
|
||||
airflow_logs:
|
||||
unleash_cache:
|
||||
payload_media:
|
||||
@@ -9,6 +9,9 @@
|
||||
# TYPESENSE_SEARCH_KEY — Typesense search-only key (exposed to frontend)
|
||||
# UNLEASH_URL — http://<unleash-ip>:4242/api (empty = all flags off)
|
||||
# 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_PASSWORD — Airflow admin password. REQUIRED: the api-server
|
||||
# refuses to start without it, rather than falling
|
||||
@@ -78,9 +81,21 @@ services:
|
||||
- FASTAPI_URL=http://backend:80/api
|
||||
- TYPESENSE_URL=http://typesense:8108
|
||||
- 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:
|
||||
backend:
|
||||
condition: service_healthy
|
||||
sc_database:
|
||||
condition: service_healthy
|
||||
networks:
|
||||
backend: {}
|
||||
macvlan:
|
||||
@@ -231,3 +246,4 @@ volumes:
|
||||
typesense_data:
|
||||
airflow_logs:
|
||||
unleash_cache:
|
||||
payload_media:
|
||||
File diff suppressed because it is too large.
Load diff
@@ -2554,3 +2554,71 @@ test('the destinations section never claims a pupil stayed at this school', asyn
|
||||
const text = (await section.textContent()) ?? '';
|
||||
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');
|
||||
});
|
||||
@@ -39,3 +39,6 @@ yarn-error.log*
|
||||
# typescript
|
||||
*.tsbuildinfo
|
||||
next-env.d.ts
|
||||
|
||||
# Payload generates this from the config on every build
|
||||
/payload-types.ts
|
||||
@@ -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.
|
||||
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
|
||||
RUN chown -R nextjs:nodejs /app
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
// environment provides — under jsdom this suite fails on import, not on an
|
||||
// assertion.
|
||||
import { NextRequest } from 'next/server';
|
||||
import { GET } from '@/app/api/[...path]/route';
|
||||
import { GET } from '@/app/(frontend)/api/[...path]/route';
|
||||
|
||||
function request(path: string) {
|
||||
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',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,7 +1,7 @@
|
||||
import { metadata as homeMetadata } from '@/app/page';
|
||||
import { metadata as rankingsMetadata } from '@/app/rankings/page';
|
||||
import { metadata as admissionsMetadata } from '@/app/admissions/page';
|
||||
import { generateMetadata as compareMetadata } from '@/app/compare/page';
|
||||
import { metadata as homeMetadata } from '@/app/(frontend)/page';
|
||||
import { metadata as rankingsMetadata } from '@/app/(frontend)/rankings/page';
|
||||
import { metadata as admissionsMetadata } from '@/app/(frontend)/admissions/page';
|
||||
import { generateMetadata as compareMetadata } from '@/app/(frontend)/compare/page';
|
||||
|
||||
describe('canonical URLs', () => {
|
||||
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.requireActual('@/lib/places'),
|
||||
|
||||
@@ -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.
|
||||
*/
|
||||
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. */
|
||||
const LEAFLET_POPUP_SURFACES = [
|
||||
@@ -171,7 +171,7 @@ describe('third-party surfaces under themed text', () => {
|
||||
*/
|
||||
describe('destination tokens', () => {
|
||||
const css = fs.readFileSync(
|
||||
path.join(__dirname, '..', '..', 'app', 'globals.css'), 'utf8');
|
||||
path.join(__dirname, '..', '..', 'app', '(frontend)', 'globals.css'), 'utf8');
|
||||
|
||||
const TOKENS = [
|
||||
'--dest-sixthform', '--dest-sfcollege', '--dest-fecollege',
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
/**
|
||||
* 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('is publicly readable', () => {
|
||||
expect(POSTS).toMatch(/access:\s*\{\s*read:\s*\(\)\s*=>\s*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';
|
||||
|
||||
// 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) {
|
||||
return <ComparisonProvider>{ui}</ComparisonProvider>;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
.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. */
|
||||
.lede {
|
||||
font-size: 1.125rem;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
}
|
||||
@@ -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's behind this</p>
|
||||
<h1 className={styles.heading}>I'm Tudor. I built this site.</h1>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className={styles.prose}>
|
||||
<p className={styles.lede}>
|
||||
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.
|
||||
</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'm not an education expert</h2>
|
||||
|
||||
<p>
|
||||
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.
|
||||
</p>
|
||||
|
||||
<p>
|
||||
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.
|
||||
</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'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's wrong</h2>
|
||||
|
||||
<p>
|
||||
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.
|
||||
</p>
|
||||
|
||||
<p>
|
||||
<a href="mailto:contact@schoolcompare.co.uk" className={styles.link}>
|
||||
contact@schoolcompare.co.uk
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
File renamed without changes.
File renamed without changes.
@@ -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,164 @@
|
||||
import type { Metadata } from 'next';
|
||||
import Link from 'next/link';
|
||||
import { notFound } from 'next/navigation';
|
||||
import { RichText } from '@payloadcms/richtext-lexical/react';
|
||||
import type { JSXConvertersFunction } from '@payloadcms/richtext-lexical/react';
|
||||
import { getCachedPayload } from '@/lib/payload';
|
||||
import { absoluteUrl } from '@/lib/site';
|
||||
import {
|
||||
blogPostingJsonLd,
|
||||
breadcrumbJsonLd,
|
||||
personJsonLd,
|
||||
organizationJsonLd,
|
||||
} from '@/lib/jsonld';
|
||||
import { CalloutBlock } from '@/components/blog/CalloutBlock';
|
||||
import styles from './Post.module.css';
|
||||
|
||||
/*
|
||||
* ISR. Unlike the index, this route has a dynamic param and no
|
||||
* generateStaticParams, so there is nothing for the build to prerender: each
|
||||
* post is generated on first request and cached until the collection's
|
||||
* afterChange hook revalidates it. That hook is what makes an edit to an
|
||||
* already-published post appear immediately.
|
||||
*/
|
||||
export const revalidate = 3600;
|
||||
|
||||
interface HeroImage {
|
||||
url?: string | null;
|
||||
alt?: string | null;
|
||||
width?: number | null;
|
||||
height?: number | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Spreads the default converters and adds the one custom block.
|
||||
*
|
||||
* Without the spread, every default node type — paragraphs, headings, links —
|
||||
* loses its renderer and the post body comes out empty.
|
||||
*/
|
||||
const calloutConverters: JSXConvertersFunction = ({ defaultConverters }) => ({
|
||||
...defaultConverters,
|
||||
blocks: {
|
||||
// Annotated because the generic block converter cannot infer a custom
|
||||
// block's field shape; String() guards the values regardless.
|
||||
callout: ({ node }: { node: { fields: Record<string, unknown> } }) => (
|
||||
<CalloutBlock
|
||||
tone={String(node.fields.tone ?? 'caveat')}
|
||||
body={String(node.fields.body ?? '')}
|
||||
/>
|
||||
),
|
||||
},
|
||||
});
|
||||
|
||||
async function findPost(slug: string) {
|
||||
const payload = await getCachedPayload();
|
||||
const { docs } = await payload.find({
|
||||
collection: 'posts',
|
||||
where: { slug: { equals: slug }, _status: { equals: 'published' } },
|
||||
limit: 1,
|
||||
depth: 1,
|
||||
});
|
||||
return docs[0] ?? null;
|
||||
}
|
||||
|
||||
function summarise(post: Record<string, unknown>) {
|
||||
return {
|
||||
title: String(post.title),
|
||||
slug: String(post.slug),
|
||||
excerpt: String(post.excerpt),
|
||||
publishedAt: String(post.publishedAt),
|
||||
};
|
||||
}
|
||||
|
||||
export async function generateMetadata(
|
||||
{ params }: { params: Promise<{ slug: string }> },
|
||||
): Promise<Metadata> {
|
||||
const { slug } = await params;
|
||||
const post = await findPost(slug);
|
||||
if (!post) return { title: 'Not found' };
|
||||
|
||||
const hero = post.heroImage as HeroImage | null | undefined;
|
||||
|
||||
return {
|
||||
title: String(post.title),
|
||||
description: String(post.excerpt),
|
||||
alternates: { canonical: absoluteUrl(`/blog/${post.slug}`) },
|
||||
openGraph: {
|
||||
type: 'article',
|
||||
title: String(post.title),
|
||||
description: String(post.excerpt),
|
||||
url: absoluteUrl(`/blog/${post.slug}`),
|
||||
publishedTime: String(post.publishedAt),
|
||||
// A post with a hero image shares that; one without falls through to the
|
||||
// generated share card at app/opengraph-image.tsx.
|
||||
...(hero?.url ? { images: [{ url: hero.url }] } : {}),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export default async function PostPage(
|
||||
{ params }: { params: Promise<{ slug: string }> },
|
||||
) {
|
||||
const { slug } = await params;
|
||||
const post = await findPost(slug);
|
||||
if (!post) notFound();
|
||||
|
||||
const summary = summarise(post as Record<string, unknown>);
|
||||
const hero = post.heroImage as HeroImage | null | undefined;
|
||||
|
||||
const jsonLd = {
|
||||
'@context': 'https://schema.org',
|
||||
'@graph': [
|
||||
blogPostingJsonLd(summary),
|
||||
breadcrumbJsonLd(summary),
|
||||
personJsonLd(),
|
||||
organizationJsonLd(),
|
||||
],
|
||||
};
|
||||
|
||||
return (
|
||||
<article className={styles.page}>
|
||||
<script
|
||||
type="application/ld+json"
|
||||
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
|
||||
/>
|
||||
|
||||
<nav className={styles.crumb}>
|
||||
<Link href="/blog" className={styles.link}>Blog</Link>
|
||||
</nav>
|
||||
|
||||
<h1 className={styles.heading}>{summary.title}</h1>
|
||||
|
||||
<p className={styles.byline}>
|
||||
By <Link href="/about" className={styles.link}>Tudor</Link>
|
||||
{' · '}
|
||||
<time dateTime={summary.publishedAt}>
|
||||
{new Date(summary.publishedAt).toLocaleDateString('en-GB', {
|
||||
day: 'numeric',
|
||||
month: 'long',
|
||||
year: 'numeric',
|
||||
})}
|
||||
</time>
|
||||
</p>
|
||||
|
||||
{/*
|
||||
A plain <img>, not next/image: Payload already generated the sized
|
||||
derivatives on upload (Media's imageSizes), so routing it through the
|
||||
optimizer would resize an image that is already the right size.
|
||||
*/}
|
||||
{hero?.url && (
|
||||
<img
|
||||
className={styles.hero}
|
||||
src={hero.url}
|
||||
alt={hero.alt ?? ''}
|
||||
width={hero.width ?? undefined}
|
||||
height={hero.height ?? undefined}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className={styles.prose}>
|
||||
<RichText data={post.content as never} converters={calloutConverters} />
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
@@ -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'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) =>
|
||||
({ '<': '<', '>': '>', '&': '&', "'": ''', '"': '"' }[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.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
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);
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -12,9 +12,14 @@ export default function robots(): MetadataRoute.Robots {
|
||||
{
|
||||
userAgent: '*',
|
||||
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')],
|
||||
};
|
||||
}
|
||||
@@ -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 },
|
||||
],
|
||||
};
|
||||
@@ -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.' },
|
||||
},
|
||||
],
|
||||
};
|
||||
@@ -0,0 +1,82 @@
|
||||
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: { read: () => true },
|
||||
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] }),
|
||||
],
|
||||
}),
|
||||
},
|
||||
],
|
||||
};
|
||||
@@ -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',
|
||||
},
|
||||
],
|
||||
};
|
||||
@@ -22,7 +22,8 @@
|
||||
|
||||
.content {
|
||||
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;
|
||||
margin-bottom: 3rem;
|
||||
}
|
||||
@@ -193,6 +194,14 @@
|
||||
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) {
|
||||
.container {
|
||||
padding: 2rem 1rem 1.5rem;
|
||||
|
||||
@@ -93,6 +93,18 @@ export function Footer() {
|
||||
</li>
|
||||
</ul>
|
||||
</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's behind this</a></li>
|
||||
<li><a href="/blog" className={styles.link}>Blog</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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>
|
||||
);
|
||||
}
|
||||
@@ -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.
@@ -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;
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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} */
|
||||
const nextConfig = {
|
||||
// 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*',
|
||||
headers: [
|
||||
@@ -127,4 +146,4 @@ const nextConfig = {
|
||||
},
|
||||
};
|
||||
|
||||
module.exports = nextConfig;
|
||||
export default withPayload(nextConfig);
|
||||
Generated
+5184
-223
File diff suppressed because it is too large.
Load diff
@@ -2,6 +2,7 @@
|
||||
"name": "nextjs-app",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"description": "SchoolCompare Next.js Application",
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
@@ -14,18 +15,24 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@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/react": "^19.2.10",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"chart.js": "^4.5.1",
|
||||
"eslint": "^9.39.2",
|
||||
"eslint-config-next": "^16.1.6",
|
||||
"graphql": "^16.14.2",
|
||||
"leaflet": "^1.9.4",
|
||||
"next": "^16.1.6",
|
||||
"payload": "^3.88.0",
|
||||
"react": "^19.2.4",
|
||||
"react-chartjs-2": "^5.3.1",
|
||||
"react-dom": "^19.2.4",
|
||||
"react-leaflet": "^5.0.0",
|
||||
"sharp": "^0.35.4",
|
||||
"typescript": "^5.9.3",
|
||||
"zod": "^4.3.6"
|
||||
},
|
||||
@@ -36,7 +43,6 @@
|
||||
"@types/jest": "^30.0.0",
|
||||
"@types/leaflet": "^1.9.21",
|
||||
"jest": "^30.2.0",
|
||||
"jest-environment-jsdom": "^30.2.0",
|
||||
"sharp": "^0.34.5"
|
||||
"jest-environment-jsdom": "^30.2.0"
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
File renamed without changes.
@@ -25,6 +25,9 @@
|
||||
"paths": {
|
||||
"@/*": [
|
||||
"./*"
|
||||
],
|
||||
"@payload-config": [
|
||||
"./payload.config.ts"
|
||||
]
|
||||
}
|
||||
},
|
||||
|
||||
Reference in new issue
Block a user