#!/usr/bin/env node /** * Renders the social card for every route. * * One 1200x630 PNG per route, screenshotted from the real built page, written * to `public/og/`. Not generated from a template: a card drawn by a separate * renderer drifts from the page it advertises, and this way a change to the * board or the palette shows up in the card by construction. * * ── ORDERING ─────────────────────────────────────────────────────────────── * * This reads `dist/` and writes `public/`, which means the cards are always one * build behind until you build again: * * pnpm build && node scripts/og.mjs && pnpm build * * That is deliberate. The alternative — screenshotting the dev server — renders * unminified CSS and a different font-loading path, and the cards came out * subtly wrong in a way nobody noticed until they were on X. * * ── WHERE THIS RUNS ──────────────────────────────────────────────────────── * * amd-server (x86), where Playwright's chromium is installed. It is NOT part of * the deploy: the deploy host has no browser, and a deploy that silently * skipped card generation would ship a site whose every link previews as a * broken image. Run it here, commit the PNGs, deploy the PNGs. * * Env: * PIG_OG_SCALE device pixel ratio, default 2 (so the file is 2400x1260 for * a 1200x630 CSS-pixel card; every unfurler downsamples, and * 1x text on a retina timeline looks like a fax). */ import fs from 'node:fs'; import path from 'node:path'; import { abs, die, dim, exists, expandRoutes, green, loadAllMetas, loadVerticals, rel, serveStatic, table } from './_lib.mjs'; const WIDTH = 1200; const HEIGHT = 630; const SCALE = Number(process.env.PIG_OG_SCALE ?? 2); const DIST = abs('dist'); const OUT_DIR = abs('public', 'og'); if (!exists(path.join(DIST, 'index.html'))) { die(`${rel(DIST)}/index.html does not exist. Run \`pnpm build\` first — the cards are shot from the built site.`); } const { chromium } = await import('playwright').catch(() => { die('playwright is not installed. `pnpm install` first.'); }); const executable = chromium.executablePath(); if (!exists(executable)) { die( `Playwright's chromium is not installed at ${executable}.\n` + ' Run this on amd-server (x86), where it is installed, and commit the PNGs. ' + 'The deploy host has no browser, which is exactly why card generation is not part of the deploy.', ); } /* ------------------------------------------------------- routes -> filenames */ const { metas } = loadAllMetas(); 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 - ')}`); /** * A demo's filename comes from its own `meta.ogImage`, not from its slug. * * `check-demos` rule 4 asserts that file exists; if this script invented a * different name, the two would disagree and the check would fail on a card * that had just been generated. */ function outputName(route) { if (route.kind === 'home') return 'home.png'; if (route.kind === 'demo') { const declared = metas.get(route.slug)?.ogImage; return typeof declared === 'string' && declared.trim() !== '' ? path.basename(declared) : `${route.slug}.png`; } // Prefixed, because a vertical slug and a demo slug live in the same // directory and nothing stops them colliding. if (route.kind === 'vertical') return `vertical-${route.id}.png`; return `${route.path.replace(/^\/+/, '').replace(/\//g, '-') || 'home'}.png`; } const targets = routes.map((route) => ({ route, name: outputName(route) })); const byName = new Map(); for (const target of targets) { const clash = byName.get(target.name); if (clash) { die( `${target.route.path} and ${clash.path} would both write public/og/${target.name}. ` + 'Change one of their `meta.ogImage` values; a shared card means one of the two pages advertises the other.', ); } byName.set(target.name, target.route); } /* --------------------------------------------------------------- screenshot */ fs.mkdirSync(OUT_DIR, { recursive: true }); const server = await serveStatic(DIST); const browser = await chromium.launch(); const context = await browser.newContext({ viewport: { width: WIDTH, height: HEIGHT }, deviceScaleFactor: SCALE, colorScheme: 'light', // Every animation on the site is an entrance. Shooting mid-flight catches // elements at 40% opacity, which reads as a rendering bug in the card. reducedMotion: 'reduce', }); const rows = []; try { for (const { route, name } of targets) { const page = await context.newPage(); await page.goto(`${server.origin}${route.path}`, { waitUntil: 'load', timeout: 30_000 }); await page.waitForSelector('#root > *', { timeout: 30_000 }).catch(() => {}); await page.waitForLoadState('networkidle', { timeout: 30_000 }).catch(() => {}); await page.evaluate(() => document.fonts.ready.then(() => true)).catch(() => {}); const file = path.join(OUT_DIR, name); // `clip` rather than fullPage: the card is a 1200x630 window onto the top // of the page, and fullPage would hand the unfurler a 1200x9000 strip that // every platform crops to something arbitrary. await page.screenshot({ path: file, clip: { x: 0, y: 0, width: WIDTH, height: HEIGHT } }); await page.close(); rows.push([route.path, `public/og/${name}`, `${(fs.statSync(file).size / 1024).toFixed(0)} kB`]); } } finally { await context.close(); await browser.close(); await server.close(); } console.log(table(['route', 'card', 'size'], rows)); console.log(''); console.log(green(`og: wrote ${rows.length} cards at ${WIDTH}x${HEIGHT} CSS px (x${SCALE}).`)); console.log(dim('Rebuild before deploying, or dist/ still holds the previous cards.'));