From 02d3ccf4b237e9bb5134a5a6fff458167dd7178a Mon Sep 17 00:00:00 2001 From: Brian Zalewski Date: Sat, 20 Jun 2026 23:11:58 -0400 Subject: [PATCH 1/2] =?UTF-8?q?fix:=20prerender=20per-route=20=20?= =?UTF-8?q?=E2=80=94=20interim=20SEO-collapse=20fix=20(de-collapse=20via?= =?UTF-8?q?=20unique=20title+canonical)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The app is a client SPA: crawlers read the static index.html shell, so every route shipped the homepage +canonical=/ → site-wide SEO collapse (confirmed live on projectsites.dev). The client-side useSEO update is invisible to Googlebot/ChatGPT/Perplexity. scripts/prerender-route-heads.mjs (postbuild) writes dist/<route>/index.html for each static route with a UNIQUE per-route <title> + self-referencing <link canonical> (+ og:title/og:url). CF Pages serves these file-based, BEFORE the /* → /index.html SPA fallback. Verified: 13 per-route HTML files, each /about→'About — {name}' canonical=/about (was homepage title + canonical=/). The {name} placeholder resolves per-site via swap-brand. INTERIM by design — fixes the critical de-collapse (unique title + self-canonical, which Google dedupes by). Per-route DESCRIPTIONS still need the full fix (they're runtime-only React literals; resolve via vite-react-ssg). Removing this script once SSG lands is a one-liner. validate-ssr-head (PR #1) now passes (prerendered route HTML present). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --- package.json | 2 +- scripts/prerender-route-heads.mjs | 40 +++++++++++++++++++++++++++++++ 2 files changed, 41 insertions(+), 1 deletion(-) create mode 100644 scripts/prerender-route-heads.mjs diff --git a/package.json b/package.json index a856ccf..a0a59c7 100644 --- a/package.json +++ b/package.json @@ -8,7 +8,7 @@ "dev": "vite", "prebuild": "node scripts/build-feeds.mjs", "build": "tsc -b && vite build", - "postbuild": "node scripts/build-feeds.mjs && cp public/feed.xml public/atom.xml public/feed.json public/sitemap.xml dist/ 2>/dev/null || true", + "postbuild": "node scripts/build-feeds.mjs && cp public/feed.xml public/atom.xml public/feed.json public/sitemap.xml dist/ 2>/dev/null || true && node scripts/prerender-route-heads.mjs", "preview": "vite preview", "build:feeds": "node scripts/build-feeds.mjs", "typecheck": "tsc -b --noEmit", diff --git a/scripts/prerender-route-heads.mjs b/scripts/prerender-route-heads.mjs new file mode 100644 index 0000000..0b29e79 --- /dev/null +++ b/scripts/prerender-route-heads.mjs @@ -0,0 +1,40 @@ +#!/usr/bin/env node +// prerender-route-heads.mjs — INTERIM SEO-collapse fix. The app is a client SPA; crawlers +// read the static index.html shell, so every route ships the homepage <title>+canonical=/. +// This postbuild step writes dist/<route>/index.html for each static route with a UNIQUE +// per-route <title> + per-route <link canonical>, so CF Pages serves real per-route HTML +// (file-based, before the /* → /index.html SPA fallback). Descriptions stay shell-level +// pending the full vite-react-ssg fix (which resolves per-route desc by running React). +import { readFileSync, writeFileSync, mkdirSync } from 'node:fs'; +import { join } from 'node:path'; + +const DIST = 'dist'; +const shell = readFileSync(join(DIST, 'index.html'), 'utf8'); + +// origin + business name from the shell's existing head. +const origin = (shell.match(/<link[^>]+rel=["']canonical["'][^>]*href=["'](https?:\/\/[^/"']+)/i) || [])[1] || ''; +const homeTitle = (shell.match(/<title>([^<]*)<\/title>/i) || [])[1] || ''; +const businessName = homeTitle.split(/\s[—–-]\s/)[0].trim() || homeTitle.trim(); + +// static routes → page label (dynamic /blog/:slug etc. handled by full SSR later). +const ROUTES = { + '/about': 'About', '/services': 'Services', '/contact': 'Contact', '/pricing': 'Pricing', + '/faq': 'FAQ', '/team': 'Team', '/gallery': 'Gallery', '/case-studies': 'Case studies', + '/blog': 'Blog', '/privacy': 'Privacy Policy', '/terms': 'Terms of Service', + '/accessibility': 'Accessibility', '/studio': 'Brand Studio', +}; + +let n = 0; +for (const [path, label] of Object.entries(ROUTES)) { + const title = `${label} — ${businessName}`; + const canonical = `${origin}${path}`; + let html = shell + .replace(/<title>[^<]*<\/title>/i, `<title>${title}`) + .replace(/(]+rel=["']canonical["'][^>]*href=["'])[^"']*(["'])/i, `$1${canonical}$2`) + .replace(/(]+property=["']og:title["'][^>]*content=["'])[^"']*(["'])/i, `$1${title}$2`) + .replace(/(]+property=["']og:url["'][^>]*content=["'])[^"']*(["'])/i, `$1${canonical}$2`); + mkdirSync(join(DIST, path), { recursive: true }); + writeFileSync(join(DIST, path, 'index.html'), html); + n++; +} +console.log(`[prerender-route-heads] wrote ${n} per-route HTML files · business="${businessName}" · origin=${origin}`); From d1cbb622d97da0b782d88eb35cf3110ae2945fc0 Mon Sep 17 00:00:00 2001 From: Brian Zalewski Date: Sun, 21 Jun 2026 00:11:07 -0400 Subject: [PATCH 2/2] =?UTF-8?q?feat:=20prerender=20per-route=20DESCRIPTION?= =?UTF-8?q?S=20too=20(read=20from=20each=20page's=20useSEO=20=E2=80=94=20s?= =?UTF-8?q?ingle=20source)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extends the interim de-collapse (this PR) from title+canonical to the FULL per-route head. The prerender now reads each route's description straight from its page useSEO() call (src/pages/.tsx), resolving the simple ${brand.business.name}/{BUSINESS_NAME} substitutions — so the server MATCHES the client one exactly (no divergence, single source of truth). Also rewrites og:title/og:url/og:description per route. Verified on a clean build: /team→'Meet the people behind {name}', /faq→'Answers about {name}', /about→'{ABOUT_META_DESCRIPTION}' — each route now has a UNIQUE title + canonical + description (was the homepage's on every route). Placeholders stay for the raw template (generator fills); for a generated site swap-brand resolves src pre-build, so the prerender reads resolved copy. Closes the per-route-head gap the title-only version left (checklist #50 unique meta desc). Full proper fix is still vite-react-ssg; this interim now covers the complete crawler-visible head. Co-Authored-By: Claude Opus 4.8 (1M context) --- scripts/prerender-route-heads.mjs | 51 +++++++++++++++++++------------ 1 file changed, 32 insertions(+), 19 deletions(-) diff --git a/scripts/prerender-route-heads.mjs b/scripts/prerender-route-heads.mjs index 0b29e79..aa4dfcf 100644 --- a/scripts/prerender-route-heads.mjs +++ b/scripts/prerender-route-heads.mjs @@ -1,40 +1,53 @@ #!/usr/bin/env node -// prerender-route-heads.mjs — INTERIM SEO-collapse fix. The app is a client SPA; crawlers -// read the static index.html shell, so every route ships the homepage +canonical=/. -// This postbuild step writes dist/<route>/index.html for each static route with a UNIQUE -// per-route <title> + per-route <link canonical>, so CF Pages serves real per-route HTML -// (file-based, before the /* → /index.html SPA fallback). Descriptions stay shell-level -// pending the full vite-react-ssg fix (which resolves per-route desc by running React). -import { readFileSync, writeFileSync, mkdirSync } from 'node:fs'; +// prerender-route-heads.mjs — INTERIM SEO-collapse fix. The app is a client SPA; crawlers read +// the static index.html shell, so every route shipped the homepage <title>+canonical=/+desc. +// This postbuild step writes dist/<route>/index.html per static route with a UNIQUE per-route +// <title>, self-referencing <link canonical>, AND per-route <meta description> — the last read +// straight from each page's own useSEO() call (single source of truth, no divergence; only the +// simple ${brand.business.name}/{BUSINESS_NAME} substitutions are resolved). CF Pages serves +// these file-based, before the /* → /index.html SPA fallback. Replace with vite-react-ssg later. +import { readFileSync, writeFileSync, mkdirSync, existsSync } from 'node:fs'; import { join } from 'node:path'; const DIST = 'dist'; const shell = readFileSync(join(DIST, 'index.html'), 'utf8'); - -// origin + business name from the shell's existing head. const origin = (shell.match(/<link[^>]+rel=["']canonical["'][^>]*href=["'](https?:\/\/[^/"']+)/i) || [])[1] || ''; const homeTitle = (shell.match(/<title>([^<]*)<\/title>/i) || [])[1] || ''; const businessName = homeTitle.split(/\s[—–-]\s/)[0].trim() || homeTitle.trim(); -// static routes → page label (dynamic /blog/:slug etc. handled by full SSR later). +// path → [page label (title), source component for description extraction] const ROUTES = { - '/about': 'About', '/services': 'Services', '/contact': 'Contact', '/pricing': 'Pricing', - '/faq': 'FAQ', '/team': 'Team', '/gallery': 'Gallery', '/case-studies': 'Case studies', - '/blog': 'Blog', '/privacy': 'Privacy Policy', '/terms': 'Terms of Service', - '/accessibility': 'Accessibility', '/studio': 'Brand Studio', + '/about': ['About', 'About'], '/services': ['Services', 'Services'], '/contact': ['Contact', 'Contact'], + '/pricing': ['Pricing', 'Pricing'], '/faq': ['FAQ', 'FAQ'], '/team': ['Team', 'Team'], + '/gallery': ['Gallery', 'Gallery'], '/case-studies': ['Case studies', 'CaseStudies'], + '/blog': ['Blog', 'Blog'], '/privacy': ['Privacy Policy', 'Privacy'], '/terms': ['Terms of Service', 'Terms'], + '/accessibility': ['Accessibility', 'Accessibility'], '/studio': ['Brand Studio', 'Studio'], }; +// pull the description straight from the page's useSEO() — single source of truth. +function pageDescription(component) { + const p = `src/pages/${component}.tsx`; + if (!existsSync(p)) return null; + const m = readFileSync(p, 'utf8').match(/useSEO\(\{[\s\S]{0,400}?description:\s*[`'"]([^`'"]+)[`'"]/); + if (!m) return null; + return m[1].replace(/\$\{brand\.business\.name\}/g, businessName).replace(/\{BUSINESS_NAME\}/g, businessName).trim(); +} +const setMeta = (html, name, attr, val) => + html.replace(new RegExp(`(<meta[^>]+${attr}=["']${name}["'][^>]*content=["'])[^"']*(["'])`, 'i'), `$1${val}$2`); + let n = 0; -for (const [path, label] of Object.entries(ROUTES)) { +for (const [path, [label, comp]] of Object.entries(ROUTES)) { const title = `${label} — ${businessName}`; const canonical = `${origin}${path}`; + const desc = pageDescription(comp); let html = shell .replace(/<title>[^<]*<\/title>/i, `<title>${title}`) - .replace(/(]+rel=["']canonical["'][^>]*href=["'])[^"']*(["'])/i, `$1${canonical}$2`) - .replace(/(]+property=["']og:title["'][^>]*content=["'])[^"']*(["'])/i, `$1${title}$2`) - .replace(/(]+property=["']og:url["'][^>]*content=["'])[^"']*(["'])/i, `$1${canonical}$2`); + .replace(/(]+rel=["']canonical["'][^>]*href=["'])[^"']*(["'])/i, `$1${canonical}$2`); + html = setMeta(html, 'og:title', 'property', title); + html = setMeta(html, 'og:url', 'property', canonical); + if (desc) { html = setMeta(html, 'description', 'name', desc); html = setMeta(html, 'og:description', 'property', desc); } mkdirSync(join(DIST, path), { recursive: true }); writeFileSync(join(DIST, path, 'index.html'), html); n++; } -console.log(`[prerender-route-heads] wrote ${n} per-route HTML files · business="${businessName}" · origin=${origin}`); +console.log(`[prerender-route-heads] wrote ${n} per-route HTML files (title+canonical+description) · business="${businessName}"`);