feat(seo): add school name to URLs, fix sticky nav, collapse compare widget
Some checks failed
Build and Push Docker Images / Build Backend (FastAPI) (push) Successful in 32s
Build and Push Docker Images / Build Frontend (Next.js) (push) Failing after 57s
Build and Push Docker Images / Build Pipeline (Meltano + dbt + Airflow) (push) Successful in 31s
Build and Push Docker Images / Trigger Portainer Update (push) Has been skipped
Some checks failed
Build and Push Docker Images / Build Backend (FastAPI) (push) Successful in 32s
Build and Push Docker Images / Build Frontend (Next.js) (push) Failing after 57s
Build and Push Docker Images / Build Pipeline (Meltano + dbt + Airflow) (push) Successful in 31s
Build and Push Docker Images / Trigger Portainer Update (push) Has been skipped
- URLs now /school/138267-school-name instead of /school/138267 - Bare URN URLs redirect to canonical slug (backward compat) - Remove overflow-x:hidden that broke sticky tab nav on secondary pages - ComparisonToast starts collapsed — user must click to open Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,23 +1,25 @@
|
|||||||
/**
|
/**
|
||||||
* Individual School Page (SSR)
|
* Individual School Page (SSR)
|
||||||
* Dynamic route for school details with full SEO optimization
|
* Dynamic route for school details with full SEO optimization
|
||||||
|
* URL format: /school/138267-school-name-here
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { fetchSchoolDetails } from '@/lib/api';
|
import { fetchSchoolDetails } from '@/lib/api';
|
||||||
import { notFound } from 'next/navigation';
|
import { notFound, redirect } from 'next/navigation';
|
||||||
import { SchoolDetailView } from '@/components/SchoolDetailView';
|
import { SchoolDetailView } from '@/components/SchoolDetailView';
|
||||||
import { SecondarySchoolDetailView } from '@/components/SecondarySchoolDetailView';
|
import { SecondarySchoolDetailView } from '@/components/SecondarySchoolDetailView';
|
||||||
|
import { parseSchoolSlug, schoolUrl } from '@/lib/utils';
|
||||||
import type { Metadata } from 'next';
|
import type { Metadata } from 'next';
|
||||||
|
|
||||||
interface SchoolPageProps {
|
interface SchoolPageProps {
|
||||||
params: Promise<{ urn: string }>;
|
params: Promise<{ slug: string }>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function generateMetadata({ params }: SchoolPageProps): Promise<Metadata> {
|
export async function generateMetadata({ params }: SchoolPageProps): Promise<Metadata> {
|
||||||
const { urn: urnString } = await params;
|
const { slug } = await params;
|
||||||
const urn = parseInt(urnString);
|
const urn = parseSchoolSlug(slug);
|
||||||
|
|
||||||
if (isNaN(urn) || urn < 100000 || urn > 999999) {
|
if (!urn || urn < 100000 || urn > 999999) {
|
||||||
return {
|
return {
|
||||||
title: 'School Not Found',
|
title: 'School Not Found',
|
||||||
};
|
};
|
||||||
@@ -27,6 +29,7 @@ export async function generateMetadata({ params }: SchoolPageProps): Promise<Met
|
|||||||
const data = await fetchSchoolDetails(urn);
|
const data = await fetchSchoolDetails(urn);
|
||||||
const { school_info } = data;
|
const { school_info } = data;
|
||||||
|
|
||||||
|
const canonicalPath = schoolUrl(urn, school_info.school_name);
|
||||||
const isSecondary = (school_info.phase ?? '').toLowerCase().includes('secondary')
|
const isSecondary = (school_info.phase ?? '').toLowerCase().includes('secondary')
|
||||||
|| (data.yearly_data ?? []).some((d: any) => d.attainment_8_score != null);
|
|| (data.yearly_data ?? []).some((d: any) => d.attainment_8_score != null);
|
||||||
const title = `${school_info.school_name} | ${school_info.local_authority || 'England'}`;
|
const title = `${school_info.school_name} | ${school_info.local_authority || 'England'}`;
|
||||||
@@ -44,7 +47,7 @@ export async function generateMetadata({ params }: SchoolPageProps): Promise<Met
|
|||||||
title,
|
title,
|
||||||
description,
|
description,
|
||||||
type: 'website',
|
type: 'website',
|
||||||
url: `https://schoolcompare.co.uk/school/${urn}`,
|
url: `https://schoolcompare.co.uk${canonicalPath}`,
|
||||||
siteName: 'SchoolCompare',
|
siteName: 'SchoolCompare',
|
||||||
},
|
},
|
||||||
twitter: {
|
twitter: {
|
||||||
@@ -53,7 +56,7 @@ export async function generateMetadata({ params }: SchoolPageProps): Promise<Met
|
|||||||
description,
|
description,
|
||||||
},
|
},
|
||||||
alternates: {
|
alternates: {
|
||||||
canonical: `https://schoolcompare.co.uk/school/${urn}`,
|
canonical: `https://schoolcompare.co.uk${canonicalPath}`,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
} catch {
|
} catch {
|
||||||
@@ -67,11 +70,11 @@ export async function generateMetadata({ params }: SchoolPageProps): Promise<Met
|
|||||||
export const dynamic = 'force-dynamic';
|
export const dynamic = 'force-dynamic';
|
||||||
|
|
||||||
export default async function SchoolPage({ params }: SchoolPageProps) {
|
export default async function SchoolPage({ params }: SchoolPageProps) {
|
||||||
const { urn: urnString } = await params;
|
const { slug } = await params;
|
||||||
const urn = parseInt(urnString);
|
const urn = parseSchoolSlug(slug);
|
||||||
|
|
||||||
// Validate URN format
|
// Validate URN format
|
||||||
if (isNaN(urn) || urn < 100000 || urn > 999999) {
|
if (!urn || urn < 100000 || urn > 999999) {
|
||||||
notFound();
|
notFound();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -86,6 +89,12 @@ export default async function SchoolPage({ params }: SchoolPageProps) {
|
|||||||
|
|
||||||
const { school_info, yearly_data, absence_data, ofsted, parent_view, census, admissions, sen_detail, phonics, deprivation, finance } = data;
|
const { school_info, yearly_data, absence_data, ofsted, parent_view, census, admissions, sen_detail, phonics, deprivation, finance } = data;
|
||||||
|
|
||||||
|
// Redirect bare URN to canonical slug URL
|
||||||
|
const canonicalSlug = schoolUrl(urn, school_info.school_name).replace('/school/', '');
|
||||||
|
if (slug !== canonicalSlug) {
|
||||||
|
redirect(`/school/${canonicalSlug}`);
|
||||||
|
}
|
||||||
|
|
||||||
const isSecondary = (school_info.phase ?? '').toLowerCase().includes('secondary')
|
const isSecondary = (school_info.phase ?? '').toLowerCase().includes('secondary')
|
||||||
|| yearly_data.some((d: any) => d.attainment_8_score != null);
|
|| yearly_data.some((d: any) => d.attainment_8_score != null);
|
||||||
|
|
||||||
@@ -5,6 +5,7 @@
|
|||||||
|
|
||||||
import { MetadataRoute } from 'next';
|
import { MetadataRoute } from 'next';
|
||||||
import { fetchSchools } from '@/lib/api';
|
import { fetchSchools } from '@/lib/api';
|
||||||
|
import { schoolUrl } from '@/lib/utils';
|
||||||
|
|
||||||
const BASE_URL = 'https://schoolcompare.co.uk';
|
const BASE_URL = 'https://schoolcompare.co.uk';
|
||||||
|
|
||||||
@@ -39,7 +40,7 @@ export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const schoolPages: MetadataRoute.Sitemap = schoolsData.schools.map((school) => ({
|
const schoolPages: MetadataRoute.Sitemap = schoolsData.schools.map((school) => ({
|
||||||
url: `${BASE_URL}/school/${school.urn}`,
|
url: `${BASE_URL}${schoolUrl(school.urn, school.school_name)}`,
|
||||||
lastModified: new Date(),
|
lastModified: new Date(),
|
||||||
changeFrequency: 'monthly',
|
changeFrequency: 'monthly',
|
||||||
priority: 0.6,
|
priority: 0.6,
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import styles from './ComparisonToast.module.css';
|
|||||||
export function ComparisonToast() {
|
export function ComparisonToast() {
|
||||||
const { selectedSchools, clearAll, removeSchool } = useComparison();
|
const { selectedSchools, clearAll, removeSchool } = useComparison();
|
||||||
const [mounted, setMounted] = useState(false);
|
const [mounted, setMounted] = useState(false);
|
||||||
const [collapsed, setCollapsed] = useState(false);
|
const [collapsed, setCollapsed] = useState(true);
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import { SchoolSearchModal } from './SchoolSearchModal';
|
|||||||
import { EmptyState } from './EmptyState';
|
import { EmptyState } from './EmptyState';
|
||||||
import { LoadingSkeleton } from './LoadingSkeleton';
|
import { LoadingSkeleton } from './LoadingSkeleton';
|
||||||
import type { ComparisonData, MetricDefinition, School } from '@/lib/types';
|
import type { ComparisonData, MetricDefinition, School } from '@/lib/types';
|
||||||
import { formatPercentage, formatProgress, CHART_COLORS } from '@/lib/utils';
|
import { formatPercentage, formatProgress, CHART_COLORS, schoolUrl } from '@/lib/utils';
|
||||||
import { fetchComparison } from '@/lib/api';
|
import { fetchComparison } from '@/lib/api';
|
||||||
import styles from './ComparisonView.module.css';
|
import styles from './ComparisonView.module.css';
|
||||||
|
|
||||||
@@ -305,7 +305,7 @@ export function ComparisonView({
|
|||||||
×
|
×
|
||||||
</button>
|
</button>
|
||||||
<h2 className={styles.schoolName}>
|
<h2 className={styles.schoolName}>
|
||||||
<a href={`/school/${school.urn}`}>{school.school_name}</a>
|
<a href={schoolUrl(school.urn, school.school_name)}>{school.school_name}</a>
|
||||||
</h2>
|
</h2>
|
||||||
<div className={styles.schoolMeta}>
|
<div className={styles.schoolMeta}>
|
||||||
{school.local_authority && (
|
{school.local_authority && (
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import { EmptyState } from './EmptyState';
|
|||||||
import { useComparisonContext } from '@/context/ComparisonContext';
|
import { useComparisonContext } from '@/context/ComparisonContext';
|
||||||
import { fetchSchools, fetchLAaverages } from '@/lib/api';
|
import { fetchSchools, fetchLAaverages } from '@/lib/api';
|
||||||
import type { SchoolsResponse, Filters, School } from '@/lib/types';
|
import type { SchoolsResponse, Filters, School } from '@/lib/types';
|
||||||
|
import { schoolUrl } from '@/lib/utils';
|
||||||
import styles from './HomeView.module.css';
|
import styles from './HomeView.module.css';
|
||||||
|
|
||||||
interface HomeViewProps {
|
interface HomeViewProps {
|
||||||
@@ -316,7 +317,7 @@ function CompactSchoolItem({ school, onAddToCompare, isInCompare }: CompactSchoo
|
|||||||
<div className={styles.compactItem}>
|
<div className={styles.compactItem}>
|
||||||
<div className={styles.compactItemContent}>
|
<div className={styles.compactItemContent}>
|
||||||
<div className={styles.compactItemHeader}>
|
<div className={styles.compactItemHeader}>
|
||||||
<a href={`/school/${school.urn}`} className={styles.compactItemName}>
|
<a href={{schoolUrl(school.urn, school.school_name)}} className={styles.compactItemName}>
|
||||||
{school.school_name}
|
{school.school_name}
|
||||||
</a>
|
</a>
|
||||||
{school.distance !== undefined && school.distance !== null && (
|
{school.distance !== undefined && school.distance !== null && (
|
||||||
@@ -352,7 +353,7 @@ function CompactSchoolItem({ school, onAddToCompare, isInCompare }: CompactSchoo
|
|||||||
>
|
>
|
||||||
{isInCompare ? '✓ Comparing' : '+ Compare'}
|
{isInCompare ? '✓ Comparing' : '+ Compare'}
|
||||||
</button>
|
</button>
|
||||||
<a href={`/school/${school.urn}`} className="btn btn-tertiary btn-sm">
|
<a href={{schoolUrl(school.urn, school.school_name)}} className="btn btn-tertiary btn-sm">
|
||||||
View
|
View
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { useEffect, useRef } from 'react';
|
|||||||
import L from 'leaflet';
|
import L from 'leaflet';
|
||||||
import 'leaflet/dist/leaflet.css';
|
import 'leaflet/dist/leaflet.css';
|
||||||
import type { School } from '@/lib/types';
|
import type { School } from '@/lib/types';
|
||||||
|
import { schoolUrl } from '@/lib/utils';
|
||||||
|
|
||||||
// Fix for default marker icons in Next.js
|
// Fix for default marker icons in Next.js
|
||||||
delete (L.Icon.Default.prototype as any)._getIconUrl;
|
delete (L.Icon.Default.prototype as any)._getIconUrl;
|
||||||
@@ -60,7 +61,7 @@ export default function LeafletMapInner({ schools, center, zoom, onMarkerClick }
|
|||||||
<strong style="font-size: 14px; display: block; margin-bottom: 8px;">${school.school_name}</strong>
|
<strong style="font-size: 14px; display: block; margin-bottom: 8px;">${school.school_name}</strong>
|
||||||
${school.local_authority ? `<div style="font-size: 12px; color: #666; margin-bottom: 4px;">${school.local_authority}</div>` : ''}
|
${school.local_authority ? `<div style="font-size: 12px; color: #666; margin-bottom: 4px;">${school.local_authority}</div>` : ''}
|
||||||
${school.school_type ? `<div style="font-size: 12px; color: #666; margin-bottom: 8px;">${school.school_type}</div>` : ''}
|
${school.school_type ? `<div style="font-size: 12px; color: #666; margin-bottom: 8px;">${school.school_type}</div>` : ''}
|
||||||
<a href="/school/${school.urn}" style="display: inline-block; margin-top: 8px; padding: 6px 12px; background: #e07256; color: white; text-decoration: none; border-radius: 4px; font-size: 12px;">View Details</a>
|
<a href="${schoolUrl(school.urn, school.school_name)}" style="display: inline-block; margin-top: 8px; padding: 6px 12px; background: #e07256; color: white; text-decoration: none; border-radius: 4px; font-size: 12px;">View Details</a>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
import { useRouter, usePathname, useSearchParams } from 'next/navigation';
|
import { useRouter, usePathname, useSearchParams } from 'next/navigation';
|
||||||
import { useComparison } from '@/hooks/useComparison';
|
import { useComparison } from '@/hooks/useComparison';
|
||||||
import type { RankingEntry, Filters, MetricDefinition } from '@/lib/types';
|
import type { RankingEntry, Filters, MetricDefinition } from '@/lib/types';
|
||||||
import { formatPercentage, formatProgress } from '@/lib/utils';
|
import { formatPercentage, formatProgress, schoolUrl } from '@/lib/utils';
|
||||||
import { EmptyState } from './EmptyState';
|
import { EmptyState } from './EmptyState';
|
||||||
import styles from './RankingsView.module.css';
|
import styles from './RankingsView.module.css';
|
||||||
|
|
||||||
@@ -267,7 +267,7 @@ export function RankingsView({
|
|||||||
)}
|
)}
|
||||||
</td>
|
</td>
|
||||||
<td className={styles.schoolCell}>
|
<td className={styles.schoolCell}>
|
||||||
<a href={`/school/${ranking.urn}`} className={styles.schoolLink}>
|
<a href={{schoolUrl(ranking.urn, ranking.school_name)}} className={styles.schoolLink}>
|
||||||
{ranking.school_name}
|
{ranking.school_name}
|
||||||
</a>
|
</a>
|
||||||
</td>
|
</td>
|
||||||
@@ -277,7 +277,7 @@ export function RankingsView({
|
|||||||
<strong>{displayValue}</strong>
|
<strong>{displayValue}</strong>
|
||||||
</td>
|
</td>
|
||||||
<td className={styles.actionCell}>
|
<td className={styles.actionCell}>
|
||||||
<a href={`/school/${ranking.urn}`} className="btn btn-tertiary btn-sm">View</a>
|
<a href={{schoolUrl(ranking.urn, ranking.school_name)}} className="btn btn-tertiary btn-sm">View</a>
|
||||||
<button
|
<button
|
||||||
onClick={() => handleAddToCompare(ranking)}
|
onClick={() => handleAddToCompare(ranking)}
|
||||||
disabled={alreadyInComparison}
|
disabled={alreadyInComparison}
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
|
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import type { School } from '@/lib/types';
|
import type { School } from '@/lib/types';
|
||||||
import { formatPercentage, formatProgress, calculateTrend, getTrendColor } from '@/lib/utils';
|
import { formatPercentage, formatProgress, calculateTrend, getTrendColor, schoolUrl } from '@/lib/utils';
|
||||||
import styles from './SchoolCard.module.css';
|
import styles from './SchoolCard.module.css';
|
||||||
|
|
||||||
interface SchoolCardProps {
|
interface SchoolCardProps {
|
||||||
@@ -25,7 +25,7 @@ export function SchoolCard({ school, onAddToCompare, onRemoveFromCompare, showDi
|
|||||||
<div className={`${styles.card} ${isInCompare ? styles.cardInCompare : ''}`}>
|
<div className={`${styles.card} ${isInCompare ? styles.cardInCompare : ''}`}>
|
||||||
<div className={styles.header}>
|
<div className={styles.header}>
|
||||||
<h3 className={styles.title}>
|
<h3 className={styles.title}>
|
||||||
<Link href={`/school/${school.urn}`}>
|
<Link href={{schoolUrl(school.urn, school.school_name)}}>
|
||||||
{school.school_name}
|
{school.school_name}
|
||||||
</Link>
|
</Link>
|
||||||
</h3>
|
</h3>
|
||||||
@@ -146,7 +146,7 @@ export function SchoolCard({ school, onAddToCompare, onRemoveFromCompare, showDi
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<div className={styles.actions}>
|
<div className={styles.actions}>
|
||||||
<Link href={`/school/${school.urn}`} className="btn btn-primary">
|
<Link href={{schoolUrl(school.urn, school.school_name)}} className="btn btn-primary">
|
||||||
View Details
|
View Details
|
||||||
</Link>
|
</Link>
|
||||||
{onAddToCompare && (
|
{onAddToCompare && (
|
||||||
|
|||||||
@@ -9,7 +9,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import type { School } from '@/lib/types';
|
import type { School } from '@/lib/types';
|
||||||
import { formatPercentage, formatProgress, calculateTrend } from '@/lib/utils';
|
import { formatPercentage, formatProgress, calculateTrend, schoolUrl } from '@/lib/utils';
|
||||||
import { progressBand } from '@/lib/metrics';
|
import { progressBand } from '@/lib/metrics';
|
||||||
import styles from './SchoolRow.module.css';
|
import styles from './SchoolRow.module.css';
|
||||||
|
|
||||||
@@ -61,7 +61,7 @@ export function SchoolRow({
|
|||||||
|
|
||||||
{/* Line 1: School name + Ofsted badge */}
|
{/* Line 1: School name + Ofsted badge */}
|
||||||
<div className={styles.line1}>
|
<div className={styles.line1}>
|
||||||
<a href={`/school/${school.urn}`} className={styles.schoolName}>
|
<a href={{schoolUrl(school.urn, school.school_name)}} className={styles.schoolName}>
|
||||||
{school.school_name}
|
{school.school_name}
|
||||||
</a>
|
</a>
|
||||||
{school.ofsted_grade && (
|
{school.ofsted_grade && (
|
||||||
@@ -155,7 +155,7 @@ export function SchoolRow({
|
|||||||
|
|
||||||
{/* Right: actions, vertically centred */}
|
{/* Right: actions, vertically centred */}
|
||||||
<div className={styles.rowActions}>
|
<div className={styles.rowActions}>
|
||||||
<a href={`/school/${school.urn}`} className="btn btn-tertiary btn-sm">
|
<a href={{schoolUrl(school.urn, school.school_name)}} className="btn btn-tertiary btn-sm">
|
||||||
View
|
View
|
||||||
</a>
|
</a>
|
||||||
{(onAddToCompare || onRemoveFromCompare) && (
|
{(onAddToCompare || onRemoveFromCompare) && (
|
||||||
|
|||||||
@@ -2,7 +2,6 @@
|
|||||||
|
|
||||||
.container {
|
.container {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
overflow-x: hidden;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Header ──────────────────────────────────────────── */
|
/* ── Header ──────────────────────────────────────────── */
|
||||||
|
|||||||
@@ -11,7 +11,7 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import type { School } from '@/lib/types';
|
import type { School } from '@/lib/types';
|
||||||
|
import { schoolUrl } from '@/lib/utils';
|
||||||
import styles from './SecondarySchoolRow.module.css';
|
import styles from './SecondarySchoolRow.module.css';
|
||||||
|
|
||||||
const OFSTED_LABELS: Record<number, string> = {
|
const OFSTED_LABELS: Record<number, string> = {
|
||||||
@@ -73,7 +73,7 @@ export function SecondarySchoolRow({
|
|||||||
|
|
||||||
{/* Line 1: School name + Ofsted badge */}
|
{/* Line 1: School name + Ofsted badge */}
|
||||||
<div className={styles.line1}>
|
<div className={styles.line1}>
|
||||||
<a href={`/school/${school.urn}`} className={styles.schoolName}>
|
<a href={{schoolUrl(school.urn, school.school_name)}} className={styles.schoolName}>
|
||||||
{school.school_name}
|
{school.school_name}
|
||||||
</a>
|
</a>
|
||||||
{school.ofsted_grade && (
|
{school.ofsted_grade && (
|
||||||
@@ -155,7 +155,7 @@ export function SecondarySchoolRow({
|
|||||||
|
|
||||||
{/* Right: actions */}
|
{/* Right: actions */}
|
||||||
<div className={styles.rowActions}>
|
<div className={styles.rowActions}>
|
||||||
<a href={`/school/${school.urn}`} className="btn btn-tertiary btn-sm">
|
<a href={{schoolUrl(school.urn, school.school_name)}} className="btn btn-tertiary btn-sm">
|
||||||
View
|
View
|
||||||
</a>
|
</a>
|
||||||
{(onAddToCompare || onRemoveFromCompare) && (
|
{(onAddToCompare || onRemoveFromCompare) && (
|
||||||
|
|||||||
@@ -20,6 +20,28 @@ export function slugify(text: string): string {
|
|||||||
.trim();
|
.trim();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build a school URL path: /school/123456-school-name (capped at ~80 chars total)
|
||||||
|
*/
|
||||||
|
const MAX_SLUG_LENGTH = 60;
|
||||||
|
|
||||||
|
export function schoolUrl(urn: number, schoolName?: string): string {
|
||||||
|
if (!schoolName) return `/school/${urn}`;
|
||||||
|
let slug = slugify(schoolName);
|
||||||
|
if (slug.length > MAX_SLUG_LENGTH) {
|
||||||
|
slug = slug.slice(0, MAX_SLUG_LENGTH).replace(/-+$/, '');
|
||||||
|
}
|
||||||
|
return `/school/${urn}-${slug}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract the URN from a school slug (e.g. "138267-some-school-name" → 138267)
|
||||||
|
*/
|
||||||
|
export function parseSchoolSlug(slug: string): number | null {
|
||||||
|
const match = slug.match(/^(\d{6})/);
|
||||||
|
return match ? parseInt(match[1], 10) : null;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Escape HTML to prevent XSS
|
* Escape HTML to prevent XSS
|
||||||
*/
|
*/
|
||||||
|
|||||||
Reference in New Issue
Block a user