/** * Build-time prerender: renders the public routes to static HTML inside dist/. * * Why: the app is a client-rendered SPA, so Vite's dist/index.html ships an * empty
. Google can render JS, but most AI answer engines * (GPTBot, ClaudeBot, PerplexityBot, Applebot-Extended) do not — they read raw * HTML. Prerendering puts real

/

//JSON-LD content into the file each * URL serves, which is what makes long-tail pages quotable. * * Route list is read from dist/sitemap.xml, which `npm run generate:sitemap` * already wrote before the Vite build — one source of truth, no TS imports. * * The pristine shell is held in memory and reused for every route, so writing * one route's output can never corrupt another's input. * * Failure policy: if Chromium cannot be launched the script warns loudly and * exits 0 — an un-prerendered page still works, it just loses non-JS crawlers. * * npm run prerender */ import { spawnSync } from 'node:child_process'; import { createServer } from 'node:http'; import { createReadStream, existsSync, mkdirSync, readdirSync, readFileSync, statSync, writeFileSync, } from 'node:fs'; import { dirname, extname, join, normalize, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; const __dirname = dirname(fileURLToPath(import.meta.url)); const ROOT = resolve(__dirname, '..'); const DIST = join(ROOT, 'dist'); const SHELL_PATH = join(DIST, 'index.html'); const MIME = { '.html': 'text/html; charset=utf-8', '.js': 'text/javascript; charset=utf-8', '.mjs': 'text/javascript; charset=utf-8', '.css': 'text/css; charset=utf-8', '.json': 'application/json; charset=utf-8', '.svg': 'image/svg+xml', '.png': 'image/png', '.jpg': 'image/jpeg', '.webp': 'image/webp', '.ico': 'image/x-icon', '.txt': 'text/plain; charset=utf-8', '.xml': 'application/xml; charset=utf-8', '.woff2': 'font/woff2', }; const warn = (m) => console.warn(`[prerender] ${m}`); const failSoft = (m) => { warn(m); warn('SKIPPING prerender — the build still succeeds, but non-JS crawlers see an empty body.'); process.exit(0); }; function findChromium() { if (process.env.PRERENDER_CHROME && existsSync(process.env.PRERENDER_CHROME)) { return process.env.PRERENDER_CHROME; } const roots = [ process.env.PLAYWRIGHT_BROWSERS_PATH, join(process.env.HOME ?? '', '.cache/ms-playwright'), '/ms-playwright', ].filter(Boolean); const relatives = [ ['chrome-linux64', 'chrome'], ['chrome-linux', 'chrome'], ['chrome-mac', 'Chromium.app', 'Contents', 'MacOS', 'Chromium'], ['chrome-win', 'chrome.exe'], ]; // Distro / CI installs, where Playwright never downloaded a browser. const systemBins = [ process.env.CHROME_PATH, '/usr/bin/google-chrome', '/usr/bin/google-chrome-stable', '/usr/bin/chromium', '/usr/bin/chromium-browser', '/opt/google/chrome/chrome', '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome', ].filter(Boolean); for (const bin of systemBins) { if (existsSync(bin)) return bin; } for (const root of roots) { if (!existsSync(root)) continue; let dirs = []; try { dirs = readdirSync(root); } catch { continue; } const versions = dirs.filter((d) => d.startsWith('chromium-')).sort().reverse(); for (const dir of versions) { for (const rel of relatives) { const candidate = join(root, dir, ...rel); if (existsSync(candidate)) return candidate; } } } return null; } function routesFromSitemap() { const sitemapPath = existsSync(join(DIST, 'sitemap.xml')) ? join(DIST, 'sitemap.xml') : join(ROOT, 'public/sitemap.xml'); if (!existsSync(sitemapPath)) { failSoft('no sitemap.xml found — run `npm run generate:sitemap` first'); } const xml = readFileSync(sitemapPath, 'utf8'); const routes = [...xml.matchAll(/([^<]+)<\/loc>/g)] .map((m) => m[1].trim()) .filter((loc) => loc.startsWith('https://getcert.eu.cc')) .map((loc) => { const path = loc.replace('https://getcert.eu.cc', ''); return path.length > 1 && path.endsWith('/') ? path.slice(0, -1) : path; }) .filter((p) => p.startsWith('/')); if (!routes.length) failSoft('sitemap.xml contained no URLs'); return [...new Set(routes)]; } const outputPathFor = (route) => route === '/' ? SHELL_PATH : join(DIST, route.replace(/^\//, ''), 'index.html'); async function render(browser, base, route) { const page = await browser.newPage({ viewport: { width: 1440, height: 1000 }, deviceScaleFactor: 1, userAgent: 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36', }); // Suppress UI chrome (consent banner) so generated HTML is content only. await page.addInitScript(() => { window.__GETCERT_PRERENDER__ = true; }); try { await page.goto(`${base}${route}`, { waitUntil: 'load', timeout: 45000 }); // The route tree mounts after auth init, so allow a couple of attempts for // the

to appear before calling it a failure. let textLength = 0; let title = ''; let lastError = ''; for (let attempt = 0; attempt < 3; attempt++) { await page.waitForSelector('h1', { timeout: 15000 }).catch(() => {}); await page.waitForTimeout(1500); await page .evaluate(async () => { try { await document.fonts?.ready; } catch { /* ignore */ } }) .catch(() => {}); textLength = await page.evaluate(() => (document.body?.innerText ?? '').trim().length); title = await page.title(); if (await page.$('h1')) break; lastError = 'no

rendered'; } if (!(await page.$('h1'))) throw new Error(lastError || 'no

rendered'); if (textLength < 200) throw new Error(`body text too short (${textLength} chars)`); if (!title || title === 'GetCert') throw new Error(`title not route-specific: "${title}"`); // The