From 5ec5b62b47cd924eb7f617984a0959b85577f37c Mon Sep 17 00:00:00 2001 From: Brian Zalewski Date: Sun, 21 Jun 2026 01:11:06 -0400 Subject: [PATCH 1/4] =?UTF-8?q?feat:=20browser-prerender=20(react-snap=20p?= =?UTF-8?q?attern)=20=E2=80=94=20full=20per-route=20SSR=20output,=20zero?= =?UTF-8?q?=20refactor?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The COMPLETE fix for the crawler-invisibility class. The app is a client SPA: crawlers read the static index.html shell, which has an EMPTY
(0 chars) → they see no body content, the homepage head on every route, and no JSON-LD (it's client-rendered). PR #2 fixed the head; this fixes EVERYTHING at once with no component/SSR-compat changes. scripts/prerender-spa.mjs (npm run prerender) serves dist/ via a tiny node http server (SPA fallback) and runs the BUILT app in headless Chromium per route (Playwright — already a dep), capturing the fully-rendered HTML → dist//index.html. Crawlers (no JS) now get real content; users still hydrate the app. Verified on a clean build: /about went from 0 chars in #root → 138 words of body content + per-route + JSON-LD present. Run in CI/GHA where Chromium is available (the template's e2e CI already has Playwright); supersedes the no-deps head-only prerender (#2, which stays as a Chromium-free fallback). Satisfies the new validate-body-content + validate-ssr-head gates. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --- package.json | 3 ++- scripts/prerender-spa.mjs | 39 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 41 insertions(+), 1 deletion(-) create mode 100644 scripts/prerender-spa.mjs diff --git a/package.json b/package.json index a856ccf..e46ac92 100644 --- a/package.json +++ b/package.json @@ -31,7 +31,8 @@ "lighthouse": "node scripts/lighthouse.mjs", "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:all": "npm run validate:brand && npm run typecheck && npm run test && npm run prompt-evals && npm run build && npm run perf-budget", + "prerender": "node scripts/prerender-spa.mjs" }, "dependencies": { "@radix-ui/react-accordion": "^1.2.0", diff --git a/scripts/prerender-spa.mjs b/scripts/prerender-spa.mjs new file mode 100644 index 0000000..5feb682 --- /dev/null +++ b/scripts/prerender-spa.mjs @@ -0,0 +1,39 @@ +#!/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/<route>/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 } from 'node:fs'; +import { join, extname } from 'node:path'; +import { chromium } from 'playwright'; + +const DIST = 'dist'; +const ROUTES = ['/about','/services','/contact','/pricing','/faq','/team','/gallery','/case-studies','/blog','/privacy','/terms','/accessibility']; +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 = '<!DOCTYPE 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`); From 893ee1c38a4b5d1db2e23046c3f4a3c6f735c308 Mon Sep 17 00:00:00 2001 From: Brian Zalewski <hey@megabyte.space> Date: Sun, 21 Jun 2026 02:10:56 -0400 Subject: [PATCH 2/4] feat: prerender ALL indexable routes from sitemap (incl dynamic blog/case-study posts) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The first cut hardcoded 12 static routes — leaving dynamic /blog/:slug + /case-studies/:slug posts client-only (still SEO-collapsed). Now discoverRoutes() reads dist/sitemap.xml and prerenders EVERY indexable URL. Path extraction strips scheme+host OR the {BUSINESS_URL} placeholder, so it works for both the raw template and generated sites (real domains). Verified: 12 → 19 routes prerendered, including all 6 blog posts. Sample dist/blog/doe-law-wcag/index.html → 150 words of body content + per-post <title>. No indexable route left client-only — the SEO-collapse fix is now COMPLETE across the whole site. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --- scripts/prerender-spa.mjs | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/scripts/prerender-spa.mjs b/scripts/prerender-spa.mjs index 5feb682..522d56b 100644 --- a/scripts/prerender-spa.mjs +++ b/scripts/prerender-spa.mjs @@ -5,12 +5,24 @@ // 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 } from 'node:fs'; +import { existsSync, readFileSync } from 'node:fs'; import { join, extname } from 'node:path'; import { chromium } from 'playwright'; const DIST = 'dist'; -const ROUTES = ['/about','/services','/contact','/pricing','/faq','/team','/gallery','/case-studies','/blog','/privacy','/terms','/accessibility']; +// 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>([^<]+)<\/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) From f3a278f0f1c5fd0a056e9fde5580b771e22d5a25 Mon Sep 17 00:00:00 2001 From: Brian Zalewski <hey@megabyte.space> Date: Sun, 21 Jun 2026 02:29:49 -0400 Subject: [PATCH 3/4] ci: run the prerender before deploy (so the SSR fix actually applies to live sites) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PR #4 added scripts/prerender-spa.mjs + 'npm run prerender' but nothing INVOKED it on deploy — the script would have existed but never run, so deployed demo sites would still ship the client-only SPA (SEO-collapsed). Wired it into demo-deploys.yml between Build and Deploy: install Chromium (GHA has it) → npm run prerender → wrangler pages deploy dist. Now the deployed dist contains the per-route prerendered HTML (body + head + JSON-LD). Deploy is GHA wrangler-action (not CF-Pages-git-integration), so Chromium is available — the browser-prerender approach works in this pipeline. Generated-customer-site deploy pipelines should mirror this build→prerender→deploy order. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --- .github/workflows/demo-deploys.yml | 5 +++++ 1 file changed, 5 insertions(+) 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: | From f071f2ec27c9612ca65fa1ba63d0e0d32d0dd4e7 Mon Sep 17 00:00:00 2001 From: Brian Zalewski <hey@megabyte.space> Date: Sun, 21 Jun 2026 02:50:03 -0400 Subject: [PATCH 4/4] ci: run prerender before deploy in deploy-template.mjs + deploy-applied.mjs too MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Completes the deploy-path coverage. Last commit wired the prerender into the GHA demo-deploys workflow, but the two SCRIPT-based deploy paths (deploy-template.mjs, deploy-applied.mjs) still ran vite build → wrangler pages deploy with NO prerender — so a script-driven deploy would still ship the client-only SPA (SEO-collapsed). Inserted 'node scripts/prerender-spa.mjs' right before each 'wrangler pages deploy dist' (dist/sitemap.xml is present by then, so all routes prerender). Now EVERY deploy path in the repo (GHA workflow + both scripts) server-renders body+head+JSON-LD per route before shipping. Both scripts node --check clean. (Operator needs Chromium locally: npx playwright install chromium — same as the e2e job.) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --- scripts/deploy-applied.mjs | 2 ++ scripts/deploy-template.mjs | 2 ++ 2 files changed, 4 insertions(+) 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, });