#!/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
, , 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 `/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}/`);
// ── 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();
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
html = html.replace(/[^<]*<\/title>/, `${seoMeta.title}`);
// Replace meta description
html = html.replace(
/`;
if (html.includes(']*\/>/, canonicalTag);
} else {
html = html.replace('', ` ${canonicalTag}\n `);
}
// Replace OG tags
html = html.replace(
/${seoMeta.title.split(' | ')[0]}