6301a1d174
Two silent bugs in the prerender pass, and the second was caused by the fix for
the first.
`waitForSelector('#root > *')` defaults to waiting for VISIBILITY, and the app's
first child is the skip link, which is hidden until focused. So it burned the
full 30s timeout on every one of 17 routes — twelve minutes of a script that
printed nothing, because its output was buffered behind a pipe — while the page
had rendered the whole time. Switching to `state: 'attached'` then fired too
early instead: useSeo writes the head from an effect, so the title was still
index.html's for a tick, and every route would have baked the homepage's head.
That is the exact bug this script exists to prevent. It now waits for `main`,
then for readyState, then settles.
The board's yellow was --warning, 32 95% 31% — darkened until white text cleared
4.5:1, and at that lightness it renders BROWN. On a board where people arrive
knowing this square should be yellow, a brown square reads as a bug in the
scorer, which on a page arguing "the grader is correct" is the worst thing it
could look like. The fill is now a real yellow and the glyph went dark: more
expected AND higher contrast, 10.02:1 against 5.03:1.
Also measured something the Honesty page had honestly declined to claim. It said
our word list is easier than the original's because our dictionary rule keeps
plurals the original's editor removed by hand. Running the same greedy solver
over both pools, 250 sampled words each: original 2,315 needs 3.552 guesses
(opener RAISE, worst 5), ours 4,603 needs 3.700 (opener TARES, worst 6). The
doubled pool outweighs the plurals. Ours is harder, and the page now says so
with the table.
177 gate checks pass. Entry chunk 106.9 kB gzipped against 160 kB.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019mt6sHQHEnEYrJZvoMCJSB
314 lines
13 KiB
JavaScript
314 lines
13 KiB
JavaScript
#!/usr/bin/env node
|
|
/**
|
|
* Bakes a real `<head>` and real markup into one HTML file per route.
|
|
*
|
|
* A client-only SPA ships ONE document. Every route gets the homepage's title,
|
|
* the homepage's description and the homepage's og:image, because the tags the
|
|
* app sets live in JavaScript that a crawler or a link unfurler never runs. So
|
|
* every shared link previews as the homepage, and the per-route OG cards that
|
|
* `scripts/og.mjs` spent two minutes rendering are dead weight on disk.
|
|
*
|
|
* This fixes that the only way that actually works without a server: run the
|
|
* real app in a real browser against the built output, let it render and set
|
|
* its own head, then write what the browser ended up with to
|
|
* `dist/<route>/index.html`. Static hosting serves those directly; the SPA
|
|
* boots on top and takes over as normal.
|
|
*
|
|
* Three things it is careful about, each of which has broken this before:
|
|
*
|
|
* - The app is mounted with `createRoot`, not `hydrateRoot`, so React
|
|
* discards the baked markup and re-renders. That is FINE and deliberate:
|
|
* there is no hydration mismatch to worry about, and the markup exists for
|
|
* the crawler, not for the first paint.
|
|
* - next-themes stamps `data-theme` and `color-scheme` onto <html> from the
|
|
* viewer's OS preference. Capturing that bakes ONE viewer's theme into the
|
|
* file every other viewer downloads, so both are reset before writing.
|
|
* - A route that never calls `useSeo` silently keeps the homepage's head.
|
|
* That is the exact bug this script exists to fix, so it is a failure, not
|
|
* a warning.
|
|
*
|
|
* Routes come from the router source and the demo/vertical data, never from a
|
|
* list in here — see `expandRoutes` in `_lib.mjs`.
|
|
*
|
|
* Usage: `node scripts/prerender.mjs` (runs as part of `pnpm build`).
|
|
* Env: `PIG_DEMO_ORIGIN` overrides the public origin baked into canonical/og:url.
|
|
*/
|
|
|
|
import fs from 'node:fs';
|
|
import path from 'node:path';
|
|
|
|
import { SITE_ORIGIN, abs, die, exists, expandRoutes, green, dim, loadAllMetas, loadVerticals, read, rel, serveStatic } from './_lib.mjs';
|
|
|
|
const DIST = abs('dist');
|
|
const INDEX = path.join(DIST, 'index.html');
|
|
|
|
if (!exists(INDEX)) die(`${rel(INDEX)} does not exist. Run \`vite build\` before prerendering.`);
|
|
|
|
const { chromium } = await import('playwright').catch(() => {
|
|
die('playwright is not installed. `pnpm install` first; prerender needs a real browser to render the app.');
|
|
});
|
|
|
|
/* ------------------------------------------------------------- the routes */
|
|
|
|
const { metas, errors: metaErrors } = loadAllMetas();
|
|
for (const e of metaErrors) console.warn(`warn ${e.file}: ${e.error}`);
|
|
|
|
const verticals = loadVerticals();
|
|
if (verticals.error) die(`could not read the verticals: ${verticals.error}`);
|
|
|
|
const { routes, errors } = expandRoutes({ metas, verticals: verticals.list });
|
|
if (errors.length) die(`route enumeration failed:\n - ${errors.join('\n - ')}`);
|
|
|
|
const verticalById = new Map(verticals.list.map((v) => [v.id, v]));
|
|
|
|
/* --------------------------------------------- the homepage's own defaults */
|
|
|
|
const indexHtml = read(INDEX);
|
|
const attr = (re) => indexHtml.match(re)?.[1] ?? '';
|
|
const BASE = {
|
|
title: attr(/<title>([^<]*)<\/title>/i),
|
|
description: attr(/<meta[^>]+name=["']description["'][^>]*content=["']([^"']*)["']/i),
|
|
siteName: attr(/<meta[^>]+property=["']og:site_name["'][^>]*content=["']([^"']*)["']/i) || 'PIG Demo',
|
|
image: attr(/<meta[^>]+property=["']og:image["'][^>]*content=["']([^"']*)["']/i) || `${SITE_ORIGIN}/og/home.png`,
|
|
};
|
|
if (!BASE.title) die(`${rel(INDEX)} has no <title>. The baked head is built from it; there is nothing to start from.`);
|
|
|
|
const absoluteUrl = (value) => (/^https?:\/\//i.test(value) ? value : `${SITE_ORIGIN}${value.startsWith('/') ? '' : '/'}${value}`);
|
|
|
|
/** What this route's head SHOULD say, from the data, where we know. */
|
|
function expected(route) {
|
|
if (route.kind === 'demo') {
|
|
const meta = metas.get(route.slug);
|
|
if (!meta) return null;
|
|
return {
|
|
title: `${meta.title} — ${BASE.siteName}`,
|
|
description: meta.tagline,
|
|
image: meta.ogImage ?? BASE.image,
|
|
};
|
|
}
|
|
if (route.kind === 'vertical') {
|
|
const vertical = verticalById.get(route.id);
|
|
if (!vertical) return null;
|
|
return {
|
|
title: `${vertical.title} — ${BASE.siteName}`,
|
|
description: vertical.description ?? BASE.description,
|
|
image: BASE.image,
|
|
};
|
|
}
|
|
if (route.kind === 'home') return { title: BASE.title, description: BASE.description, image: BASE.image };
|
|
return null; // /gallery, /honesty — only the app knows what these say
|
|
}
|
|
|
|
/* ------------------------------------------------------------------ render */
|
|
|
|
const server = await serveStatic(DIST);
|
|
const browser = await chromium.launch();
|
|
const context = await browser.newContext({
|
|
viewport: { width: 1280, height: 900 },
|
|
// Bake the light theme. Capturing whatever the build machine's OS preferred
|
|
// would ship one viewer's palette to everyone until React re-renders.
|
|
colorScheme: 'light',
|
|
reducedMotion: 'reduce',
|
|
});
|
|
|
|
/** @type {{file: string, html: string}[]} */
|
|
const pending = [];
|
|
/** @type {string[]} */
|
|
const failures = [];
|
|
|
|
try {
|
|
for (const route of routes) {
|
|
const page = await context.newPage();
|
|
/** @type {string[]} */
|
|
const crashes = [];
|
|
page.on('pageerror', (error) => crashes.push(error.message));
|
|
|
|
const url = `${server.origin}${route.path}`;
|
|
await page.goto(url, { waitUntil: 'load', timeout: 30_000 });
|
|
// The router's lazy routes resolve a chunk before they render anything, so
|
|
// "load" is too early for every page but the eagerly-imported home.
|
|
// Two things are easy to get wrong here and both fail silently.
|
|
//
|
|
// Wait for `main`, not `#root > *`: the app's first child is the skip link,
|
|
// and `waitForSelector` defaults to waiting for VISIBILITY, so a
|
|
// visually-hidden first child burns the full timeout on every route while
|
|
// the page has in fact been rendered the whole time.
|
|
//
|
|
// Then settle. `useSeo` writes the head from an effect, so the title is
|
|
// still the one baked into index.html for a tick after the markup appears.
|
|
// Reading too early bakes the homepage's head into every route — the exact
|
|
// bug this script exists to prevent, reintroduced by the fix for the first.
|
|
await page.waitForSelector('#root main', { timeout: 30_000 }).catch(() => {});
|
|
await page
|
|
.waitForFunction(() => document.readyState === 'complete', null, { timeout: 10_000 })
|
|
.catch(() => {});
|
|
await page.waitForTimeout(250);
|
|
await page.waitForLoadState('networkidle', { timeout: 30_000 }).catch(() => {});
|
|
// `document.fonts.ready` resolves to a FontFaceSet, which does not survive
|
|
// serialisation back across the bridge; return a boolean instead.
|
|
await page.evaluate(() => document.fonts.ready.then(() => true)).catch(() => {});
|
|
|
|
const seen = await page.evaluate(() => ({
|
|
title: document.title,
|
|
description: document.querySelector('meta[name="description"]')?.getAttribute('content') ?? '',
|
|
canonical: document.querySelector('link[rel="canonical"]')?.getAttribute('href') ?? '',
|
|
image: document.querySelector('meta[property="og:image"]')?.getAttribute('content') ?? '',
|
|
bodyText: (document.querySelector('#root')?.textContent ?? '').trim().length,
|
|
lang: document.documentElement.lang,
|
|
}));
|
|
|
|
if (crashes.length) failures.push(`${route.path}: the page threw while rendering — ${crashes[0]}`);
|
|
if (seen.bodyText < 200) {
|
|
failures.push(
|
|
`${route.path}: #root rendered ${seen.bodyText} characters of text. A prerendered page with no content ` +
|
|
'is worse than none, because it caches an empty page at a real URL.',
|
|
);
|
|
}
|
|
|
|
const want = expected(route);
|
|
const appSetItsOwnHead = seen.title !== '' && (route.kind === 'home' || seen.title !== BASE.title);
|
|
const title = appSetItsOwnHead ? seen.title : want?.title;
|
|
const description = (appSetItsOwnHead && seen.description) || want?.description || BASE.description;
|
|
const image = (appSetItsOwnHead && seen.image) || want?.image || BASE.image;
|
|
|
|
if (!title) {
|
|
failures.push(
|
|
`${route.path}: still carries the homepage title "${BASE.title}" after rendering, and there is no ` +
|
|
'demo or vertical record to build one from. Call `useSeo(...)` in the page component — a route ' +
|
|
'that does not set its own head is the exact bug this script exists to fix.',
|
|
);
|
|
await page.close();
|
|
continue;
|
|
}
|
|
if (!appSetItsOwnHead) {
|
|
console.warn(
|
|
dim(`warn ${route.path}: the app left the homepage head in place; baking the value from the ${route.kind} record instead.`),
|
|
);
|
|
}
|
|
|
|
await page.evaluate(bakeHead, {
|
|
title,
|
|
description,
|
|
canonical: absoluteUrl(route.path),
|
|
image: absoluteUrl(image),
|
|
siteName: BASE.siteName,
|
|
robots: null,
|
|
});
|
|
|
|
pending.push({ file: outputFile(route.path), html: await page.content() });
|
|
await page.close();
|
|
}
|
|
|
|
/* ------------------------------------------------------------- 404.html */
|
|
|
|
const page = await context.newPage();
|
|
// A path no route can ever match, so the router's `*` branch renders.
|
|
await page.goto(`${server.origin}/__prerender-not-found__`, { waitUntil: 'load', timeout: 30_000 });
|
|
await page.waitForSelector('#root > *', { timeout: 30_000 }).catch(() => {});
|
|
await page.evaluate(bakeHead, {
|
|
title: `Page not found — ${BASE.siteName}`,
|
|
description: 'That page does not exist. Every demo on this site is a directory in the repository, so a missing one is usually a renamed slug.',
|
|
canonical: null,
|
|
image: absoluteUrl(BASE.image),
|
|
siteName: BASE.siteName,
|
|
// The 404 body is served at every wrong URL there will ever be. Indexing
|
|
// it means indexing an unbounded set of URLs that all say "not found".
|
|
robots: 'noindex, follow',
|
|
});
|
|
pending.push({ file: path.join(DIST, '404.html'), html: await page.content() });
|
|
await page.close();
|
|
} finally {
|
|
await context.close();
|
|
await browser.close();
|
|
await server.close();
|
|
}
|
|
|
|
if (failures.length) die(`prerender refused to write:\n - ${failures.join('\n - ')}`);
|
|
|
|
/* ------------------------------------------------------------------- write */
|
|
|
|
for (const { file, html } of pending) {
|
|
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
fs.writeFileSync(file, `${html}\n`, 'utf8');
|
|
}
|
|
|
|
console.log(green(`prerender: wrote ${pending.length} files under ${rel(DIST)} (origin ${SITE_ORIGIN}).`));
|
|
for (const { file } of pending) console.log(dim(` ${rel(file)}`));
|
|
|
|
/* ----------------------------------------------------------------- helpers */
|
|
|
|
function outputFile(route) {
|
|
if (route === '/') return path.join(DIST, 'index.html');
|
|
return path.join(DIST, route.replace(/^\/+/, ''), 'index.html');
|
|
}
|
|
|
|
/**
|
|
* Runs INSIDE the page. Rewrites the head, then undoes the theme stamping.
|
|
*
|
|
* Written as a single self-contained function because it is serialised to the
|
|
* browser — it cannot close over anything from this module.
|
|
*/
|
|
function bakeHead(values) {
|
|
const head = document.head;
|
|
|
|
const upsert = (selector, create, content) => {
|
|
let tag = head.querySelector(selector);
|
|
if (!tag) {
|
|
tag = create();
|
|
head.appendChild(tag);
|
|
}
|
|
tag.setAttribute(tag.tagName === 'LINK' ? 'href' : 'content', content);
|
|
return tag;
|
|
};
|
|
const meta = (name, content) =>
|
|
upsert(`meta[name="${name}"]`, () => {
|
|
const el = document.createElement('meta');
|
|
el.setAttribute('name', name);
|
|
return el;
|
|
}, content);
|
|
const property = (prop, content) =>
|
|
upsert(`meta[property="${prop}"]`, () => {
|
|
const el = document.createElement('meta');
|
|
el.setAttribute('property', prop);
|
|
return el;
|
|
}, content);
|
|
|
|
document.title = values.title;
|
|
meta('description', values.description);
|
|
|
|
property('og:type', 'website');
|
|
property('og:site_name', values.siteName);
|
|
property('og:title', values.title);
|
|
property('og:description', values.description);
|
|
property('og:image', values.image);
|
|
|
|
// summary_large_image is the only card type that shows a 1200x630 image.
|
|
// Without it X renders a thumbnail and the card reads as a link, not a page.
|
|
meta('twitter:card', 'summary_large_image');
|
|
meta('twitter:title', values.title);
|
|
meta('twitter:description', values.description);
|
|
meta('twitter:image', values.image);
|
|
|
|
if (values.canonical) {
|
|
upsert('link[rel="canonical"]', () => {
|
|
const el = document.createElement('link');
|
|
el.setAttribute('rel', 'canonical');
|
|
return el;
|
|
}, values.canonical);
|
|
property('og:url', values.canonical);
|
|
} else {
|
|
head.querySelector('link[rel="canonical"]')?.remove();
|
|
head.querySelector('meta[property="og:url"]')?.remove();
|
|
}
|
|
|
|
if (values.robots) meta('robots', values.robots);
|
|
else head.querySelector('meta[name="robots"]')?.remove();
|
|
|
|
// next-themes wrote these from the rendering machine's OS preference. Left
|
|
// in, every visitor downloads that machine's theme and sees it flash to
|
|
// their own once React boots.
|
|
document.documentElement.setAttribute('data-theme', 'light');
|
|
document.documentElement.style.removeProperty('color-scheme');
|
|
if (document.documentElement.getAttribute('style') === '') document.documentElement.removeAttribute('style');
|
|
}
|