From 07d586d0ad0f8c83047b3e9aaa092a3f64de5c33 Mon Sep 17 00:00:00 2001 From: Tudor Date: Wed, 2 Sep 2026 16:29:41 +0100 Subject: [PATCH] docs(blog): how to publish, and why the app has two route groups MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PUBLISHING.md carries the house style with the posts, so the standard survives without the design doc to hand — including the rule that a post states what a metric does not show, which is the strongest signal a human wrote it. CLAUDE.md gains the two constraints that are invisible from the code and expensive to rediscover: metadata file conventions break if moved into a route group, and the build must keep succeeding with DATABASE_URL unset. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_017YmbBhr8s7GusjDE12hrZM --- claude.md | 40 +++++++++++++++++ nextjs-app/docs/PUBLISHING.md | 82 +++++++++++++++++++++++++++++++++++ 2 files changed, 122 insertions(+) create mode 100644 nextjs-app/docs/PUBLISHING.md diff --git a/claude.md b/claude.md index 3d05e66..057a630 100644 --- a/claude.md +++ b/claude.md @@ -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 ``/``, 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-.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 diff --git a/nextjs-app/docs/PUBLISHING.md b/nextjs-app/docs/PUBLISHING.md new file mode 100644 index 0000000..75a6eb6 --- /dev/null +++ b/nextjs-app/docs/PUBLISHING.md @@ -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 `

` 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.