feat: a named author, an About page and a Payload blog #140
No files matched your search
@@ -2554,3 +2554,32 @@ test('the destinations section never claims a pupil stayed at this school', asyn
|
||||
const text = (await section.textContent()) ?? '';
|
||||
expect(text).not.toMatch(/stayed on (here|at this school)/i);
|
||||
});
|
||||
|
||||
/**
|
||||
* The About page and the blog exist to give the site a named human author.
|
||||
* These journeys assert the load-bearing parts of that — a name, a face, the
|
||||
* honesty claim, and a resolvable Person entity — rather than exact copy,
|
||||
* which will be edited.
|
||||
*/
|
||||
test('the about page names a human author and is reachable from the footer', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
const aboutLink = page.locator('footer a[href="/about"]');
|
||||
await expect(aboutLink).toBeVisible();
|
||||
await aboutLink.click();
|
||||
await page.waitForURL(/\/about$/);
|
||||
|
||||
await expect(page.getByRole('heading', { level: 1 })).toContainText('Tudor');
|
||||
await expect(page.locator('img[alt*="Tudor"]')).toBeVisible();
|
||||
|
||||
// The credibility claim is lived experience plus stated provenance, not
|
||||
// expertise. If this sentence ever disappears the positioning has drifted.
|
||||
await expect(page.getByText(/not an education expert/i)).toBeVisible();
|
||||
|
||||
const jsonLd = await page
|
||||
.locator('script[type="application/ld+json"]')
|
||||
.first()
|
||||
.textContent();
|
||||
expect(jsonLd).toContain('"Person"');
|
||||
// First name only — a surname here would be the one place it leaks.
|
||||
expect(jsonLd).not.toMatch(/familyName/);
|
||||
});
|
||||
@@ -0,0 +1,34 @@
|
||||
import { metadata } from '@/app/(frontend)/about/page';
|
||||
import { personJsonLd, organizationJsonLd } from '@/lib/jsonld';
|
||||
|
||||
describe('/about metadata', () => {
|
||||
it('canonicalises to the bare path', () => {
|
||||
expect(metadata.alternates?.canonical)
|
||||
.toBe('https://www.schoolcompare.co.uk/about');
|
||||
});
|
||||
});
|
||||
|
||||
describe('author structured data', () => {
|
||||
it('describes a Person with a first name and a photo', () => {
|
||||
const person = personJsonLd();
|
||||
expect(person['@type']).toBe('Person');
|
||||
expect(person.name).toBe('Tudor');
|
||||
expect(person.image).toBe('https://www.schoolcompare.co.uk/brand/tudor.jpg');
|
||||
expect(person.url).toBe('https://www.schoolcompare.co.uk/about');
|
||||
});
|
||||
|
||||
it('never publishes a surname or an employer', () => {
|
||||
// Author identity constraint: first name only. A surname here would be
|
||||
// the one place it leaks, since JSON-LD is machine-read and archived.
|
||||
const serialised = JSON.stringify(personJsonLd());
|
||||
expect(serialised).not.toMatch(/familyName|Sitaru/i);
|
||||
expect(serialised).not.toMatch(/worksFor|affiliation/i);
|
||||
});
|
||||
|
||||
it('describes the site as an Organization the Person authors for', () => {
|
||||
const org = organizationJsonLd();
|
||||
expect(org['@type']).toBe('Organization');
|
||||
expect(org.name).toBe('schoolcompare');
|
||||
expect(org.url).toBe('https://www.schoolcompare.co.uk');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,83 @@
|
||||
.page {
|
||||
max-width: 42rem;
|
||||
margin: 0 auto;
|
||||
padding: 2.5rem 1.25rem 4rem;
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1.25rem;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.portrait {
|
||||
border-radius: 50%;
|
||||
border: 2px solid var(--border);
|
||||
object-fit: cover;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.kicker {
|
||||
font-family: var(--font-ui);
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
color: var(--brand);
|
||||
margin: 0 0 0.35rem;
|
||||
}
|
||||
|
||||
.heading {
|
||||
font-family: var(--font-display);
|
||||
font-size: clamp(1.5rem, 4vw, 2rem);
|
||||
font-weight: 700;
|
||||
line-height: 1.2;
|
||||
color: var(--text-primary);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.subheading {
|
||||
font-family: var(--font-display);
|
||||
font-size: 1.15rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
margin: 2.25rem 0 0.75rem;
|
||||
}
|
||||
|
||||
.prose p {
|
||||
font-family: var(--font-ui);
|
||||
font-size: 1rem;
|
||||
line-height: 1.7;
|
||||
color: var(--text-secondary);
|
||||
margin: 0 0 1.1rem;
|
||||
}
|
||||
|
||||
/* The opening paragraph carries the page. Larger, and in the primary ink
|
||||
rather than the secondary, so it reads as a voice rather than as body copy. */
|
||||
.lede {
|
||||
font-size: 1.125rem;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.prose .lede {
|
||||
font-size: 1.125rem;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.link {
|
||||
color: var(--brand);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.link:hover {
|
||||
color: var(--brand-strong);
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.header {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 1rem;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
import type { Metadata } from 'next';
|
||||
import Image from 'next/image';
|
||||
import { absoluteUrl } from '@/lib/site';
|
||||
import { personJsonLd, organizationJsonLd } from '@/lib/jsonld';
|
||||
import styles from './About.module.css';
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'About',
|
||||
description:
|
||||
'Who builds schoolcompare, why it exists, and where its numbers come from.',
|
||||
alternates: { canonical: absoluteUrl('/about') },
|
||||
};
|
||||
|
||||
export default function AboutPage() {
|
||||
const jsonLd = {
|
||||
'@context': 'https://schema.org',
|
||||
'@graph': [personJsonLd(), organizationJsonLd()],
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={styles.page}>
|
||||
<script
|
||||
type="application/ld+json"
|
||||
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
|
||||
/>
|
||||
|
||||
<header className={styles.header}>
|
||||
<Image
|
||||
src="/brand/tudor.jpg"
|
||||
alt="Tudor, who builds schoolcompare"
|
||||
width={96}
|
||||
height={96}
|
||||
className={styles.portrait}
|
||||
priority
|
||||
/>
|
||||
<div>
|
||||
<p className={styles.kicker}>Who's behind this</p>
|
||||
<h1 className={styles.heading}>I'm Tudor. I built this site.</h1>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className={styles.prose}>
|
||||
<p className={styles.lede}>
|
||||
I'm a parent in south-west London. When we started looking at
|
||||
primary schools, I found the information I needed was all published —
|
||||
and almost impossible to hold in one place.
|
||||
</p>
|
||||
|
||||
<p>
|
||||
SATs results were in one government table. Ofsted judgements were in a
|
||||
separate service, in a format that had just changed. Admissions
|
||||
distances were buried in council PDFs, a different one per borough,
|
||||
each with its own layout. I ended up building a spreadsheet, and then
|
||||
I got tired of the spreadsheet.
|
||||
</p>
|
||||
|
||||
<p>
|
||||
So I built this instead. It pulls the official figures into one place
|
||||
and puts them side by side, which is what I wanted and could not find.
|
||||
</p>
|
||||
|
||||
<h2 className={styles.subheading}>I'm not an education expert</h2>
|
||||
|
||||
<p>
|
||||
I want to be straightforward about that. I'm not a teacher, a
|
||||
governor, or an education researcher. I have no qualification that
|
||||
makes my opinion about a school worth more than yours.
|
||||
</p>
|
||||
|
||||
<p>
|
||||
What I do have is the problem itself — I'm going through primary
|
||||
admissions right now — and a working knowledge of data, which is what
|
||||
I do for a living. That combination is enough to take published
|
||||
figures and present them honestly. It is not enough to tell you which
|
||||
school is right for your child, and this site never tries to.
|
||||
</p>
|
||||
|
||||
<h2 className={styles.subheading}>Where the numbers come from</h2>
|
||||
|
||||
<p>
|
||||
Everything here is official published data: Key Stage 2 and Key Stage
|
||||
4 results and school characteristics from the Department for
|
||||
Education, inspection outcomes from Ofsted, and admissions data from
|
||||
local authorities. Nothing is estimated, modelled or filled in. Where
|
||||
a figure is missing, the page says so rather than showing a guess.
|
||||
</p>
|
||||
|
||||
<p>
|
||||
This is an independent site. It is not affiliated with the Department
|
||||
for Education or with Ofsted, and nobody pays to appear on it or to
|
||||
rank higher.
|
||||
</p>
|
||||
|
||||
<h2 className={styles.subheading}>What the data can't tell you</h2>
|
||||
|
||||
<p>
|
||||
A school is not its results. The figures here describe one year group,
|
||||
on a handful of days, measured in a way that suits national statistics
|
||||
rather than your child. A small cohort makes percentages swing wildly
|
||||
— in a class of thirty, one pupil is more than three points. Results
|
||||
say nothing at all about whether a child will be happy somewhere.
|
||||
</p>
|
||||
|
||||
<p>
|
||||
I try to build that honesty into the site rather than just say it
|
||||
here. Special schools and pupil referral units are never compared
|
||||
against a mainstream national average, because that comparison is
|
||||
meaningless and makes good schools look like failing ones. Where a
|
||||
number is unreliable, the aim is for the page to tell you before you
|
||||
draw a conclusion from it.
|
||||
</p>
|
||||
|
||||
<h2 className={styles.subheading}>If something's wrong</h2>
|
||||
|
||||
<p>
|
||||
Tell me and I'll fix it. Genuinely — if a figure looks wrong, or
|
||||
a page gives a misleading impression of a school, I want to know.
|
||||
It's the fastest way this gets better.
|
||||
</p>
|
||||
|
||||
<p>
|
||||
<a href="mailto:contact@schoolcompare.co.uk" className={styles.link}>
|
||||
contact@schoolcompare.co.uk
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -22,7 +22,8 @@
|
||||
|
||||
.content {
|
||||
display: grid;
|
||||
grid-template-columns: 1.6fr 1fr 1fr;
|
||||
/* Brand column plus three link columns: Product, Resources, About. */
|
||||
grid-template-columns: 1.6fr 1fr 1fr 1fr;
|
||||
gap: 2rem;
|
||||
margin-bottom: 3rem;
|
||||
}
|
||||
@@ -193,6 +194,14 @@
|
||||
color: var(--on-sunken);
|
||||
}
|
||||
|
||||
/* Four columns crush between the tablet range and the 768px collapse, so
|
||||
pair them up first rather than jumping straight to a single column. */
|
||||
@media (max-width: 960px) {
|
||||
.content {
|
||||
grid-template-columns: 1fr 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.container {
|
||||
padding: 2rem 1rem 1.5rem;
|
||||
|
||||
@@ -93,6 +93,18 @@ export function Footer() {
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div className={styles.section}>
|
||||
<h4 className={styles.sectionTitle}>About</h4>
|
||||
<ul className={styles.links}>
|
||||
{/* The only route to a named human. Deliberately not in the nav:
|
||||
the mobile bottom bar already carries four items, and both of
|
||||
these are lower intent than any of them. Post bylines link
|
||||
here too, which is where a reader actually asks the question. */}
|
||||
<li><a href="/about" className={styles.link}>Who's behind this</a></li>
|
||||
<li><a href="/blog" className={styles.link}>Blog</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={styles.bottom}>
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
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;
|
||||
}
|
||||
Reference in new issue
Block a user