Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b2a3f32c62 |
No files matched your search
@@ -57,23 +57,6 @@ REGISTRY: dict[str, Flag] = {
|
|||||||
),
|
),
|
||||||
added=date(2026, 8, 26),
|
added=date(2026, 8, 26),
|
||||||
),
|
),
|
||||||
Flag(
|
|
||||||
name="about_page",
|
|
||||||
description=(
|
|
||||||
"The /about page, its footer link, its sitemap entry, and the "
|
|
||||||
"named-author byline on every blog post."
|
|
||||||
),
|
|
||||||
added=date(2026, 9, 8),
|
|
||||||
),
|
|
||||||
Flag(
|
|
||||||
name="blog",
|
|
||||||
description=(
|
|
||||||
"The /blog index, post pages, the RSS feed, their footer link "
|
|
||||||
"and their sitemap entries. Not /admin: posts must be "
|
|
||||||
"writable before the blog is readable."
|
|
||||||
),
|
|
||||||
added=date(2026, 9, 8),
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2560,56 +2560,8 @@ test('the destinations section never claims a pupil stayed at this school', asyn
|
|||||||
* These journeys assert the load-bearing parts of that — a name, a face, the
|
* 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,
|
* honesty claim, and a resolvable Person entity — rather than exact copy,
|
||||||
* which will be edited.
|
* which will be edited.
|
||||||
*
|
|
||||||
* Both are behind flags (about_page, blog), so each has a lit journey and a
|
|
||||||
* dark one. Flag state is read from the observable effect rather than from
|
|
||||||
* /api/flags, which the public proxy denies on purpose — the same approach
|
|
||||||
* distanceFeatureIsOn() takes above.
|
|
||||||
*/
|
*/
|
||||||
async function aboutPageIsOn(page: Page): Promise<boolean> {
|
|
||||||
return (await page.request.get('/about')).ok();
|
|
||||||
}
|
|
||||||
|
|
||||||
async function blogIsOn(page: Page): Promise<boolean> {
|
|
||||||
return (await page.request.get('/blog')).ok();
|
|
||||||
}
|
|
||||||
|
|
||||||
test('with the about page off, it is absent rather than empty', async ({ page }) => {
|
|
||||||
test.skip(await aboutPageIsOn(page), 'the about_page flag is on in this environment');
|
|
||||||
|
|
||||||
// Dark means the URL does not exist, not that it renders empty: a 404 is
|
|
||||||
// what stops a crawler keeping the page in its index.
|
|
||||||
expect((await page.request.get('/about')).status()).toBe(404);
|
|
||||||
|
|
||||||
// A footer link into a 404 is the failure this flag has to avoid.
|
|
||||||
await page.goto('/');
|
|
||||||
await expect(page.locator('footer a[href="/about"]')).toHaveCount(0);
|
|
||||||
|
|
||||||
// And a sitemap must never advertise a URL that 404s.
|
|
||||||
const sitemap = await page.request.get('/content-sitemap.xml');
|
|
||||||
expect(await sitemap.text()).not.toContain('/about');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('with the blog off, it is absent rather than empty', async ({ page }) => {
|
|
||||||
test.skip(await blogIsOn(page), 'the blog flag is on in this environment');
|
|
||||||
|
|
||||||
expect((await page.request.get('/blog')).status()).toBe(404);
|
|
||||||
expect((await page.request.get('/blog/rss.xml')).status()).toBe(404);
|
|
||||||
|
|
||||||
await page.goto('/');
|
|
||||||
await expect(page.locator('footer a[href="/blog"]')).toHaveCount(0);
|
|
||||||
|
|
||||||
const sitemap = await page.request.get('/content-sitemap.xml');
|
|
||||||
expect(await sitemap.text()).not.toContain('/blog');
|
|
||||||
|
|
||||||
// The admin panel is deliberately NOT flagged: posts have to be writable
|
|
||||||
// before the blog is readable, or there is nothing to turn on.
|
|
||||||
expect((await page.request.get('/admin')).status()).not.toBe(404);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('the about page names a human author and is reachable from the footer', async ({ page }) => {
|
test('the about page names a human author and is reachable from the footer', async ({ page }) => {
|
||||||
test.skip(!(await aboutPageIsOn(page)), 'the about_page flag is off in this environment');
|
|
||||||
|
|
||||||
await page.goto('/');
|
await page.goto('/');
|
||||||
const aboutLink = page.locator('footer a[href="/about"]');
|
const aboutLink = page.locator('footer a[href="/about"]');
|
||||||
await expect(aboutLink).toBeVisible();
|
await expect(aboutLink).toBeVisible();
|
||||||
@@ -2633,8 +2585,6 @@ test('the about page names a human author and is reachable from the footer', asy
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('the blog lists posts and each one renders with a byline', async ({ page }) => {
|
test('the blog lists posts and each one renders with a byline', async ({ page }) => {
|
||||||
test.skip(!(await blogIsOn(page)), 'the blog flag is off in this environment');
|
|
||||||
|
|
||||||
await page.goto('/blog');
|
await page.goto('/blog');
|
||||||
await expect(page.getByRole('heading', { level: 1 })).toBeVisible();
|
await expect(page.getByRole('heading', { level: 1 })).toBeVisible();
|
||||||
|
|
||||||
@@ -2662,13 +2612,8 @@ test('the admin panel is not indexable', async ({ page }) => {
|
|||||||
|
|
||||||
test('the content sitemap lists the about page and is advertised in robots', async ({ page }) => {
|
test('the content sitemap lists the about page and is advertised in robots', async ({ page }) => {
|
||||||
const sitemap = await page.request.get('/content-sitemap.xml');
|
const sitemap = await page.request.get('/content-sitemap.xml');
|
||||||
// Served whatever the flags say: robots.txt names it unconditionally, and
|
|
||||||
// with both dark it is a valid empty urlset rather than a 404.
|
|
||||||
expect(sitemap.ok()).toBeTruthy();
|
expect(sitemap.ok()).toBeTruthy();
|
||||||
|
expect(await sitemap.text()).toContain('/about');
|
||||||
if (await aboutPageIsOn(page)) {
|
|
||||||
expect(await sitemap.text()).toContain('/about');
|
|
||||||
}
|
|
||||||
|
|
||||||
// The school corpus sitemap is proxied from FastAPI; this one is Next's.
|
// The school corpus sitemap is proxied from FastAPI; this one is Next's.
|
||||||
// robots.txt must advertise both or the blog never gets discovered.
|
// robots.txt must advertise both or the blog never gets discovered.
|
||||||
|
|||||||
@@ -27,27 +27,14 @@ describe('BlogPosting structured data', () => {
|
|||||||
it('names the same Person entity the about page declares', () => {
|
it('names the same Person entity the about page declares', () => {
|
||||||
// By @id, not by repeating the person: search engines must resolve every
|
// By @id, not by repeating the person: search engines must resolve every
|
||||||
// post and the about page to one author entity, or the site has several.
|
// post and the about page to one author entity, or the site has several.
|
||||||
const ld = blogPostingJsonLd(post, { namedAuthor: true });
|
const ld = blogPostingJsonLd(post);
|
||||||
expect(ld['@type']).toBe('BlogPosting');
|
expect(ld['@type']).toBe('BlogPosting');
|
||||||
expect(ld.author['@id']).toBe('https://www.schoolcompare.co.uk/about#tudor');
|
expect(ld.author['@id']).toBe('https://www.schoolcompare.co.uk/about#tudor');
|
||||||
expect(ld.publisher['@id']).toBe('https://www.schoolcompare.co.uk#organization');
|
expect(ld.publisher['@id']).toBe('https://www.schoolcompare.co.uk#organization');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('attributes to the organization when the about page is dark', () => {
|
|
||||||
/*
|
|
||||||
* The two flags are independent, so blog-on-about-off is a reachable
|
|
||||||
* state. The Person entity lives at /about#tudor and that URL 404s while
|
|
||||||
* the flag is dark, so claiming it would declare an author that resolves
|
|
||||||
* to nothing — worse for the blog's credibility than having no named
|
|
||||||
* author at all. Attribute to the publisher instead.
|
|
||||||
*/
|
|
||||||
const ld = blogPostingJsonLd(post, { namedAuthor: false });
|
|
||||||
expect(ld.author['@id']).toBe('https://www.schoolcompare.co.uk#organization');
|
|
||||||
expect(JSON.stringify(ld)).not.toContain('/about');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('carries a self-referencing canonical url and the publish date', () => {
|
it('carries a self-referencing canonical url and the publish date', () => {
|
||||||
const ld = blogPostingJsonLd(post, { namedAuthor: true });
|
const ld = blogPostingJsonLd(post);
|
||||||
expect(ld.url).toBe(
|
expect(ld.url).toBe(
|
||||||
'https://www.schoolcompare.co.uk/blog/what-the-data-cannot-tell-you',
|
'https://www.schoolcompare.co.uk/blog/what-the-data-cannot-tell-you',
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,47 +0,0 @@
|
|||||||
/**
|
|
||||||
* The footer is the only navigational route to /about and /blog, so it is
|
|
||||||
* where a dark flag would otherwise leave a link into a 404.
|
|
||||||
*
|
|
||||||
* Both props default to false. A caller that forgets to pass them hides the
|
|
||||||
* links, which is the direction that cannot break a page — the same reasoning
|
|
||||||
* as backend/flags.py's "every flag defaults to False".
|
|
||||||
*/
|
|
||||||
import { render, screen } from '@testing-library/react';
|
|
||||||
import { Footer } from '@/components/Footer';
|
|
||||||
|
|
||||||
describe('footer feature links', () => {
|
|
||||||
it('links to both when both flags are on', () => {
|
|
||||||
render(<Footer aboutEnabled blogEnabled />);
|
|
||||||
expect(screen.getByRole('link', { name: /who's behind this/i }))
|
|
||||||
.toHaveAttribute('href', '/about');
|
|
||||||
expect(screen.getByRole('link', { name: /^blog$/i }))
|
|
||||||
.toHaveAttribute('href', '/blog');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('omits the about link when that flag is dark', () => {
|
|
||||||
render(<Footer blogEnabled />);
|
|
||||||
expect(screen.queryByRole('link', { name: /who's behind this/i })).toBeNull();
|
|
||||||
expect(screen.getByRole('link', { name: /^blog$/i })).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('omits the blog link when that flag is dark', () => {
|
|
||||||
render(<Footer aboutEnabled />);
|
|
||||||
expect(screen.queryByRole('link', { name: /^blog$/i })).toBeNull();
|
|
||||||
expect(screen.getByRole('link', { name: /who's behind this/i }))
|
|
||||||
.toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('drops the whole section when both are dark, not an empty heading', () => {
|
|
||||||
// Shipping dark means the footer renders as it did before the feature
|
|
||||||
// existed, not as a section with its contents removed.
|
|
||||||
render(<Footer />);
|
|
||||||
expect(screen.queryByRole('heading', { name: /^about$/i })).toBeNull();
|
|
||||||
expect(screen.queryByRole('link', { name: /who's behind this/i })).toBeNull();
|
|
||||||
expect(screen.queryByRole('link', { name: /^blog$/i })).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('defaults to dark when a caller passes nothing', () => {
|
|
||||||
render(<Footer />);
|
|
||||||
expect(screen.queryByRole('link', { name: /who's behind this/i })).toBeNull();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { getFlags, FLAGS_REVALIDATE } from '@/lib/flags';
|
import { getFlags } from '@/lib/flags';
|
||||||
|
|
||||||
// jsdom provides no global fetch, so there is nothing for jest.spyOn to attach
|
// jsdom provides no global fetch, so there is nothing for jest.spyOn to attach
|
||||||
// to — assign it and restore the original afterwards. This is the first test
|
// to — assign it and restore the original afterwards. This is the first test
|
||||||
@@ -31,28 +31,4 @@ describe('getFlags', () => {
|
|||||||
mockFetch(async () => ({ ok: false, status: 503 }));
|
mockFetch(async () => ({ ok: false, status: 503 }));
|
||||||
await expect(getFlags()).resolves.toEqual({});
|
await expect(getFlags()).resolves.toEqual({});
|
||||||
});
|
});
|
||||||
|
|
||||||
/*
|
|
||||||
* Reading a flag pins the calling route's ISR floor: Next uses the LOWEST
|
|
||||||
* revalidate among a route's fetches for the whole route. That is why the
|
|
||||||
* revalidate is an argument rather than the constant.
|
|
||||||
*
|
|
||||||
* Every SEO route here declares `revalidate = 604800`. A gate that read
|
|
||||||
* flags at the 300s default would drop the whole school and place corpus
|
|
||||||
* from a weekly cache to a 5-minute one, which is a large origin-load
|
|
||||||
* regression to pay for a feature flag.
|
|
||||||
*/
|
|
||||||
it('reads at the 300s floor by default', async () => {
|
|
||||||
mockFetch(async () => ({ ok: true, json: async () => ({}) }));
|
|
||||||
await getFlags();
|
|
||||||
expect((global.fetch as jest.Mock).mock.calls[0][1])
|
|
||||||
.toEqual({ next: { revalidate: FLAGS_REVALIDATE } });
|
|
||||||
});
|
|
||||||
|
|
||||||
it('lets a caller pass its own route floor instead', async () => {
|
|
||||||
mockFetch(async () => ({ ok: true, json: async () => ({}) }));
|
|
||||||
await getFlags(604800);
|
|
||||||
expect((global.fetch as jest.Mock).mock.calls[0][1])
|
|
||||||
.toEqual({ next: { revalidate: 604800 } });
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
@@ -1,8 +1,6 @@
|
|||||||
import type { Metadata } from 'next';
|
import type { Metadata } from 'next';
|
||||||
import Image from 'next/image';
|
import Image from 'next/image';
|
||||||
import { notFound } from 'next/navigation';
|
|
||||||
import { absoluteUrl } from '@/lib/site';
|
import { absoluteUrl } from '@/lib/site';
|
||||||
import { getFlags } from '@/lib/flags';
|
|
||||||
import { personJsonLd, organizationJsonLd } from '@/lib/jsonld';
|
import { personJsonLd, organizationJsonLd } from '@/lib/jsonld';
|
||||||
import styles from './About.module.css';
|
import styles from './About.module.css';
|
||||||
|
|
||||||
@@ -13,18 +11,7 @@ export const metadata: Metadata = {
|
|||||||
alternates: { canonical: absoluteUrl('/about') },
|
alternates: { canonical: absoluteUrl('/about') },
|
||||||
};
|
};
|
||||||
|
|
||||||
/*
|
export default function AboutPage() {
|
||||||
* Gated on about_page. The default 300s read is the right floor here: this
|
|
||||||
* page declares no revalidate of its own, so nothing is lost by it, and a flip
|
|
||||||
* lands within five minutes.
|
|
||||||
*
|
|
||||||
* notFound(), not a redirect: while the flag is dark this URL does not exist,
|
|
||||||
* and a 404 is what tells a crawler not to keep it.
|
|
||||||
*/
|
|
||||||
export default async function AboutPage() {
|
|
||||||
const flags = await getFlags();
|
|
||||||
if (flags.about_page !== true) notFound();
|
|
||||||
|
|
||||||
const jsonLd = {
|
const jsonLd = {
|
||||||
'@context': 'https://schema.org',
|
'@context': 'https://schema.org',
|
||||||
'@graph': [personJsonLd(), organizationJsonLd()],
|
'@graph': [personJsonLd(), organizationJsonLd()],
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ import type { JSXConvertersFunction } from '@payloadcms/richtext-lexical/react';
|
|||||||
import { getCachedPayload } from '@/lib/payload';
|
import { getCachedPayload } from '@/lib/payload';
|
||||||
import type { Post, Media } from '@/payload-types';
|
import type { Post, Media } from '@/payload-types';
|
||||||
import { absoluteUrl } from '@/lib/site';
|
import { absoluteUrl } from '@/lib/site';
|
||||||
import { getFlags } from '@/lib/flags';
|
|
||||||
import {
|
import {
|
||||||
blogPostingJsonLd,
|
blogPostingJsonLd,
|
||||||
breadcrumbJsonLd,
|
breadcrumbJsonLd,
|
||||||
@@ -113,15 +112,6 @@ export default async function PostPage(
|
|||||||
{ params }: { params: Promise<{ slug: string }> },
|
{ params }: { params: Promise<{ slug: string }> },
|
||||||
) {
|
) {
|
||||||
const { slug } = await params;
|
const { slug } = await params;
|
||||||
|
|
||||||
/*
|
|
||||||
* Flags read at this route's own declared floor, so gating costs it nothing.
|
|
||||||
* Checked before the post is fetched: a dark blog should not query Payload.
|
|
||||||
*/
|
|
||||||
const flags = await getFlags(3600);
|
|
||||||
if (flags.blog !== true) notFound();
|
|
||||||
const namedAuthor = flags.about_page === true;
|
|
||||||
|
|
||||||
const post = await findPost(slug);
|
const post = await findPost(slug);
|
||||||
if (!post) notFound();
|
if (!post) notFound();
|
||||||
|
|
||||||
@@ -130,15 +120,10 @@ export default async function PostPage(
|
|||||||
|
|
||||||
const jsonLd = {
|
const jsonLd = {
|
||||||
'@context': 'https://schema.org',
|
'@context': 'https://schema.org',
|
||||||
/*
|
|
||||||
* The Person entity is anchored at /about#tudor, so it is declared only
|
|
||||||
* when that page exists. Claiming an author whose URL 404s is a worse
|
|
||||||
* signal than attributing the post to the publisher.
|
|
||||||
*/
|
|
||||||
'@graph': [
|
'@graph': [
|
||||||
blogPostingJsonLd(summary, { namedAuthor }),
|
blogPostingJsonLd(summary),
|
||||||
breadcrumbJsonLd(summary),
|
breadcrumbJsonLd(summary),
|
||||||
...(namedAuthor ? [personJsonLd()] : []),
|
personJsonLd(),
|
||||||
organizationJsonLd(),
|
organizationJsonLd(),
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
@@ -157,10 +142,7 @@ export default async function PostPage(
|
|||||||
<h1 className={styles.heading}>{summary.title}</h1>
|
<h1 className={styles.heading}>{summary.title}</h1>
|
||||||
|
|
||||||
<p className={styles.byline}>
|
<p className={styles.byline}>
|
||||||
{/* Unlinked while about_page is dark; the flags are independent. */}
|
By <Link href="/about" className={styles.link}>Tudor</Link>
|
||||||
By {namedAuthor
|
|
||||||
? <Link href="/about" className={styles.link}>Tudor</Link>
|
|
||||||
: 'Tudor'}
|
|
||||||
{' · '}
|
{' · '}
|
||||||
<time dateTime={summary.publishedAt}>
|
<time dateTime={summary.publishedAt}>
|
||||||
{new Date(summary.publishedAt).toLocaleDateString('en-GB', {
|
{new Date(summary.publishedAt).toLocaleDateString('en-GB', {
|
||||||
|
|||||||
@@ -1,9 +1,7 @@
|
|||||||
import type { Metadata } from 'next';
|
import type { Metadata } from 'next';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { notFound } from 'next/navigation';
|
|
||||||
import { getCachedPayload } from '@/lib/payload';
|
import { getCachedPayload } from '@/lib/payload';
|
||||||
import { absoluteUrl } from '@/lib/site';
|
import { absoluteUrl } from '@/lib/site';
|
||||||
import { getFlags } from '@/lib/flags';
|
|
||||||
import styles from './Blog.module.css';
|
import styles from './Blog.module.css';
|
||||||
|
|
||||||
/*
|
/*
|
||||||
@@ -33,9 +31,6 @@ function formatDate(value: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default async function BlogIndexPage() {
|
export default async function BlogIndexPage() {
|
||||||
const flags = await getFlags();
|
|
||||||
if (flags.blog !== true) notFound();
|
|
||||||
|
|
||||||
const payload = await getCachedPayload();
|
const payload = await getCachedPayload();
|
||||||
const { docs } = await payload.find({
|
const { docs } = await payload.find({
|
||||||
collection: 'posts',
|
collection: 'posts',
|
||||||
@@ -53,11 +48,7 @@ export default async function BlogIndexPage() {
|
|||||||
<p className={styles.standfirst}>
|
<p className={styles.standfirst}>
|
||||||
What school performance data shows, what it doesn't, and how to
|
What school performance data shows, what it doesn't, and how to
|
||||||
read it without being misled. Written by{' '}
|
read it without being misled. Written by{' '}
|
||||||
{/* Plain text when about_page is dark: the two flags are
|
<Link href="/about" className={styles.link}>Tudor</Link>.
|
||||||
independent, so this link would otherwise point at a 404. */}
|
|
||||||
{flags.about_page === true
|
|
||||||
? <Link href="/about" className={styles.link}>Tudor</Link>
|
|
||||||
: 'Tudor'}.
|
|
||||||
</p>
|
</p>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import { getCachedPayload } from '@/lib/payload';
|
import { getCachedPayload } from '@/lib/payload';
|
||||||
import { absoluteUrl } from '@/lib/site';
|
import { absoluteUrl } from '@/lib/site';
|
||||||
import { getFlags } from '@/lib/flags';
|
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* Dynamic, not ISR.
|
* Dynamic, not ISR.
|
||||||
@@ -19,11 +18,6 @@ function escapeXml(value: string): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function GET() {
|
export async function GET() {
|
||||||
// A dark blog has no feed. 404 rather than an empty channel: an empty feed
|
|
||||||
// is a live feed with nothing in it, which a reader would keep polling.
|
|
||||||
const flags = await getFlags();
|
|
||||||
if (flags.blog !== true) return new Response('Not found', { status: 404 });
|
|
||||||
|
|
||||||
const payload = await getCachedPayload();
|
const payload = await getCachedPayload();
|
||||||
const { docs } = await payload.find({
|
const { docs } = await payload.find({
|
||||||
collection: 'posts',
|
collection: 'posts',
|
||||||
|
|||||||
@@ -8,7 +8,6 @@
|
|||||||
*/
|
*/
|
||||||
import { getCachedPayload } from '@/lib/payload';
|
import { getCachedPayload } from '@/lib/payload';
|
||||||
import { absoluteUrl } from '@/lib/site';
|
import { absoluteUrl } from '@/lib/site';
|
||||||
import { getFlags } from '@/lib/flags';
|
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* Dynamic, not ISR.
|
* Dynamic, not ISR.
|
||||||
@@ -22,33 +21,18 @@ import { getFlags } from '@/lib/flags';
|
|||||||
export const dynamic = 'force-dynamic';
|
export const dynamic = 'force-dynamic';
|
||||||
|
|
||||||
export async function GET() {
|
export async function GET() {
|
||||||
/*
|
const payload = await getCachedPayload();
|
||||||
* A dark page must not be advertised. Submitting a URL that 404s is the one
|
const { docs } = await payload.find({
|
||||||
* thing a sitemap is not allowed to do, so each entry is gated on the same
|
collection: 'posts',
|
||||||
* flag that gates the page itself.
|
where: { _status: { equals: 'published' } },
|
||||||
*
|
sort: '-publishedAt',
|
||||||
* With both flags dark this emits a valid, empty <urlset> rather than a 404:
|
limit: 500,
|
||||||
* robots.txt names this sitemap unconditionally, and an empty sitemap is a
|
depth: 0,
|
||||||
* well-formed statement that there is nothing here yet.
|
});
|
||||||
*/
|
|
||||||
const flags = await getFlags();
|
|
||||||
const aboutEnabled = flags.about_page === true;
|
|
||||||
const blogEnabled = flags.blog === true;
|
|
||||||
|
|
||||||
// Only query Payload when the blog is actually being advertised.
|
|
||||||
const docs = blogEnabled
|
|
||||||
? (await (await getCachedPayload()).find({
|
|
||||||
collection: 'posts',
|
|
||||||
where: { _status: { equals: 'published' } },
|
|
||||||
sort: '-publishedAt',
|
|
||||||
limit: 500,
|
|
||||||
depth: 0,
|
|
||||||
})).docs
|
|
||||||
: [];
|
|
||||||
|
|
||||||
const urls: Array<{ loc: string; lastmod: string | null }> = [
|
const urls: Array<{ loc: string; lastmod: string | null }> = [
|
||||||
...(aboutEnabled ? [{ loc: absoluteUrl('/about'), lastmod: null }] : []),
|
{ loc: absoluteUrl('/about'), lastmod: null },
|
||||||
...(blogEnabled ? [{ loc: absoluteUrl('/blog'), lastmod: null }] : []),
|
{ loc: absoluteUrl('/blog'), lastmod: null },
|
||||||
...docs.map((post) => ({
|
...docs.map((post) => ({
|
||||||
loc: absoluteUrl(`/blog/${post.slug}`),
|
loc: absoluteUrl(`/blog/${post.slug}`),
|
||||||
lastmod: new Date(String(post.updatedAt ?? post.publishedAt)).toISOString(),
|
lastmod: new Date(String(post.updatedAt ?? post.publishedAt)).toISOString(),
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ import { ComparisonToast } from '@/components/ComparisonToast';
|
|||||||
import { RouteTrail } from '@/components/RouteTrail';
|
import { RouteTrail } from '@/components/RouteTrail';
|
||||||
import { ComparisonProvider } from '@/context/ComparisonProvider';
|
import { ComparisonProvider } from '@/context/ComparisonProvider';
|
||||||
import { SITE_URL } from '@/lib/site';
|
import { SITE_URL } from '@/lib/site';
|
||||||
import { getFlags } from '@/lib/flags';
|
|
||||||
import './globals.css';
|
import './globals.css';
|
||||||
|
|
||||||
// Manrope carries headings and key messaging — the guideline's "friendly,
|
// Manrope carries headings and key messaging — the guideline's "friendly,
|
||||||
@@ -79,28 +78,11 @@ export const metadata: Metadata = {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
/*
|
export default function RootLayout({
|
||||||
* The footer's About and Blog links are flagged, which makes this the one
|
|
||||||
* place on the site that reads a flag on every route.
|
|
||||||
*
|
|
||||||
* 604800 is deliberate and load-bearing: it is the revalidate every SEO route
|
|
||||||
* here already declares. Next pins a route to the LOWEST revalidate among its
|
|
||||||
* fetches, so reading flags at the 300s default would drop the whole school
|
|
||||||
* and place corpus from a weekly cache to a 5-minute one — a large origin-load
|
|
||||||
* regression to hide two footer links.
|
|
||||||
*
|
|
||||||
* The cost is latency in one direction only. The pages themselves read the
|
|
||||||
* same flags at their own floors and flip within minutes; the footer links
|
|
||||||
* follow within a week. Turning a feature on early therefore shows the page
|
|
||||||
* before its footer link, which is harmless. Turning one off leaves a link to
|
|
||||||
* a 404 until the cache turns over, so a rollback that matters wants a purge.
|
|
||||||
*/
|
|
||||||
export default async function RootLayout({
|
|
||||||
children,
|
children,
|
||||||
}: Readonly<{
|
}: Readonly<{
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
}>) {
|
}>) {
|
||||||
const flags = await getFlags(604800);
|
|
||||||
return (
|
return (
|
||||||
// The font variable classes must sit on <html>, not <body>. globals.css
|
// The font variable classes must sit on <html>, not <body>. globals.css
|
||||||
// declares --font-display on :root as var(--font-manrope) and --font-ui as
|
// declares --font-display on :root as var(--font-manrope) and --font-ui as
|
||||||
@@ -144,10 +126,7 @@ export default async function RootLayout({
|
|||||||
{children}
|
{children}
|
||||||
</main>
|
</main>
|
||||||
<ComparisonToast />
|
<ComparisonToast />
|
||||||
<Footer
|
<Footer />
|
||||||
aboutEnabled={flags.about_page === true}
|
|
||||||
blogEnabled={flags.blog === true}
|
|
||||||
/>
|
|
||||||
</ComparisonProvider>
|
</ComparisonProvider>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -10,17 +10,7 @@
|
|||||||
import { LogoMark } from './Logo';
|
import { LogoMark } from './Logo';
|
||||||
import styles from './Footer.module.css';
|
import styles from './Footer.module.css';
|
||||||
|
|
||||||
/**
|
export function Footer() {
|
||||||
* Both default to false so a caller that forgets a prop hides the link rather
|
|
||||||
* than pointing it at a page that 404s. Same reasoning as backend/flags.py:
|
|
||||||
* "Every flag defaults to False."
|
|
||||||
*/
|
|
||||||
interface FooterProps {
|
|
||||||
aboutEnabled?: boolean;
|
|
||||||
blogEnabled?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function Footer({ aboutEnabled = false, blogEnabled = false }: FooterProps = {}) {
|
|
||||||
const currentYear = new Date().getFullYear();
|
const currentYear = new Date().getFullYear();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -104,26 +94,17 @@ export function Footer({ aboutEnabled = false, blogEnabled = false }: FooterProp
|
|||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Dropped entirely when both flags are dark, rather than left as an
|
<div className={styles.section}>
|
||||||
empty heading: shipping dark means the footer renders as it did
|
<h4 className={styles.sectionTitle}>About</h4>
|
||||||
before the feature existed. */}
|
<ul className={styles.links}>
|
||||||
{(aboutEnabled || blogEnabled) && (
|
{/* The only route to a named human. Deliberately not in the nav:
|
||||||
<div className={styles.section}>
|
the mobile bottom bar already carries four items, and both of
|
||||||
<h4 className={styles.sectionTitle}>About</h4>
|
these are lower intent than any of them. Post bylines link
|
||||||
<ul className={styles.links}>
|
here too, which is where a reader actually asks the question. */}
|
||||||
{/* The only route to a named human. Deliberately not in the nav:
|
<li><a href="/about" className={styles.link}>Who's behind this</a></li>
|
||||||
the mobile bottom bar already carries four items, and both of
|
<li><a href="/blog" className={styles.link}>Blog</a></li>
|
||||||
these are lower intent than any of them. Post bylines link
|
</ul>
|
||||||
here too, which is where a reader actually asks the question. */}
|
</div>
|
||||||
{aboutEnabled && (
|
|
||||||
<li><a href="/about" className={styles.link}>Who's behind this</a></li>
|
|
||||||
)}
|
|
||||||
{blogEnabled && (
|
|
||||||
<li><a href="/blog" className={styles.link}>Blog</a></li>
|
|
||||||
)}
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className={styles.bottom}>
|
<div className={styles.bottom}>
|
||||||
|
|||||||
@@ -4,26 +4,6 @@ The blog is Payload CMS, running inside the Next.js app. There is no separate
|
|||||||
service and no second deploy. Writing a post is done in the browser and takes
|
service and no second deploy. Writing a post is done in the browser and takes
|
||||||
effect on the live site within seconds.
|
effect on the live site within seconds.
|
||||||
|
|
||||||
## Before any of this: the flags
|
|
||||||
|
|
||||||
`/blog` and `/about` are behind feature flags (`blog` and `about_page`), and
|
|
||||||
every flag in this system starts off. While `blog` is dark, `/blog`, every post
|
|
||||||
page and the RSS feed return 404, and neither appears in the sitemap or the
|
|
||||||
footer. Posts still save normally, because `/admin` is deliberately **not**
|
|
||||||
flagged: you have to be able to write a post before there is anything worth
|
|
||||||
switching on.
|
|
||||||
|
|
||||||
So a new post published to a dark blog is invisible, and that is working as
|
|
||||||
intended, not a bug. Flip the flag in Unleash when the content is ready.
|
|
||||||
Staging and production hold their own values (`development` and `production`
|
|
||||||
environments), so you can light it on staging first. The page follows a flip
|
|
||||||
within five minutes; the footer link takes up to a week, because it renders in
|
|
||||||
the root layout and is cached at the same weekly floor as the school corpus.
|
|
||||||
|
|
||||||
Both flags are temporary scaffolding, like every flag here: a test starts
|
|
||||||
failing once one is older than 90 days, at which point either the feature is
|
|
||||||
permanent and the flag comes out, or it was never going to ship.
|
|
||||||
|
|
||||||
## Signing in
|
## Signing in
|
||||||
|
|
||||||
`https://www.schoolcompare.co.uk/admin`, one account, no registration. If you
|
`https://www.schoolcompare.co.uk/admin`, one account, no registration. If you
|
||||||
|
|||||||
+3
-13
@@ -26,21 +26,11 @@ export const FLAGS_REVALIDATE = 300;
|
|||||||
const API = process.env.FASTAPI_URL || process.env.NEXT_PUBLIC_API_URL
|
const API = process.env.FASTAPI_URL || process.env.NEXT_PUBLIC_API_URL
|
||||||
|| 'http://localhost:8000/api';
|
|| 'http://localhost:8000/api';
|
||||||
|
|
||||||
/**
|
/** Every flag and its value. Never throws: an unreadable flag is a dark one. */
|
||||||
* Every flag and its value. Never throws: an unreadable flag is a dark one.
|
export async function getFlags(): Promise<Flags> {
|
||||||
*
|
|
||||||
* `revalidate` is the caller's, because reading flags pins the whole route to
|
|
||||||
* the lowest revalidate among its fetches. Every SEO route here declares
|
|
||||||
* 604800; gating one at the 300s default would drop it from a weekly cache to
|
|
||||||
* a 5-minute one. Pass the route's own floor and gating costs it nothing.
|
|
||||||
*
|
|
||||||
* The trade is flag-flip latency: a route that revalidates weekly takes up to
|
|
||||||
* a week to notice a flip. Pass a smaller number where a flip must land fast.
|
|
||||||
*/
|
|
||||||
export async function getFlags(revalidate: number = FLAGS_REVALIDATE): Promise<Flags> {
|
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`${API}/flags`, {
|
const res = await fetch(`${API}/flags`, {
|
||||||
next: { revalidate },
|
next: { revalidate: FLAGS_REVALIDATE },
|
||||||
});
|
});
|
||||||
if (!res.ok) return {};
|
if (!res.ok) return {};
|
||||||
return await res.json();
|
return await res.json();
|
||||||
|
|||||||
@@ -44,27 +44,15 @@ interface PostSummary {
|
|||||||
* References the Person and Organization by @id rather than repeating them, so
|
* 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
|
* search engines resolve every post and the About page to the one author
|
||||||
* entity. Repeating the shape would declare several people with one name.
|
* 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(
|
export function blogPostingJsonLd(post: PostSummary) {
|
||||||
post: PostSummary,
|
|
||||||
{ namedAuthor }: { namedAuthor: boolean },
|
|
||||||
) {
|
|
||||||
return {
|
return {
|
||||||
'@type': 'BlogPosting',
|
'@type': 'BlogPosting',
|
||||||
headline: post.title,
|
headline: post.title,
|
||||||
description: post.excerpt,
|
description: post.excerpt,
|
||||||
url: absoluteUrl(`/blog/${post.slug}`),
|
url: absoluteUrl(`/blog/${post.slug}`),
|
||||||
datePublished: post.publishedAt,
|
datePublished: post.publishedAt,
|
||||||
author: {
|
author: { '@id': `${SITE_URL}/about#tudor` },
|
||||||
'@id': namedAuthor ? `${SITE_URL}/about#tudor` : `${SITE_URL}#organization`,
|
|
||||||
},
|
|
||||||
publisher: { '@id': `${SITE_URL}#organization` },
|
publisher: { '@id': `${SITE_URL}#organization` },
|
||||||
} as const;
|
} as const;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in new issue
Block a user