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
@@ -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