#!/usr/bin/env node
/**
* Bakes a real `
` 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//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 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>/i),
description: attr(/]+name=["']description["'][^>]*content=["']([^"']*)["']/i),
siteName: attr(/]+property=["']og:site_name["'][^>]*content=["']([^"']*)["']/i) || 'PIG Demo',
image: attr(/]+property=["']og:image["'][^>]*content=["']([^"']*)["']/i) || `${SITE_ORIGIN}/og/home.png`,
};
if (!BASE.title) die(`${rel(INDEX)} has no . 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');
}