Files
moments/ui/run-prerender.mjs
rob thijssen 1b753f991f
Some checks failed
deploy / Build api + worker + web (push) Failing after 53s
deploy / Deploy moments-api to nikola (push) Has been skipped
deploy / Deploy moments-worker to frootmig (push) Has been skipped
deploy / Deploy web to oolon (push) Has been skipped
feat: prerender every route + Gitea Actions deploy
Make the site fully prerendered so a plain curl returns complete content
for every route (crawlers / AI screening tools see real text, not an empty
#root), while humans keep full client interactivity.

Prerender:
- Build-time per-route render: prefetch data, renderToString, inline the
  dehydrated react-query cache as window.__RQ_STATE__; client hydrateRoots
  and refetches live (activity stays fresh; crawlers get the baked snapshot).
- New entry-server.tsx + prerender/{prefetch,routes,meta}.ts + run-prerender.mjs;
  shared lib/ranges.ts keeps SSR and client query keys identical.
- pnpm build now: tsc -b -> vite client build -> ssr build -> prerender.
- API base absolute at build (VITE_API_BASE), relative /api/v1 in the browser.
- CSS imports moved to the client entry so the tree imports under Node.
- schema.org Person + Occupation JSON-LD and per-route title/description/og.
- UTC + explicit field widths on shared date formatting so SSR and client
  hydration match byte-for-byte (fixes hydration mismatch on /activity).
- Strip non-text gist content from the CV fetch (1MB -> 25KB gzipped page).

Deploy (Gitea Actions, replaces script/deploy.sh):
- deploy.yml: on push to main, lint/test gate, build api+worker as static
  musl binaries (pure-rustls, no glibc skew) + prerendered web, deploy each
  over SSH as gitea_ci with scoped sudo.
- refresh.yml: daily cron re-bakes only the web snapshot so gist/activity
  edits propagate without a push or bouncing the api/worker.
- script/infra-setup.sh + asset/sudoers.d/{api,worker,web}-host.conf for
  one-time per-host provisioning. Secrets: RSYNC_SSH_KEY, QUERY_GITHUB_TOKEN,
  QUERY_GITEA_TOKEN.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01X7zF7Kf4JqDwa6M8Qgge9M
2026-06-25 12:53:46 +03:00

128 lines
4.8 KiB
JavaScript

// 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 <title>/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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
const escapeAttr = (s) => escapeText(s).replace(/"/g, '&quot;');
// 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)}</title>`,
);
const desc = escapeAttr(head.description);
out = replaceOrAppendHead(
out,
/<meta\s+name="description"[^>]*>/i,
`<meta name="description" content="${desc}" />`,
);
out = replaceOrAppendHead(
out,
/<meta\s+property="og:title"[^>]*>/i,
`<meta property="og:title" content="${escapeAttr(head.title)}" />`,
);
out = replaceOrAppendHead(
out,
/<meta\s+property="og:description"[^>]*>/i,
`<meta property="og:description" content="${desc}" />`,
);
out = replaceOrAppendHead(
out,
/<meta\s+property="og:url"[^>]*>/i,
`<meta property="og:url" content="${escapeAttr(url)}" />`,
);
const ld = `<script type="application/ld+json">${serialize(head.jsonLd)}</script>`;
return out.replace('</head>', ` ${ld}\n </head>`);
}
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(
'<div id="root"></div>',
`<div id="root">${html}</div>\n <script>window.__RQ_STATE__=${serialize(state)}</script>`,
);
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;
}