diff --git a/.github/workflows/demo-deploys.yml b/.github/workflows/demo-deploys.yml index c3c3c46..a749cfd 100644 --- a/.github/workflows/demo-deploys.yml +++ b/.github/workflows/demo-deploys.yml @@ -84,6 +84,11 @@ jobs: - name: Build run: npm run build + - name: Prerender routes (server-render body + head + JSON-LD per route) + run: | + npx playwright install --with-deps chromium + npm run prerender + - name: Determine Pages project name id: project run: | diff --git a/package.json b/package.json index 350a524..da891f4 100644 --- a/package.json +++ b/package.json @@ -32,7 +32,8 @@ "perf-budget": "node scripts/perf-budget.mjs", "hue-rotate": "node scripts/hue-rotate.mjs", "validate:all": "npm run validate:brand && npm run typecheck && npm run test && npm run prompt-evals && npm run build && npm run perf-budget", - "validate": "node scripts/build_validators.mjs dist" + "validate": "node scripts/build_validators.mjs dist", + "prerender": "node scripts/prerender-spa.mjs" }, "dependencies": { "@radix-ui/react-accordion": "^1.2.0", diff --git a/scripts/deploy-applied.mjs b/scripts/deploy-applied.mjs index d35ff82..b4e03c9 100644 --- a/scripts/deploy-applied.mjs +++ b/scripts/deploy-applied.mjs @@ -143,6 +143,8 @@ try { console.log(`→ Deploy to ${project}.pages.dev`); try { + console.log('→ Prerender routes (server-render body + head + JSON-LD per route)'); + execSync('node scripts/prerender-spa.mjs', { cwd: repoRoot, stdio: 'inherit' }); execSync(`npx wrangler pages deploy dist --project-name=${project} --branch=main --commit-dirty=true`, { cwd: repoRoot, stdio: 'inherit', diff --git a/scripts/deploy-template.mjs b/scripts/deploy-template.mjs index 2911aac..6269dac 100644 --- a/scripts/deploy-template.mjs +++ b/scripts/deploy-template.mjs @@ -80,6 +80,8 @@ try { syncSecretsToPages(PROJECT, COMMON_SECRETS); console.log(`→ Deploy`); + console.log('→ Prerender routes (server-render body + head + JSON-LD per route)'); + execSync('node scripts/prerender-spa.mjs', { cwd: repoRoot, stdio: 'inherit' }); execSync(`npx wrangler pages deploy dist --project-name=${PROJECT} --branch=main --commit-dirty=true`, { cwd: repoRoot, stdio: 'inherit', env: process.env, }); diff --git a/scripts/prerender-spa.mjs b/scripts/prerender-spa.mjs new file mode 100644 index 0000000..522d56b --- /dev/null +++ b/scripts/prerender-spa.mjs @@ -0,0 +1,51 @@ +#!/usr/bin/env node +// prerender-spa.mjs — browser-prerender (react-snap pattern). Runs the BUILT SPA in headless +// Chromium per route and saves the fully-rendered HTML → dist//index.html. Fixes the +// whole crawler-invisibility class at once (body content + per-route head + JSON-LD) with ZERO +// component/SSR-compat changes. Crawlers (no JS) get real content; users still hydrate the app. +import { createServer } from 'node:http'; +import { readFile, writeFile, mkdir } from 'node:fs/promises'; +import { existsSync, readFileSync } from 'node:fs'; +import { join, extname } from 'node:path'; +import { chromium } from 'playwright'; + +const DIST = 'dist'; +// Routes = every indexable URL from the sitemap (incl dynamic /blog/:slug, /case-studies/:slug), +// minus the home shell (left as-is). Falls back to the static set if no sitemap. +function discoverRoutes() { + const sm = join(DIST, 'sitemap.xml'); + if (existsSync(sm)) { + const locs = [...readFileSync(sm, 'utf8').matchAll(/([^<]+)<\/loc>/g)] + .map((m) => m[1].replace(/^(https?:\/\/[^/]+|\{[^}]+\})/, '').split('?')[0]) // strip scheme+host OR {PLACEHOLDER} + .filter((p) => p && p.startsWith('/') && p !== '/'); + if (locs.length) return [...new Set(locs)]; + } + return ['/about','/services','/contact','/pricing','/faq','/team','/gallery','/case-studies','/blog','/privacy','/terms','/accessibility']; +} +const ROUTES = discoverRoutes(); +const MIME = { '.html':'text/html', '.js':'text/javascript', '.css':'text/css', '.json':'application/json', '.svg':'image/svg+xml', '.png':'image/png', '.ico':'image/x-icon', '.webmanifest':'application/manifest+json', '.xml':'application/xml', '.txt':'text/plain', '.woff2':'font/woff2' }; + +// tiny static server with SPA fallback (so the app boots + client-routes) +const server = createServer(async (req, res) => { + let p = decodeURIComponent(req.url.split('?')[0]); + let f = join(DIST, p); + if (!existsSync(f) || !extname(f)) f = join(DIST, 'index.html'); // SPA fallback + try { const buf = await readFile(f); res.writeHead(200, { 'Content-Type': MIME[extname(f)] || 'application/octet-stream' }); res.end(buf); } + catch { res.writeHead(404); res.end('nf'); } +}); +await new Promise((r) => server.listen(0, r)); +const port = server.address().port; + +const browser = await chromium.launch(); +const page = await browser.newPage(); +let n = 0; +for (const route of ROUTES) { + await page.goto(`http://localhost:${port}${route}`, { waitUntil: 'networkidle', timeout: 20000 }); + await page.waitForFunction(() => { const r = document.getElementById('root'); return r && r.innerHTML.length > 500; }, { timeout: 10000 }).catch(() => {}); + const html = '\n' + await page.evaluate(() => document.documentElement.outerHTML); + await mkdir(join(DIST, route), { recursive: true }); + await writeFile(join(DIST, route, 'index.html'), html); + n++; +} +await browser.close(); server.close(); +console.log(`[prerender-spa] rendered ${n} routes to static HTML`);