// Drives the SSR bundle (built by `vite build --ssr`) to bake one static HTML // file per route into dist/. Reads the client build's dist/index.html as the // template, then for each route injects: route /description/og tags, a // schema.org JSON-LD block, the server-rendered markup into #root, and the // dehydrated react-query cache as window.__RQ_STATE__ for hydration. // // nginx serves these via `try_files $uri $uri/ /index.html`: /cv → cv/index.html, // /blog/<slug> → blog/<slug>/index.html, etc., with unbaked routes (e.g. // /activity/:timespan) SPA-falling-back to the home page and rendering client-side. import { mkdir, readFile, writeFile } from 'node:fs/promises'; import { dirname, join } from 'node:path'; import { fileURLToPath, pathToFileURL } from 'node:url'; const here = dirname(fileURLToPath(import.meta.url)); const distDir = join(here, 'dist'); const SITE_URL = 'https://rob.tn'; const { collectRoutes, renderRoute } = await import( pathToFileURL(join(here, 'dist-server', 'entry-server.js')).href ); const escapeText = (s) => s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>'); const escapeAttr = (s) => escapeText(s).replace(/"/g, '"'); // JS line/paragraph separators are valid in JSON strings but illegal in a // <script>; written here as escape sequences so the source file stays ASCII. const JS_LINE_SEPARATORS = new RegExp('[\u2028\u2029]', 'g'); /** Safe for embedding JSON inside an inline <script>: neutralise `<` (so a * `</script>` in the data can't close the tag) and the line separators. */ const serialize = (value) => JSON.stringify(value) .replace(/</g, '\\u003c') .replace(JS_LINE_SEPARATORS, (c) => '\\u' + c.charCodeAt(0).toString(16)); function replaceOrAppendHead(html, regex, tag) { return regex.test(html) ? html.replace(regex, tag) : html.replace('</head>', ` ${tag}\n </head>`); } function applyHead(html, head, url) { let out = html.replace( /<title>[\s\S]*?<\/title>/, `<title>${escapeText(head.title)}`, ); const desc = escapeAttr(head.description); out = replaceOrAppendHead( out, /]*>/i, ``, ); out = replaceOrAppendHead( out, /]*>/i, ``, ); out = replaceOrAppendHead( out, /]*>/i, ``, ); out = replaceOrAppendHead( out, /]*>/i, ``, ); const ld = ``; return out.replace('', ` ${ld}\n `); } function outPathFor(route) { if (route === '/') return join(distDir, 'index.html'); return join(distDir, route.replace(/^\//, ''), 'index.html'); } const template = await readFile(join(distDir, 'index.html'), 'utf8'); let routes = await collectRoutes(); // Optional filter args: `node run-prerender.mjs /activity /cv` bakes only the // routes whose path starts with one of the given prefixes (useful for quick // iteration). With no args, every route is baked. const filters = process.argv.slice(2); if (filters.length > 0) { routes = routes.filter((r) => filters.some((f) => r === f || r.startsWith(f))); } let ok = 0; let failed = 0; for (const route of routes) { const url = route === '/' ? `${SITE_URL}/` : `${SITE_URL}${route}`; try { const { html, state, head } = await renderRoute(route); let page = applyHead(template, head, url); page = page.replace( '
', `
${html}
\n `, ); const outPath = outPathFor(route); await mkdir(dirname(outPath), { recursive: true }); await writeFile(outPath, page); ok += 1; console.log(`prerendered ${route}`); } catch (err) { // Don't fail the whole build for one route — write the bare template so the // route still resolves and hydrates client-side, and surface the error. failed += 1; console.error(`prerender FAILED for ${route}: ${err?.stack || err}`); const outPath = outPathFor(route); await mkdir(dirname(outPath), { recursive: true }); await writeFile(outPath, template); } } console.log(`prerender complete: ${ok} ok, ${failed} failed, ${routes.length} routes`); // A handful of failed routes still ship a working client-rendered fallback, so // don't block the deploy for those. Only fail the build if nothing prerendered // at all (e.g. the API was unreachable), which signals a genuinely broken build. if (ok === 0) { console.error('prerender produced no pages — failing the build'); process.exitCode = 1; }