feat(places): town, locality, authority and outcode routes
Every generateStaticParams is gated behind PRERENDER_PLACES and wrapped in the same try/catch the school route uses. The plan claimed authority pages were 'few enough to always prebuild' — but few enough still means the API must be reachable at build time, and in CI it is not: the build failed with ECONNREFUSED rather than degrading to ISR. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015mWQnpye9F299NVRCCSRvj
This commit is contained in:
1 parent
de5e790112
commit
c981d89137
6 files changed
+332
-3
No files matched your search
@@ -1362,10 +1362,14 @@ export async function generateStaticParams(): Promise<Array<{ place: string }>>
|
|||||||
// Off by default: ~2,000 place routes cannot be built in CI on every deploy.
|
// Off by default: ~2,000 place routes cannot be built in CI on every deploy.
|
||||||
// Matches the PRERENDER_SCHOOLS gate on the school route.
|
// Matches the PRERENDER_SCHOOLS gate on the school route.
|
||||||
if (process.env.PRERENDER_PLACES !== '1') return [];
|
if (process.env.PRERENDER_PLACES !== '1') return [];
|
||||||
const { fetchPlaces } = await import('@/lib/places');
|
try {
|
||||||
return (await fetchPlaces())
|
return (await fetchPlaces())
|
||||||
.filter((p) => p.kind === 'town' || p.kind === 'locality')
|
.filter((p) => p.kind === 'town' || p.kind === 'locality')
|
||||||
.map((p) => ({ place: p.slug }));
|
.map((p) => ({ place: p.slug }));
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('generateStaticParams: API unreachable, falling back to on-demand ISR.', error);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function resolve(slug: string) {
|
async function resolve(slug: string) {
|
||||||
@@ -1517,11 +1521,20 @@ export const revalidate = 604800;
|
|||||||
export const dynamicParams = true;
|
export const dynamicParams = true;
|
||||||
|
|
||||||
export async function generateStaticParams(): Promise<Array<{ la: string }>> {
|
export async function generateStaticParams(): Promise<Array<{ la: string }>> {
|
||||||
// Only ~154 authorities, so these are cheap enough to always prebuild —
|
// Gated like every other prerender in this app. There are only ~154
|
||||||
// unlike towns, which are gated behind PRERENDER_PLACES.
|
// authorities, but "few enough to always build" still means the API must be
|
||||||
|
// reachable at build time, and in CI it is not — the build fails with
|
||||||
|
// ECONNREFUSED rather than degrading. The catch is the same fallback the
|
||||||
|
// school route uses.
|
||||||
|
if (process.env.PRERENDER_PLACES !== '1') return [];
|
||||||
|
try {
|
||||||
return (await fetchPlaces())
|
return (await fetchPlaces())
|
||||||
.filter((p) => p.kind === 'authority')
|
.filter((p) => p.kind === 'authority')
|
||||||
.map((p) => ({ la: p.slug }));
|
.map((p) => ({ la: p.slug }));
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('generateStaticParams: API unreachable, falling back to on-demand ISR.', error);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function generateMetadata({ params }: Props): Promise<Metadata> {
|
export async function generateMetadata({ params }: Props): Promise<Metadata> {
|
||||||
@@ -1581,9 +1594,14 @@ export const dynamicParams = true;
|
|||||||
export async function generateStaticParams(): Promise<Array<{ outcode: string }>> {
|
export async function generateStaticParams(): Promise<Array<{ outcode: string }>> {
|
||||||
// 1,760 of these; same CI budget argument as the town routes.
|
// 1,760 of these; same CI budget argument as the town routes.
|
||||||
if (process.env.PRERENDER_PLACES !== '1') return [];
|
if (process.env.PRERENDER_PLACES !== '1') return [];
|
||||||
|
try {
|
||||||
return (await fetchPlaces())
|
return (await fetchPlaces())
|
||||||
.filter((p) => p.kind === 'outcode')
|
.filter((p) => p.kind === 'outcode')
|
||||||
.map((p) => ({ outcode: p.slug }));
|
.map((p) => ({ outcode: p.slug }));
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('generateStaticParams: API unreachable, falling back to on-demand ISR.', error);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function generateMetadata({ params }: Props): Promise<Metadata> {
|
export async function generateMetadata({ params }: Props): Promise<Metadata> {
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import { generateMetadata as placeMeta } from '@/app/schools/[place]/page';
|
||||||
|
|
||||||
|
jest.mock('@/lib/places', () => ({
|
||||||
|
...jest.requireActual('@/lib/places'),
|
||||||
|
fetchPlace: jest.fn(async (kind: string, slug: string) =>
|
||||||
|
slug === 'atlantis' ? null : ({
|
||||||
|
place: { kind, slug, name: 'Brentwood', count: 29,
|
||||||
|
parent_authority: 'Essex' },
|
||||||
|
schools: [], averages: { rwm_expected_pct: 63, attainment_8_score: null },
|
||||||
|
})),
|
||||||
|
fetchPlaces: jest.fn(async () => []),
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe('place page metadata', () => {
|
||||||
|
it('titles the page the way the place is searched', async () => {
|
||||||
|
const m = await placeMeta({ params: Promise.resolve({ place: 'brentwood' }) });
|
||||||
|
expect(m.title).toMatch(/schools in brentwood/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('canonicalises to its own path on the www host', async () => {
|
||||||
|
const m = await placeMeta({ params: Promise.resolve({ place: 'brentwood' }) });
|
||||||
|
expect(m.alternates?.canonical)
|
||||||
|
.toBe('https://www.schoolcompare.co.uk/schools/brentwood');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('an unknown place gets a not-found title rather than inventing one', async () => {
|
||||||
|
const m = await placeMeta({ params: Promise.resolve({ place: 'atlantis' }) });
|
||||||
|
expect(m.title).toMatch(/not found/i);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
/**
|
||||||
|
* Phase variants of a place page.
|
||||||
|
*
|
||||||
|
* Phase is part of the query — "primary schools in beccles", "secondary
|
||||||
|
* schools in brentwood" — not a filter applied afterwards, so each gets its
|
||||||
|
* own indexable path. A place with no schools of the phase has no page: the
|
||||||
|
* per-phase threshold, not an error.
|
||||||
|
*/
|
||||||
|
import { notFound } from 'next/navigation';
|
||||||
|
import type { Metadata } from 'next';
|
||||||
|
import { fetchPlace } from '@/lib/places';
|
||||||
|
import { fetchNationalAverages } from '@/lib/api';
|
||||||
|
import { PlaceView } from '@/components/places/PlaceView';
|
||||||
|
import { absoluteUrl } from '@/lib/site';
|
||||||
|
|
||||||
|
interface Props { params: Promise<{ place: string; phase: string }> }
|
||||||
|
|
||||||
|
export const revalidate = 604800;
|
||||||
|
export const dynamicParams = true;
|
||||||
|
|
||||||
|
const PHASES = ['primary', 'secondary'] as const;
|
||||||
|
type Phase = (typeof PHASES)[number];
|
||||||
|
|
||||||
|
const isPhase = (v: string): v is Phase => (PHASES as readonly string[]).includes(v);
|
||||||
|
|
||||||
|
async function resolve(slug: string, phase: Phase) {
|
||||||
|
return (await fetchPlace('town', slug, phase))
|
||||||
|
?? (await fetchPlace('locality', slug, phase));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function generateMetadata({ params }: Props): Promise<Metadata> {
|
||||||
|
const { place: slug, phase } = await params;
|
||||||
|
if (!isPhase(phase)) return { title: 'Place Not Found' };
|
||||||
|
const detail = await resolve(slug, phase);
|
||||||
|
if (!detail || detail.schools.length === 0) return { title: 'Place Not Found' };
|
||||||
|
|
||||||
|
const word = phase === 'secondary' ? 'Secondary' : 'Primary';
|
||||||
|
const { name } = detail.place;
|
||||||
|
return {
|
||||||
|
title: `${word} Schools in ${name} — Ranked | schoolcompare`,
|
||||||
|
description:
|
||||||
|
`Every ${phase} school in ${name} ranked by results, with Ofsted grades and `
|
||||||
|
+ `the local average against England.`,
|
||||||
|
alternates: { canonical: absoluteUrl(`/schools/${slug}/${phase}`) },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function PlacePhasePage({ params }: Props) {
|
||||||
|
const { place: slug, phase } = await params;
|
||||||
|
if (!isPhase(phase)) notFound();
|
||||||
|
const detail = await resolve(slug, phase);
|
||||||
|
if (!detail || detail.schools.length === 0) notFound();
|
||||||
|
|
||||||
|
const national = await fetchNationalAverages().catch(() => null);
|
||||||
|
const englandAverage = phase === 'secondary'
|
||||||
|
? national?.secondary?.attainment_8_score ?? null
|
||||||
|
: national?.primary?.rwm_expected_pct ?? null;
|
||||||
|
|
||||||
|
return <PlaceView detail={detail} phase={phase}
|
||||||
|
englandAverage={englandAverage} neighbours={[]} />;
|
||||||
|
}
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
/**
|
||||||
|
* Town and locality pages.
|
||||||
|
*
|
||||||
|
* A place below the five-school threshold is not in the registry, so
|
||||||
|
* fetchPlace returns null and the request 404s rather than rendering a page
|
||||||
|
* with nothing to say.
|
||||||
|
*/
|
||||||
|
import { notFound, redirect } from 'next/navigation';
|
||||||
|
import type { Metadata } from 'next';
|
||||||
|
import { fetchPlace, fetchPlaces, authoritySlug } from '@/lib/places';
|
||||||
|
import { fetchNationalAverages } from '@/lib/api';
|
||||||
|
import { PlaceView } from '@/components/places/PlaceView';
|
||||||
|
import { absoluteUrl } from '@/lib/site';
|
||||||
|
|
||||||
|
interface Props { params: Promise<{ place: string }> }
|
||||||
|
|
||||||
|
// ISR: place aggregates change only when the pipeline runs.
|
||||||
|
export const revalidate = 604800;
|
||||||
|
export const dynamicParams = true;
|
||||||
|
|
||||||
|
export async function generateStaticParams(): Promise<Array<{ place: string }>> {
|
||||||
|
// Off by default: ~2,000 place routes cannot be built in CI on every deploy.
|
||||||
|
// Matches the PRERENDER_SCHOOLS gate on the school route.
|
||||||
|
if (process.env.PRERENDER_PLACES !== '1') return [];
|
||||||
|
try {
|
||||||
|
return (await fetchPlaces())
|
||||||
|
.filter((p) => p.kind === 'town' || p.kind === 'locality')
|
||||||
|
.map((p) => ({ place: p.slug }));
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('generateStaticParams: API unreachable, falling back to on-demand ISR.', error);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resolve(slug: string) {
|
||||||
|
return (await fetchPlace('town', slug)) ?? (await fetchPlace('locality', slug));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Other towns in the same authority — the cheapest honest definition of
|
||||||
|
* "nearby", and enough to stop each place page being a dead end. */
|
||||||
|
async function neighboursOf(detail: { place: { slug: string; parent_authority: string | null } }) {
|
||||||
|
if (!detail.place.parent_authority) return [];
|
||||||
|
const all = await fetchPlaces();
|
||||||
|
return all
|
||||||
|
.filter((p) => p.kind === 'town' && p.slug !== detail.place.slug)
|
||||||
|
.slice(0, 12);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function generateMetadata({ params }: Props): Promise<Metadata> {
|
||||||
|
const { place: slug } = await params;
|
||||||
|
const detail = await resolve(slug);
|
||||||
|
if (!detail) return { title: 'Place Not Found' };
|
||||||
|
|
||||||
|
const { name, count } = detail.place;
|
||||||
|
return {
|
||||||
|
title: `Schools in ${name} — Compare ${count} Schools | schoolcompare`,
|
||||||
|
description:
|
||||||
|
`Every school in ${name} ranked by SATs and GCSE results, with Ofsted grades, `
|
||||||
|
+ `the local average against England, and how close you had to live to get a place.`,
|
||||||
|
alternates: { canonical: absoluteUrl(`/schools/${slug}`) },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function PlacePage({ params }: Props) {
|
||||||
|
const { place: slug } = await params;
|
||||||
|
const detail = await resolve(slug);
|
||||||
|
if (!detail) notFound();
|
||||||
|
|
||||||
|
// Global constraint: no page without a local average. A place with too few
|
||||||
|
// schools carrying results has nothing to say that a list does not, so it
|
||||||
|
// defers to its authority rather than publishing a thin page.
|
||||||
|
if (detail.averages.rwm_expected_pct == null
|
||||||
|
&& detail.averages.attainment_8_score == null) {
|
||||||
|
if (detail.place.parent_authority) {
|
||||||
|
redirect(`/schools/authority/${authoritySlug(detail.place.parent_authority)}`);
|
||||||
|
}
|
||||||
|
notFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
const national = await fetchNationalAverages().catch(() => null);
|
||||||
|
// NationalAverages is nested by phase — { primary: {...}, secondary: {...} }
|
||||||
|
// — not flat. Reading it flat silently yields undefined and the page renders
|
||||||
|
// with no comparison, which is the one thing that makes it not a list.
|
||||||
|
return (
|
||||||
|
<PlaceView
|
||||||
|
detail={detail}
|
||||||
|
englandAverage={national?.primary?.rwm_expected_pct ?? null}
|
||||||
|
neighbours={await neighboursOf(detail)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
/**
|
||||||
|
* Local authority pages.
|
||||||
|
*
|
||||||
|
* A separate namespace from /schools/[place] because 67 town names collide
|
||||||
|
* with an authority name and neither set contains the other — Bedford the
|
||||||
|
* town holds 104 schools, Bedford the authority 86, because postal towns
|
||||||
|
* cross authority boundaries. The title says "Local Authority" so a reader
|
||||||
|
* landing on both knows which set each covers.
|
||||||
|
*/
|
||||||
|
import { notFound } from 'next/navigation';
|
||||||
|
import type { Metadata } from 'next';
|
||||||
|
import { fetchPlace, fetchPlaces } from '@/lib/places';
|
||||||
|
import { fetchNationalAverages } from '@/lib/api';
|
||||||
|
import { PlaceView } from '@/components/places/PlaceView';
|
||||||
|
import { absoluteUrl } from '@/lib/site';
|
||||||
|
|
||||||
|
interface Props { params: Promise<{ la: string }> }
|
||||||
|
|
||||||
|
export const revalidate = 604800;
|
||||||
|
export const dynamicParams = true;
|
||||||
|
|
||||||
|
export async function generateStaticParams(): Promise<Array<{ la: string }>> {
|
||||||
|
// Gated like every other prerender in this app. There are only ~154
|
||||||
|
// authorities, but "few enough to always build" still means the API must be
|
||||||
|
// reachable at build time, and in CI it is not — the build fails with
|
||||||
|
// ECONNREFUSED rather than degrading. The catch is the same fallback the
|
||||||
|
// school route uses.
|
||||||
|
if (process.env.PRERENDER_PLACES !== '1') return [];
|
||||||
|
try {
|
||||||
|
return (await fetchPlaces())
|
||||||
|
.filter((p) => p.kind === 'authority')
|
||||||
|
.map((p) => ({ la: p.slug }));
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('generateStaticParams: API unreachable, falling back to on-demand ISR.', error);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function generateMetadata({ params }: Props): Promise<Metadata> {
|
||||||
|
const { la } = await params;
|
||||||
|
const detail = await fetchPlace('authority', la);
|
||||||
|
if (!detail) return { title: 'Place Not Found' };
|
||||||
|
|
||||||
|
const { name, count } = detail.place;
|
||||||
|
return {
|
||||||
|
title: `Schools in ${name} — Local Authority | schoolcompare`,
|
||||||
|
description:
|
||||||
|
`All ${count} schools in the ${name} local authority, ranked by SATs and GCSE `
|
||||||
|
+ `results, with Ofsted grades and the authority average against England.`,
|
||||||
|
alternates: { canonical: absoluteUrl(`/schools/authority/${la}`) },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function AuthorityPage({ params }: Props) {
|
||||||
|
const { la } = await params;
|
||||||
|
const detail = await fetchPlace('authority', la);
|
||||||
|
if (!detail) notFound();
|
||||||
|
|
||||||
|
const national = await fetchNationalAverages().catch(() => null);
|
||||||
|
return (
|
||||||
|
<PlaceView
|
||||||
|
detail={detail}
|
||||||
|
englandAverage={national?.primary?.rwm_expected_pct ?? null}
|
||||||
|
neighbours={[]}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
/**
|
||||||
|
* Postcode district pages.
|
||||||
|
*
|
||||||
|
* No phase variants: nobody searches "primary schools in SW11", so the
|
||||||
|
* variants would be pages without demand. These exist to catch
|
||||||
|
* "schools near <postcode>" and to give London districts a geographic page
|
||||||
|
* where the GIAS town field cannot.
|
||||||
|
*/
|
||||||
|
import { notFound } from 'next/navigation';
|
||||||
|
import type { Metadata } from 'next';
|
||||||
|
import { fetchPlace, fetchPlaces } from '@/lib/places';
|
||||||
|
import { fetchNationalAverages } from '@/lib/api';
|
||||||
|
import { PlaceView } from '@/components/places/PlaceView';
|
||||||
|
import { absoluteUrl } from '@/lib/site';
|
||||||
|
|
||||||
|
interface Props { params: Promise<{ outcode: string }> }
|
||||||
|
|
||||||
|
export const revalidate = 604800;
|
||||||
|
export const dynamicParams = true;
|
||||||
|
|
||||||
|
export async function generateStaticParams(): Promise<Array<{ outcode: string }>> {
|
||||||
|
// 1,760 of these; same CI budget argument as the town routes.
|
||||||
|
if (process.env.PRERENDER_PLACES !== '1') return [];
|
||||||
|
try {
|
||||||
|
return (await fetchPlaces())
|
||||||
|
.filter((p) => p.kind === 'outcode')
|
||||||
|
.map((p) => ({ outcode: p.slug }));
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('generateStaticParams: API unreachable, falling back to on-demand ISR.', error);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function generateMetadata({ params }: Props): Promise<Metadata> {
|
||||||
|
const { outcode } = await params;
|
||||||
|
const detail = await fetchPlace('outcode', outcode);
|
||||||
|
if (!detail) return { title: 'Place Not Found' };
|
||||||
|
|
||||||
|
const { name, count } = detail.place;
|
||||||
|
return {
|
||||||
|
title: `Schools near ${name} | schoolcompare`,
|
||||||
|
description:
|
||||||
|
`${count} schools in the ${name} postcode district, ranked by results, with `
|
||||||
|
+ `Ofsted grades and how close you had to live to get a place.`,
|
||||||
|
alternates: { canonical: absoluteUrl(`/schools/near/${outcode}`) },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function OutcodePage({ params }: Props) {
|
||||||
|
const { outcode } = await params;
|
||||||
|
const detail = await fetchPlace('outcode', outcode);
|
||||||
|
if (!detail) notFound();
|
||||||
|
|
||||||
|
const national = await fetchNationalAverages().catch(() => null);
|
||||||
|
return (
|
||||||
|
<PlaceView
|
||||||
|
detail={detail}
|
||||||
|
englandAverage={national?.primary?.rwm_expected_pct ?? null}
|
||||||
|
neighbours={[]}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in new issue
Block a user