#!/usr/bin/env node /** * prerender-seo.mjs — Generates route-specific HTML for SEO-important pages. * * How it works: * 1. Starts a Vite dev server in SSR mode (no HTTP server, just the transform pipeline) * 2. Loads src/entry-server.tsx through Vite — handles TSX, CSS, SVG, path aliases * 3. For each route with seoMeta in seoRoutes.ts: * a. Renders the React component to HTML via renderToString * b. Replaces , <meta>, OG/Twitter tags, canonical URL from seoMeta * c. Injects the rendered HTML into #root-seo * 4. Writes to dist/{route}/index.html * * nginx's try_files ($uri/) serves these automatically. * * Run after `vite build`: node scripts/prerender-seo.mjs */ import { readFileSync, writeFileSync, mkdirSync, existsSync } from 'fs'; import { dirname, join } from 'path'; import { fileURLToPath } from 'url'; import { createServer } from 'vite'; const __dirname = dirname(fileURLToPath(import.meta.url)); const distDir = join(__dirname, '..', 'dist'); // Check that dist exists (vite build must have run first) if (!existsSync(join(distDir, 'index.html'))) { console.error('❌ dist/index.html not found. Run `vite build` first.'); process.exit(1); } const baseHtml = readFileSync(join(distDir, 'index.html'), 'utf-8'); const DOMAIN = 'https://velxio.dev'; // nginx serves each prerendered route as `<route>/index.html` and // 301-redirects the slash-less URL to add the trailing slash. Canonical + // og:url must therefore use the slash form (== the served URL == the sitemap // entry), or Google sees a canonical that points to a redirecting URL. const withSlash = (u) => (u.endsWith('/') ? u : `${u}/`); /** * Swap the whole static #root-seo block (the hand-written homepage fallback * in index.html) for this page's SSR body. Located by string search, not by * a regex anchored on a following <script>: Vite hoists the module script * into <head>, so in the BUILT index.html the block is followed by </body> * — the old regex never matched and every prerendered page shipped the * homepage's fallback body (same <h1>, same nav) under its own <head>. */ function replaceRootSeo(html, seoBody) { const start = html.indexOf('<div id="root-seo"'); if (start === -1) { // No fallback block in this template: add ours before </body>. return html.replace('</body>', `<div id="root-seo" aria-hidden="true">${seoBody}</div>\n </body>`); } const bodyEnd = html.indexOf('</body>', start); const end = html.lastIndexOf('</div>', bodyEnd === -1 ? html.length : bodyEnd); if (end === -1 || end < start) return html; return ( html.slice(0, start) + `<div id="root-seo" aria-hidden="true">${seoBody}</div>` + html.slice(end + '</div>'.length) ); } // ── Mock browser globals for SSR ──────────────────────────────────────────── // Zustand's persist middleware and some components access these at import time. if (typeof globalThis.localStorage === 'undefined') { globalThis.localStorage = { getItem: () => null, setItem: () => {}, removeItem: () => {}, clear: () => {}, length: 0, key: () => null, }; } if (typeof globalThis.window === 'undefined') { globalThis.window = globalThis; } if (typeof globalThis.navigator === 'undefined') { globalThis.navigator = { userAgent: 'prerender-seo' }; } if (typeof globalThis.matchMedia === 'undefined') { globalThis.matchMedia = () => ({ matches: false, addListener: () => {}, removeListener: () => {} }); } // ── Start Vite in SSR mode ────────────────────────────────────────────────── console.log('🔧 Starting Vite SSR transform pipeline...'); const vite = await createServer({ server: { middlewareMode: true }, appType: 'custom', logLevel: 'warn', }); let generated = 0; try { // Load entry-server.tsx through Vite's transform pipeline const { getPrerenderedRoutes, render, getPrerenderedExampleRoutes, renderExample, loadRouteComponents } = await vite.ssrLoadModule('/src/entry-server.tsx'); // Pull in the overlay's marketing pages (pro builds) before asking for // the route list — without this the prerender would silently cover only // the OSS surface. await loadRouteComponents(); const routes = getPrerenderedRoutes(); const exampleRoutes = getPrerenderedExampleRoutes(); // hreflang for the localized (marketing/static) pages: every locale // variant of the route plus x-default on the locale-less English URL. // The SPA serves the same page translated under /<locale>/..., so each // variant is a real page (see useSEO.ts, which emits the same set on the // client, and the prod app shell, which emits it for the /<locale>/ // variants nginx has no static file for). const { LOCALES, LOCALE_META, DEFAULT_LOCALE } = await vite.ssrLoadModule('/src/i18n/config.ts'); const localizedPath = (path, locale) => locale === DEFAULT_LOCALE ? path : `/${locale}${path === '/' ? '' : path}`; const hreflangTags = (routePath) => { const variant = (l) => withSlash(`${DOMAIN}${localizedPath(routePath, l)}`); return [ ...LOCALES.map((l) => `<link rel="alternate" hreflang="${LOCALE_META[l].htmlLang}" href="${variant(l)}" />`), `<link rel="alternate" hreflang="x-default" href="${variant(DEFAULT_LOCALE)}" />`, ].join('\n '); }; const withHreflang = (html, routePath) => html.replace('</head>', ` ${hreflangTags(routePath)}\n </head>`); // Route metadata for the prod app shell (title/description per SEO route), // so /<locale>/<route> paths that fall through to it get the route's own // head instead of the homepage's. // Starter examples ("New <board> project" pages) for the prod app shell. const starters = await vite.ssrLoadModule('/src/data/starters.ts'); writeFileSync( join(distDir, 'starters.json'), JSON.stringify( starters.STARTER_EXAMPLES.map((st) => ({ id: st.id, board: st.board, title: starters.starterTitle(st.board), description: starters.starterDescription(st.board), })), ), 'utf-8', ); const { SEO_ROUTES } = await vite.ssrLoadModule('/src/seoRoutes.ts'); writeFileSync( join(distDir, 'seo-routes.json'), JSON.stringify( SEO_ROUTES.filter((r) => r.seoMeta && !r.noindex).map((r) => ({ path: r.path, title: r.seoMeta.title, description: r.seoMeta.description, })), ), 'utf-8', ); console.log(`📄 Prerendering ${routes.length} SEO pages + ${exampleRoutes.length} example pages...\n`); for (const route of routes) { const { seoMeta } = route; if (!seoMeta) continue; // Render the React component to HTML let bodyHtml = render(route.path); // Build the page HTML let html = baseHtml; // Replace <title> html = html.replace(/<title>[^<]*<\/title>/, `<title>${seoMeta.title}`); // Replace meta description html = html.replace( /`; if (html.includes(']*\/>/, canonicalTag); } else { html = html.replace('', ` ${canonicalTag}\n `); } // hreflang only for a page that is its own canonical; a page pointing // its canonical elsewhere (/v2 -> /) must not declare alternates. if (withSlash(seoMeta.url) === withSlash(`${DOMAIN}${route.path}`)) { html = withHreflang(html, route.path); } // Replace OG tags html = html.replace( /${seoMeta.title.split(' | ')[0]}

${seoMeta.description}

`; html = replaceRootSeo(html, seoBody); // Write to dist/{path}/index.html const routePath = route.path === '/' ? '' : route.path.slice(1); if (routePath) { const dir = join(distDir, routePath); if (!existsSync(dir)) mkdirSync(dir, { recursive: true }); writeFileSync(join(dir, 'index.html'), html, 'utf-8'); } else { // Root — update dist/index.html in place writeFileSync(join(distDir, 'index.html'), html, 'utf-8'); } generated++; const ssrStatus = bodyHtml ? '✓' : '⚠ (meta only)'; console.log(` ${ssrStatus} ${route.path}`); } // ── Prerender example detail pages ────────────────────────────────────────── console.log('\n📦 Prerendering example pages...\n'); let examplesGenerated = 0; for (const exRoute of exampleRoutes) { const exampleId = exRoute.path.split('/').pop(); let bodyHtml = renderExample(exampleId); let html = baseHtml; html = html.replace(/[^<]*<\/title>/, `<title>${exRoute.title}`); html = html.replace( /`; if (html.includes(']*\/>/, canonicalTag); } else { html = html.replace('', ` ${canonicalTag}\n `); } html = html.replace(/${exRoute.title.split(' — ')[0]}

${exRoute.description}

`; html = replaceRootSeo(html, seoBody); const dir = join(distDir, 'examples', exampleId); if (!existsSync(dir)) mkdirSync(dir, { recursive: true }); writeFileSync(join(dir, 'index.html'), html, 'utf-8'); examplesGenerated++; const ssrStatus = bodyHtml ? '✓' : '⚠ (meta only)'; if (examplesGenerated <= 5 || examplesGenerated % 20 === 0) { console.log(` ${ssrStatus} /examples/${exampleId}`); } } if (exampleRoutes.length > 5) { console.log(` ... (${examplesGenerated} total)`); } generated += examplesGenerated; // Also ensure root index.html has a canonical tag let rootHtml = readFileSync(join(distDir, 'index.html'), 'utf-8'); const rootCanonical = ``; if (!rootHtml.includes('', ` ${rootCanonical}\n `); writeFileSync(join(distDir, 'index.html'), rootHtml, 'utf-8'); } } finally { await vite.close(); } console.log(`\n✅ Prerendered ${generated} SEO pages with SSR content`);