2026-09-02 16:22:52 +01:00
|
|
|
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;
|
|
|
|
|
}
|
2026-09-02 16:28:58 +01:00
|
|
|
|
|
|
|
|
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.
|
2026-09-08 17:12:25 +01:00
|
|
|
*
|
|
|
|
|
* `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.
|
2026-09-02 16:28:58 +01:00
|
|
|
*/
|
2026-09-08 17:12:25 +01:00
|
|
|
export function blogPostingJsonLd(
|
|
|
|
|
post: PostSummary,
|
|
|
|
|
{ namedAuthor }: { namedAuthor: boolean },
|
|
|
|
|
) {
|
2026-09-02 16:28:58 +01:00
|
|
|
return {
|
|
|
|
|
'@type': 'BlogPosting',
|
|
|
|
|
headline: post.title,
|
|
|
|
|
description: post.excerpt,
|
|
|
|
|
url: absoluteUrl(`/blog/${post.slug}`),
|
|
|
|
|
datePublished: post.publishedAt,
|
2026-09-08 17:12:25 +01:00
|
|
|
author: {
|
|
|
|
|
'@id': namedAuthor ? `${SITE_URL}/about#tudor` : `${SITE_URL}#organization`,
|
|
|
|
|
},
|
2026-09-02 16:28:58 +01:00
|
|
|
publisher: { '@id': `${SITE_URL}#organization` },
|
|
|
|
|
} as const;
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-14 20:45:50 +01:00
|
|
|
/**
|
|
|
|
|
* 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".
|
|
|
|
|
*/
|
2026-09-14 20:54:59 +01:00
|
|
|
export interface SchoolPhasePage {
|
|
|
|
|
phase: string;
|
|
|
|
|
count: number;
|
|
|
|
|
url: string;
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-14 20:45:50 +01:00
|
|
|
export interface SchoolPlace {
|
|
|
|
|
kind: string;
|
|
|
|
|
slug: string;
|
|
|
|
|
name: string;
|
|
|
|
|
count: number;
|
|
|
|
|
url: string;
|
2026-09-14 20:54:59 +01:00
|
|
|
/**
|
|
|
|
|
* The phase variants this school is actually listed on: usually one, two
|
|
|
|
|
* for an all-through school, none for an outcode, which publishes no phase
|
|
|
|
|
* route. Decided by the place registry, never re-derived here.
|
|
|
|
|
*/
|
|
|
|
|
phases: SchoolPhasePage[];
|
2026-09-14 20:45:50 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 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;
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-02 16:28:58 +01:00
|
|
|
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;
|
|
|
|
|
}
|