Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .github/workflows/demo-deploys.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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: |
Expand Down
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
2 changes: 2 additions & 0 deletions scripts/deploy-applied.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
2 changes: 2 additions & 0 deletions scripts/deploy-template.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
});
Expand Down
51 changes: 51 additions & 0 deletions scripts/prerender-spa.mjs
Original file line number Diff line number Diff line change
@@ -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/<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, 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>([^<]+)<\/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 = '<!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`);
Loading