PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 1m14s
PR Checks / Backend Smoke (pull_request) Successful in 9s
PR Checks / Build Backend (no push) (pull_request) Successful in 22s
PR Checks / Build Frontend (no push) (pull_request) Successful in 1m24s
PR Checks / Build Pipeline (no push) (pull_request) Successful in 11s
PR Checks / AI Code Review (Claude) (pull_request) Successful in 1m18s
W2 shipped ~5,000 place pages and nothing linked into them. The
location layer pointed down at school pages; school pages pointed
nowhere on the site. Their only anchor was the school's own website, so
the ~27k pages carrying most of the site's inbound authority passed it
straight off-site, and the new corpus was reachable mainly through the
sitemap.
Three things close the loop.
A reverse index over the place registry, places_for_urn, answers which
published places contain a school. Derived from the registry rather
than stored beside it, so the two cannot disagree about which places
exist: a place below the publish threshold is absent from the registry
and therefore never offered as a link. A test asserts that invariant
across every place in a built registry.
GET /api/schools/{urn} gains a `places` array carrying the name, count
and canonical path for each. It rides on the request the page already
makes, so the school page costs no extra round trip. The frontend types
it optional and defaults it to empty, because the two images deploy
separately and a frontend ahead of the API must render without it.
The page gains a "More schools near here" module and a BreadcrumbList.
The module orders narrowest first, because a reader on a school page
wants its town before its county, while the API orders widest first for
the trail. Anchors state their destination's size — "37 schools in
Brentwood" — which is worth more to a reader and a crawler than "see
more". With no published places it renders nothing rather than an empty
heading.
The trail is rooted at the homepage, not /schools. There is no /schools
index page; the location layer lives only at /schools/[place],
/schools/authority/[la] and /schools/near/[outcode]. Rooting it at the
bare path would have opened every breadcrumb with a link to a 404.
Outcodes are omitted from the trail: "schools near CM15" is a real
query and a useful link, but nobody navigates Essex to CM15 to a
school, and a breadcrumb claiming that describes a hierarchy the site
does not have.
School pages also now declare the School type rather than
EducationalOrganization, the parent type that covers universities and
nurseries alike.
The e2e journey asserts the round trip in both directions, following a
place page's own first school so the pair is genuinely related rather
than hardcoded. A one-way link is what already existed.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DXnXQKnPpZBBP61fBQiFkq
148 lines
4.6 KiB
TypeScript
148 lines
4.6 KiB
TypeScript
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.
|
|
*
|
|
* `namedAuthor` is the about_page flag. The Person entity is anchored at
|
|
* /about#tudor, and that URL 404s while the flag is dark, so a post published
|
|
* in that state must not claim it: an author @id resolving to nothing is a
|
|
* worse signal than no named author. It falls back to the publisher, which is
|
|
* always live. The parameter is required rather than defaulted because every
|
|
* call site has the flag to hand and the wrong default is silent.
|
|
*/
|
|
export function blogPostingJsonLd(
|
|
post: PostSummary,
|
|
{ namedAuthor }: { namedAuthor: boolean },
|
|
) {
|
|
return {
|
|
'@type': 'BlogPosting',
|
|
headline: post.title,
|
|
description: post.excerpt,
|
|
url: absoluteUrl(`/blog/${post.slug}`),
|
|
datePublished: post.publishedAt,
|
|
author: {
|
|
'@id': namedAuthor ? `${SITE_URL}/about#tudor` : `${SITE_URL}#organization`,
|
|
},
|
|
publisher: { '@id': `${SITE_URL}#organization` },
|
|
} as const;
|
|
}
|
|
|
|
/**
|
|
* A place the location layer publishes a page for, as the school API reports
|
|
* it. `count` is what lets a link say "All 37 schools in Brentwood" rather
|
|
* than "click here".
|
|
*/
|
|
export interface SchoolPlace {
|
|
kind: string;
|
|
slug: string;
|
|
name: string;
|
|
count: number;
|
|
url: string;
|
|
}
|
|
|
|
/**
|
|
* The trail a school page sits at the end of: Schools → authority → town.
|
|
*
|
|
* Only authority and town/locality appear. An outcode is a useful link in the
|
|
* module beside this — a parent does search "schools near CM15" — but it is
|
|
* not a step anyone navigates through, and a breadcrumb that claims otherwise
|
|
* describes a hierarchy the site does not have.
|
|
*
|
|
* Levels are skipped rather than faked. A school whose town falls below the
|
|
* publish threshold has no town page, so the trail closes over the gap; the
|
|
* alternative is a breadcrumb linking to a 404.
|
|
*/
|
|
export function schoolBreadcrumbJsonLd(
|
|
school: { name: string; url: string; places: SchoolPlace[] },
|
|
) {
|
|
/*
|
|
* Rooted at the homepage, not at /schools. There is no /schools index page
|
|
* — the location layer is /schools/[place], /schools/authority/[la] and
|
|
* /schools/near/[outcode], with nothing at the bare path — so a trail
|
|
* starting there would open with a link to a 404.
|
|
*/
|
|
const trail: Array<{ name: string; url: string }> = [
|
|
{ name: 'schoolcompare', url: '/' },
|
|
];
|
|
|
|
const authority = school.places.find((p) => p.kind === 'authority');
|
|
if (authority) trail.push({ name: authority.name, url: authority.url });
|
|
|
|
const town = school.places.find((p) => p.kind === 'town' || p.kind === 'locality');
|
|
if (town) trail.push({ name: town.name, url: town.url });
|
|
|
|
trail.push({ name: school.name, url: school.url });
|
|
|
|
return {
|
|
'@type': 'BreadcrumbList',
|
|
itemListElement: trail.map((step, index) => ({
|
|
'@type': 'ListItem',
|
|
position: index + 1,
|
|
name: step.name,
|
|
item: absoluteUrl(step.url),
|
|
})),
|
|
} 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;
|
|
}
|