// Prerenders every route in PRERENDER_ROUTES to static HTML under dist/, so crawlers // receive full markup with per-route head tags without executing JS. // Run AFTER `vite build` (see the "build" script in package.json): npx tsx scripts/prerender.mts // // How it works: a Vite dev server in middleware mode gives us ssrLoadModule — TSX, // the '@/' alias, and CSS imports all resolve exactly as in the app build, so no // separate SSR bundle is needed. The built dist/index.html is the template: its // default /description/og:/twitter: fallback tags are stripped (the per-route // tags from renderHeadTags() would otherwise duplicate them), the route's head tags // are injected before </head>, and the rendered app HTML is placed inside #root. // // NOTE: the client does NOT hydrate — main.tsx keeps createRoot().render(), which // replaces the prerendered DOM on load. That is intentional (no mismatch risk). import fs from 'node:fs'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; import { createServer } from 'vite'; import type { RouteMeta } from '../src/seo/routes-meta'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); const webRoot = path.resolve(__dirname, '..'); const distDir = path.join(webRoot, 'dist'); const templatePath = path.join(distDir, 'index.html'); function fail(msg: string): never { console.error(`prerender: FAILED — ${msg}`); process.exit(1); } if (!fs.existsSync(templatePath)) { fail(`${templatePath} not found — run \`vite build\` first`); } const rawTemplate = fs.readFileSync(templatePath, 'utf8'); // Strip the template's default SEO fallback tags (title, meta description, og:*, // twitter:*). renderHeadTags() emits the per-route versions of all of them; leaving // the defaults in would give crawlers duplicate/conflicting tags. [^>]* also matches // newlines, covering the multi-line <meta> formatting in index.html. const template = rawTemplate .replace(/[ \t]*<title>[\s\S]*?<\/title>\s*\n?/i, '') .replace(/[ \t]*<meta[^>]*name="description"[^>]*>\s*\n?/gi, '') .replace(/[ \t]*<meta[^>]*property="og:[^"]*"[^>]*>\s*\n?/gi, '') .replace(/[ \t]*<meta[^>]*name="twitter:[^"]*"[^>]*>\s*\n?/gi, ''); if (/<title>|property="og:|name="twitter:/i.test(template)) { fail('template still contains default <title>/og:/twitter: tags after stripping — index.html format changed?'); } if (!template.includes('<div id="root"></div>')) { fail('template is missing `<div id="root"></div>` — cannot inject app HTML'); } // Middleware mode + appType 'custom' = no HTTP server, no HTML middlewares — we only // want ssrLoadModule. Root is apps/web so vite.config.ts (alias, envDir) applies. const vite = await createServer({ root: webRoot, logLevel: 'error', server: { middlewareMode: true }, appType: 'custom', }); let exitCode = 0; try { const { render } = (await vite.ssrLoadModule('/src/entry-server.tsx')) as { render: (url: string) => string; }; const { PRERENDER_ROUTES, metaForPath, renderHeadTags } = (await vite.ssrLoadModule( '/src/seo/routes-meta.ts', )) as { PRERENDER_ROUTES: string[]; metaForPath: (p: string) => RouteMeta | undefined; renderHeadTags: (m: RouteMeta) => string; }; if (PRERENDER_ROUTES.length === 0) fail('PRERENDER_ROUTES is empty'); for (const route of PRERENDER_ROUTES) { try { const meta = metaForPath(route); if (!meta) throw new Error(`no meta registered for ${route}`); const appHtml = render(route); if (!appHtml.trim()) throw new Error('rendered app HTML is empty'); const doc = template .replace('</head>', ` ${renderHeadTags(meta)}\n </head>`) .replace('<div id="root"></div>', `<div id="root">${appHtml}</div>`); const outFile = route === '/' ? templatePath : path.join(distDir, ...route.slice(1).split('/'), 'index.html'); fs.mkdirSync(path.dirname(outFile), { recursive: true }); fs.writeFileSync(outFile, doc, 'utf8'); console.log(`prerender: ok ${route} -> ${path.relative(webRoot, outFile)}`); } catch (err) { exitCode = 1; console.error(`prerender: ERROR rendering ${route}:`, err); } } } finally { await vite.close(); } if (exitCode !== 0) fail('one or more routes failed (see errors above)'); console.log('prerender: all routes rendered');