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 SchoolPhasePage { phase: string; count: number; url: string; } export interface SchoolPlace { kind: string; slug: string; name: string; count: number; url: string; /** * 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[]; } /** * 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; }