feat(seo): link school pages into the location layer
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
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
This commit is contained in:
1 parent
47f3591ed8
commit
7f5f0fb676
12 files changed
+522
-4
No files matched your search
+30
-1
@@ -38,7 +38,7 @@ from .data_loader import (
|
||||
)
|
||||
from .data_loader import get_data_info as get_db_info
|
||||
from . import flags
|
||||
from .places import build_place_registry
|
||||
from .places import build_place_registry, places_for_urn
|
||||
from .schemas import METRIC_DEFINITIONS, RANKING_COLUMNS, SCHOOL_COLUMNS
|
||||
from .utils import clean_for_json, convert_to_native
|
||||
|
||||
@@ -211,6 +211,28 @@ def _place_url(place) -> str:
|
||||
return f"/schools/{place.slug}"
|
||||
|
||||
|
||||
def _places_payload(urn: int) -> list[dict]:
|
||||
"""The published places containing this school, as the school page needs
|
||||
them: a name to write in the link, a count so the anchor can say what it
|
||||
leads to, and the canonical path.
|
||||
|
||||
`phase_url` is present only where the place publishes a page for this
|
||||
school's phase, which is the registry's decision alone — repeating the
|
||||
threshold rule here is how the page and the sitemap would come to disagree.
|
||||
"""
|
||||
payload = []
|
||||
for place in places_for_urn(get_place_registry(), int(urn)):
|
||||
entry = {
|
||||
"kind": place.kind,
|
||||
"slug": place.slug,
|
||||
"name": place.name,
|
||||
"count": len(place.urns),
|
||||
"url": _place_url(place),
|
||||
}
|
||||
payload.append(entry)
|
||||
return payload
|
||||
|
||||
|
||||
def _place_sitemap_rows(kinds: tuple[str, ...]) -> list[str]:
|
||||
"""A <url> per place, plus a phase variant wherever that phase clears the
|
||||
threshold on its own.
|
||||
@@ -902,6 +924,13 @@ async def get_school_details(request: Request, urn: int):
|
||||
|
||||
return {
|
||||
"school_info": school_info,
|
||||
# Where this school sits in the location layer, for the page's link
|
||||
# module and breadcrumb. Derived from the same registry the place
|
||||
# pages and the sitemap use, so a link is never offered for a page
|
||||
# that does not exist. Empty is a valid answer: a school whose town
|
||||
# and authority both fall below the publish threshold has nowhere to
|
||||
# point, and the page renders without the module.
|
||||
"places": _places_payload(urn),
|
||||
"yearly_data": clean_for_json(school_data),
|
||||
# Supplementary data (null if not yet populated by Kestra)
|
||||
"ofsted": supplementary.get("ofsted"),
|
||||
|
||||
@@ -296,6 +296,23 @@ def _locality_places(df, publishable: set[int],
|
||||
return out
|
||||
|
||||
|
||||
def places_for_urn(registry: dict[str, Place], urn: int) -> tuple[Place, ...]:
|
||||
"""Every published place containing this school, largest kind first.
|
||||
|
||||
The reverse of the registry, and the thing school pages link out through.
|
||||
Derived from the registry rather than stored beside it, so the two cannot
|
||||
disagree about which places exist: a link module must never offer a place
|
||||
whose page does not exist, and a place below the publish threshold is
|
||||
simply absent from the registry, so it is absent from here too.
|
||||
|
||||
Ordered authority → town/locality → outcode, widest first, because that is
|
||||
the order a breadcrumb reads and the order the link module lists.
|
||||
"""
|
||||
order = {"authority": 0, "town": 1, "locality": 2, "outcode": 3}
|
||||
found = [p for p in registry.values() if urn in p.urns]
|
||||
return tuple(sorted(found, key=lambda p: (order.get(p.kind, 9), p.slug)))
|
||||
|
||||
|
||||
def build_place_registry(df) -> dict[str, Place]:
|
||||
"""Every place the site publishes, keyed by "<kind>:<slug>"."""
|
||||
if df.empty or "urn" not in df.columns:
|
||||
|
||||
@@ -8,7 +8,7 @@ import numpy as np
|
||||
import pandas as pd
|
||||
import pytest
|
||||
|
||||
from backend.places import MIN_SCHOOLS, build_place_registry
|
||||
from backend.places import MIN_SCHOOLS, build_place_registry, places_for_urn
|
||||
|
||||
|
||||
def _df(rows: list[dict]) -> pd.DataFrame:
|
||||
@@ -418,3 +418,54 @@ def test_an_authority_still_publishes_phase_variants():
|
||||
and /schools/authority/[la]/[phase] is the route that serves it."""
|
||||
reg = build_place_registry(_df(_town(MIN_SCHOOLS, "Maidstone", "Kent")))
|
||||
assert reg["authority:kent"].publishes_phase("primary")
|
||||
|
||||
|
||||
# ── The reverse index: which published places contain a school ──────────────
|
||||
#
|
||||
# School pages link out to the location layer through this. It is the whole
|
||||
# point of the index: before it, ~27k school pages linked to nothing on the
|
||||
# site and stranded whatever authority they held.
|
||||
|
||||
def test_a_school_resolves_to_every_published_place_containing_it():
|
||||
reg = build_place_registry(_df(_town(MIN_SCHOOLS, "Brentwood", "Essex")))
|
||||
places = places_for_urn(reg, 100000)
|
||||
|
||||
kinds = {p.kind for p in places}
|
||||
assert "town" in kinds
|
||||
assert "authority" in kinds
|
||||
|
||||
|
||||
def test_a_school_in_an_unpublished_town_still_resolves_to_its_authority():
|
||||
# A town below the threshold has no page, so there is no link to offer —
|
||||
# but the authority above it clears the threshold on the same schools and
|
||||
# is where that reader should be sent.
|
||||
reg = build_place_registry(_df(
|
||||
_town(MIN_SCHOOLS - 1, "Tinytown", "Essex")
|
||||
+ _town(MIN_SCHOOLS, "Brentwood", "Essex", start=200000)
|
||||
))
|
||||
places = places_for_urn(reg, 100000)
|
||||
|
||||
# The town is below the threshold, so it has no page and must not be
|
||||
# offered as a link. The authority above it does, and is the right target.
|
||||
assert all(p.slug != "tinytown" for p in places)
|
||||
assert "authority" in {p.kind for p in places}
|
||||
|
||||
|
||||
def test_an_unknown_urn_resolves_to_nothing_rather_than_raising():
|
||||
# A school page renders for any URN the API knows; the link module is not
|
||||
# entitled to take the page down when it has nothing to say.
|
||||
reg = build_place_registry(_df(_town(MIN_SCHOOLS, "Brentwood", "Essex")))
|
||||
assert places_for_urn(reg, 999999) == ()
|
||||
|
||||
|
||||
def test_the_index_is_consistent_with_the_registry_it_was_built_from():
|
||||
# The invariant that matters: a link module must never offer a place whose
|
||||
# page does not exist, and never omit one that does.
|
||||
reg = build_place_registry(_df(
|
||||
_town(MIN_SCHOOLS, "Brentwood", "Essex")
|
||||
+ _town(MIN_SCHOOLS, "Bedford", "Bedford", start=300000)
|
||||
))
|
||||
for key, place in reg.items():
|
||||
for urn in place.urns:
|
||||
assert place in places_for_urn(reg, urn), (
|
||||
f"{urn} is in {key} but the index does not say so")
|
||||
@@ -69,3 +69,60 @@ def test_nan_gias_fields_serialize_as_null(client):
|
||||
assert info["capacity"] is None
|
||||
assert info["total_pupils"] is None
|
||||
assert info["school_name"] == "West London Performing Arts Academy"
|
||||
|
||||
|
||||
# ── Links out to the location layer ─────────────────────────────────────────
|
||||
#
|
||||
# School pages carried no link into the site at all: the only anchor on the
|
||||
# template pointed at the school's own website, so ~27k pages received
|
||||
# whatever authority the site had and sent it off-site. `places` is what the
|
||||
# link module and the breadcrumb are built from.
|
||||
|
||||
def test_places_is_present_even_when_the_school_belongs_to_none(client):
|
||||
# This fixture's single school cannot clear any publish threshold, so the
|
||||
# honest answer is an empty list. The key must still be there: a missing
|
||||
# key and "no places" are different things to the page rendering it.
|
||||
body = client.get("/api/schools/150275").json()
|
||||
assert body["places"] == []
|
||||
|
||||
|
||||
def test_places_names_only_pages_that_exist(monkeypatch):
|
||||
from backend import app as app_module
|
||||
from backend.places import MIN_SCHOOLS
|
||||
|
||||
def _df():
|
||||
return pd.DataFrame([
|
||||
{
|
||||
"urn": 100000 + i,
|
||||
"school_name": f"Brentwood School {i}",
|
||||
"town": "Brentwood",
|
||||
"local_authority": "Essex",
|
||||
"postcode": "CM15 8AA",
|
||||
"phase": "Primary",
|
||||
"year": 202425,
|
||||
"rwm_expected_pct": 60.0,
|
||||
"attainment_8_score": np.nan,
|
||||
"ofsted_grade": 2.0,
|
||||
"ofsted_date": None,
|
||||
}
|
||||
for i in range(MIN_SCHOOLS)
|
||||
])
|
||||
|
||||
monkeypatch.setattr(app_module, "load_school_data", _df)
|
||||
monkeypatch.setattr(app_module, "get_supplementary_data", lambda db, urn: {})
|
||||
monkeypatch.setattr(app_module, "_place_registry", None)
|
||||
client = TestClient(app_module.app, raise_server_exceptions=False)
|
||||
|
||||
places = client.get("/api/schools/100000").json()["places"]
|
||||
assert places, "a school in a published town must offer links"
|
||||
|
||||
by_kind = {p["kind"]: p for p in places}
|
||||
assert by_kind["town"]["url"] == "/schools/brentwood"
|
||||
assert by_kind["authority"]["url"] == "/schools/authority/essex"
|
||||
|
||||
# Every entry carries what the link text needs, and a count, so the anchor
|
||||
# can say what it leads to rather than "click here".
|
||||
for place in places:
|
||||
assert place["name"]
|
||||
assert place["count"] >= 1
|
||||
assert place["url"].startswith("/schools/")
|
||||
@@ -1935,6 +1935,50 @@ async function firstPlaceOfKind(page: Page, kind: string) {
|
||||
return hit as { kind: string; slug: string; name: string; count: number };
|
||||
}
|
||||
|
||||
/**
|
||||
* The round trip. Place pages always linked down to school pages; school
|
||||
* pages linked nowhere on the site, so the ~27k of them that carry most of
|
||||
* the inbound authority stranded it — their only anchor pointed at the
|
||||
* school's own website.
|
||||
*
|
||||
* Asserting both directions is the point. A one-way link is what already
|
||||
* existed and is not what this journey is for.
|
||||
*/
|
||||
test('a school page links back into the location layer, and the place page links down', async ({ page }) => {
|
||||
const town = await firstPlaceOfKind(page, 'town');
|
||||
|
||||
// Start from the place page and take its first school, so the pair is
|
||||
// guaranteed to be genuinely related rather than a hardcoded guess.
|
||||
await page.goto(`/schools/${town.slug}`);
|
||||
const schoolHref = await page.locator('a[href^="/school/"]').first()
|
||||
.getAttribute('href');
|
||||
expect(schoolHref, 'the town page listed no school to follow').toBeTruthy();
|
||||
|
||||
await page.goto(schoolHref!);
|
||||
|
||||
// Down: the school page must offer a link back to the town it sits in.
|
||||
const backToTown = page.locator(`a[href="/schools/${town.slug}"]`);
|
||||
await expect(backToTown).toHaveCount(1);
|
||||
await expect(backToTown).toBeVisible();
|
||||
|
||||
// The anchor says what it leads to, which is worth more than "see more".
|
||||
await expect(backToTown).toContainText(town.name, { ignoreCase: true });
|
||||
await expect(backToTown).toContainText(/\d+ schools?/);
|
||||
|
||||
// And the breadcrumb resolves the school into a real hierarchy.
|
||||
const blocks = await page.locator('script[type="application/ld+json"]')
|
||||
.allTextContents();
|
||||
const graph = blocks.join(' ');
|
||||
expect(graph).toContain('"BreadcrumbList"');
|
||||
// The narrower type, not the EducationalOrganization parent it used to be.
|
||||
expect(graph).toContain('"School"');
|
||||
|
||||
// Following it lands on a real page, not a 404.
|
||||
await backToTown.click();
|
||||
await page.waitForURL(new RegExp(`/schools/${town.slug}$`));
|
||||
await expect(page.locator('h1')).toContainText(town.name, { ignoreCase: true });
|
||||
});
|
||||
|
||||
for (const [kind, prefix, article] of [
|
||||
['town', '/schools/', 'a'],
|
||||
['authority', '/schools/authority/', 'an'],
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
/**
|
||||
* The module that ends the stranding: before it, a school page's only anchor
|
||||
* pointed at the school's own website, so ~27k pages sent authority off-site
|
||||
* and none of it reached the location layer.
|
||||
*/
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { NearbyPlaces } from '@/components/school/NearbyPlaces';
|
||||
|
||||
const essex = { kind: 'authority', slug: 'essex', name: 'Essex', count: 480, url: '/schools/authority/essex' };
|
||||
const brentwood = { kind: 'town', slug: 'brentwood', name: 'Brentwood', count: 37, url: '/schools/brentwood' };
|
||||
const cm15 = { kind: 'outcode', slug: 'cm15', name: 'CM15', count: 12, url: '/schools/near/cm15' };
|
||||
|
||||
describe('NearbyPlaces', () => {
|
||||
it('links to every place the school belongs to', () => {
|
||||
render(<NearbyPlaces places={[essex, brentwood, cm15]} />);
|
||||
|
||||
expect(screen.getByRole('link', { name: /Brentwood/ }))
|
||||
.toHaveAttribute('href', '/schools/brentwood');
|
||||
expect(screen.getByRole('link', { name: /Essex/ }))
|
||||
.toHaveAttribute('href', '/schools/authority/essex');
|
||||
expect(screen.getByRole('link', { name: /CM15/ }))
|
||||
.toHaveAttribute('href', '/schools/near/cm15');
|
||||
});
|
||||
|
||||
it('says how many schools each link leads to', () => {
|
||||
// An anchor that states its destination's size is worth more to a reader
|
||||
// and to a crawler than "see more".
|
||||
render(<NearbyPlaces places={[brentwood]} />);
|
||||
expect(screen.getByRole('link', { name: /37 schools in Brentwood/ }))
|
||||
.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders nothing at all when the school has no published places', () => {
|
||||
// Not an empty heading. A school whose town and authority both fall below
|
||||
// the threshold has nowhere to point, and the page should look as it did
|
||||
// before the module existed.
|
||||
const { container } = render(<NearbyPlaces places={[]} />);
|
||||
expect(container).toBeEmptyDOMElement();
|
||||
});
|
||||
|
||||
it('puts the narrowest place first, which is the most useful link', () => {
|
||||
// The API orders widest-first for the breadcrumb; a reader on a school
|
||||
// page wants its town before its county.
|
||||
render(<NearbyPlaces places={[essex, brentwood, cm15]} />);
|
||||
const hrefs = screen.getAllByRole('link').map((a) => a.getAttribute('href'));
|
||||
expect(hrefs.indexOf('/schools/brentwood'))
|
||||
.toBeLessThan(hrefs.indexOf('/schools/authority/essex'));
|
||||
});
|
||||
|
||||
it('handles a singular count without saying "1 schools"', () => {
|
||||
render(<NearbyPlaces places={[{ ...brentwood, count: 1 }]} />);
|
||||
expect(screen.getByRole('link', { name: /1 school in Brentwood/ }))
|
||||
.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,68 @@
|
||||
/**
|
||||
* School pages had no BreadcrumbList and no links into the location layer.
|
||||
* Both are fixed by the same data — the `places` array the API now returns —
|
||||
* so they are tested together.
|
||||
*/
|
||||
import { schoolBreadcrumbJsonLd } from '@/lib/jsonld';
|
||||
|
||||
const essex = { kind: 'authority', slug: 'essex', name: 'Essex', count: 480, url: '/schools/authority/essex' };
|
||||
const brentwood = { kind: 'town', slug: 'brentwood', name: 'Brentwood', count: 37, url: '/schools/brentwood' };
|
||||
const outcode = { kind: 'outcode', slug: 'cm15', name: 'CM15', count: 12, url: '/schools/near/cm15' };
|
||||
|
||||
describe('school breadcrumbs', () => {
|
||||
it('reads home to authority to town to school', () => {
|
||||
const ld = schoolBreadcrumbJsonLd({
|
||||
name: 'Brentwood School', url: '/school/100000-brentwood-school',
|
||||
places: [essex, brentwood],
|
||||
});
|
||||
|
||||
expect(ld['@type']).toBe('BreadcrumbList');
|
||||
expect(ld.itemListElement.map((i) => i.name))
|
||||
.toEqual(['schoolcompare', 'Essex', 'Brentwood', 'Brentwood School']);
|
||||
expect(ld.itemListElement.map((i) => i.position)).toEqual([1, 2, 3, 4]);
|
||||
});
|
||||
|
||||
it('skips a level the school has no published place for', () => {
|
||||
// A school whose town falls below the publish threshold has no town page.
|
||||
// The trail closes over the gap rather than linking to a 404.
|
||||
const ld = schoolBreadcrumbJsonLd({
|
||||
name: 'Lone School', url: '/school/1-lone-school', places: [essex],
|
||||
});
|
||||
|
||||
expect(ld.itemListElement.map((i) => i.name))
|
||||
.toEqual(['schoolcompare', 'Essex', 'Lone School']);
|
||||
expect(ld.itemListElement.map((i) => i.position)).toEqual([1, 2, 3]);
|
||||
});
|
||||
|
||||
it('omits outcodes, which are not a place a breadcrumb reads through', () => {
|
||||
// CM15 is a useful link in the module but nonsense in a trail: nobody
|
||||
// navigates Essex → CM15 → school.
|
||||
const ld = schoolBreadcrumbJsonLd({
|
||||
name: 'Brentwood School', url: '/school/100000-brentwood-school',
|
||||
places: [essex, brentwood, outcode],
|
||||
});
|
||||
|
||||
expect(JSON.stringify(ld)).not.toContain('cm15');
|
||||
});
|
||||
|
||||
it('still produces a valid trail when the school has no places at all', () => {
|
||||
const ld = schoolBreadcrumbJsonLd({
|
||||
name: 'Orphan School', url: '/school/2-orphan-school', places: [],
|
||||
});
|
||||
|
||||
expect(ld.itemListElement.map((i) => i.name)).toEqual(['schoolcompare', 'Orphan School']);
|
||||
});
|
||||
|
||||
it('uses absolute urls, as every other entity on the site does', () => {
|
||||
const ld = schoolBreadcrumbJsonLd({
|
||||
name: 'Brentwood School', url: '/school/100000-brentwood-school',
|
||||
places: [essex, brentwood],
|
||||
});
|
||||
|
||||
for (const item of ld.itemListElement) {
|
||||
expect(item.item).toMatch(/^https:\/\/www\.schoolcompare\.co\.uk\//);
|
||||
}
|
||||
// The root is the homepage: there is no /schools index page to link to.
|
||||
expect(ld.itemListElement[0].item).toBe('https://www.schoolcompare.co.uk/');
|
||||
});
|
||||
});
|
||||
@@ -7,6 +7,8 @@
|
||||
import { fetchSchoolDetails, fetchSchools, fetchNationalAverages } from '@/lib/api';
|
||||
import { notFound, redirect } from 'next/navigation';
|
||||
import { SchoolDetailShell } from '@/components/school/SchoolDetailShell';
|
||||
import { NearbyPlaces } from '@/components/school/NearbyPlaces';
|
||||
import { schoolBreadcrumbJsonLd, type SchoolPlace } from '@/lib/jsonld';
|
||||
import { PrimarySchoolSections } from '@/components/school/PrimarySchoolSections';
|
||||
import { SecondarySchoolSections } from '@/components/school/SecondarySchoolSections';
|
||||
import {
|
||||
@@ -149,6 +151,10 @@ export default async function SchoolPage({ params }: SchoolPageProps) {
|
||||
}
|
||||
|
||||
const { school_info, yearly_data, absence_data, ofsted, census, admissions, admissions_history, admission_distance, deprivation, finance, destinations } = data;
|
||||
// Absent on an older API build; the module and the trail both degrade to
|
||||
// nothing rather than throwing, which is how this shipped without a
|
||||
// lockstep deploy of the two images.
|
||||
const places: SchoolPlace[] = data.places ?? [];
|
||||
|
||||
// Redirect bare URN to canonical slug URL
|
||||
const canonicalSlug = schoolUrl(urn, school_info.school_name).replace('/school/', '');
|
||||
@@ -185,10 +191,19 @@ export default async function SchoolPage({ params }: SchoolPageProps) {
|
||||
const primaryNavItems = buildNavItems(primaryFlags, navInput);
|
||||
const secondaryNavItems = buildSecondaryNavItems(secondaryFlags, navInput);
|
||||
|
||||
// Generate JSON-LD structured data for SEO
|
||||
/*
|
||||
* `School`, not `EducationalOrganization`.
|
||||
*
|
||||
* Both are valid, but EducationalOrganization is the parent type covering
|
||||
* universities, training providers and nurseries alike. School is the
|
||||
* specific one, and a type that says what the page is about is the whole
|
||||
* point of declaring it. Google's own guidance treats the narrower type as
|
||||
* the correct choice where it applies.
|
||||
*/
|
||||
const structuredData = {
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'EducationalOrganization',
|
||||
'@graph': [{
|
||||
'@type': 'School',
|
||||
name: school_info.school_name,
|
||||
identifier: school_info.urn.toString(),
|
||||
...(school_info.address && {
|
||||
@@ -210,6 +225,15 @@ export default async function SchoolPage({ params }: SchoolPageProps) {
|
||||
...(school_info.school_type && {
|
||||
additionalType: school_info.school_type,
|
||||
}),
|
||||
},
|
||||
// The trail the page sits at the end of. School pages carried no
|
||||
// breadcrumb at all, while every place page already emitted one.
|
||||
schoolBreadcrumbJsonLd({
|
||||
name: school_info.school_name,
|
||||
url: `/school/${slug}`,
|
||||
places,
|
||||
}),
|
||||
],
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -264,6 +288,7 @@ export default async function SchoolPage({ params }: SchoolPageProps) {
|
||||
/>
|
||||
</SchoolDetailShell>
|
||||
)}
|
||||
<NearbyPlaces places={places} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
/* Tokens only — the same vocabulary schoolSections.module.css uses, so the
|
||||
module follows both themes without a rule of its own. No hardcoded colour
|
||||
appears here; darkThemeSafety asserts that across the codebase. */
|
||||
|
||||
.section {
|
||||
margin-top: 2rem;
|
||||
}
|
||||
|
||||
/* Matches .sectionTitle in schoolSections.module.css, including the brand
|
||||
rule before the text, so this reads as one more section of the page
|
||||
rather than a footer bolted underneath it. */
|
||||
.heading {
|
||||
font-size: 1.125rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
margin-bottom: 0.875rem;
|
||||
padding-bottom: 0.5rem;
|
||||
border-bottom: 2px solid var(--border);
|
||||
font-family: var(--font-display);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
}
|
||||
|
||||
.heading::before {
|
||||
content: "";
|
||||
display: inline-block;
|
||||
width: 3px;
|
||||
height: 1em;
|
||||
background: var(--brand);
|
||||
border-radius: 2px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.list {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem 1.25rem;
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.link {
|
||||
color: var(--brand-strong);
|
||||
font-weight: 500;
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 2px;
|
||||
}
|
||||
|
||||
.link:hover {
|
||||
text-decoration-thickness: 2px;
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import Link from 'next/link';
|
||||
import type { SchoolPlace } from '@/lib/jsonld';
|
||||
import styles from './NearbyPlaces.module.css';
|
||||
|
||||
/**
|
||||
* Links from a school page into the location layer.
|
||||
*
|
||||
* This exists for a structural reason rather than a decorative one. Before
|
||||
* it, the only anchor on a school page pointed at the school's own website,
|
||||
* so the ~27k pages that carry most of the site's inbound authority passed it
|
||||
* straight off-site and none of it reached the place pages. These links are
|
||||
* what circulate it instead.
|
||||
*
|
||||
* Every entry comes from the place registry via the API, so a link is only
|
||||
* ever offered for a page that exists: a place below the publish threshold is
|
||||
* absent from the registry and therefore absent here.
|
||||
*/
|
||||
|
||||
/** Narrowest first: a reader on a school page wants its town before its
|
||||
* county. The API orders widest-first because that is what the breadcrumb
|
||||
* reads, so the two orders are deliberately different. */
|
||||
const ORDER: Record<string, number> = {
|
||||
town: 0, locality: 0, outcode: 1, authority: 2,
|
||||
};
|
||||
|
||||
function label(place: SchoolPlace): string {
|
||||
const noun = place.count === 1 ? 'school' : 'schools';
|
||||
const preposition = place.kind === 'outcode' ? 'near' : 'in';
|
||||
return `${place.count} ${noun} ${preposition} ${place.name}`;
|
||||
}
|
||||
|
||||
export function NearbyPlaces({ places }: { places: SchoolPlace[] }) {
|
||||
if (places.length === 0) return null;
|
||||
|
||||
const sorted = [...places].sort(
|
||||
(a, b) => (ORDER[a.kind] ?? 9) - (ORDER[b.kind] ?? 9),
|
||||
);
|
||||
|
||||
return (
|
||||
<section className={styles.section} aria-labelledby="nearby-places">
|
||||
<h2 id="nearby-places" className={styles.heading}>More schools near here</h2>
|
||||
<ul className={styles.list}>
|
||||
{sorted.map((place) => (
|
||||
<li key={`${place.kind}:${place.slug}`}>
|
||||
<Link href={place.url} className={styles.link}>{label(place)}</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -69,6 +69,63 @@ export function blogPostingJsonLd(
|
||||
} 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',
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import type { SchoolPlace } from '@/lib/jsonld';
|
||||
|
||||
/**
|
||||
* TypeScript type definitions for SchoolCompare API
|
||||
* Generated from backend/models.py and backend/schemas.py
|
||||
@@ -346,6 +348,15 @@ export interface SchoolsResponse {
|
||||
|
||||
export interface SchoolDetailsResponse {
|
||||
school_info: School;
|
||||
/**
|
||||
* The published location-layer pages containing this school, widest first.
|
||||
*
|
||||
* Optional because the frontend and backend ship as separate images: a
|
||||
* frontend deployed ahead of the API that serves this must render without
|
||||
* it, not throw. Empty is also a real answer — a school whose town and
|
||||
* authority both fall below the publish threshold has nowhere to link.
|
||||
*/
|
||||
places?: SchoolPlace[];
|
||||
yearly_data: SchoolResult[];
|
||||
absence_data: AbsenceData | null;
|
||||
// Supplementary data (null until Kestra populates)
|
||||
|
||||
Reference in new issue
Block a user