#!/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(/<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. await page.waitForSelector('#root > *', { timeout: 30_000 }).catch(() => {}); 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'); }