Queue-North-Website/scripts/prerender.js

202 lines
6.7 KiB
JavaScript
Raw Permalink Normal View History

feat(seo): publish privacy policy, remove street address, prerender all routes (batch 0.9.3) Client directive (Levi Halford, 2026-08-01) ahead of Google/Meta lead forms. Privacy policy: - Publish approved policy verbatim at /privacy-policy (src/data/privacyPolicy.js is the single source of truth; 292/292 source lines verified present) - Privacy Policy link in the footer of every page - Effective/Last Updated 2026-07-31, privacy@queuenorth.com as mailto Remove St. Petersburg street address from every surface named in the brief: footer, contact page, schema markup, SEO metadata, Google Maps links. Collapse ProfessionalService + Organization schema into a single Organization with areaServed: United States; drop geo coordinates, priceRange, openingHours. Add the approved US-coverage sentence to About. No replacement address. Crawler visibility (the site previously served 0 bytes of body HTML without JS): - Prerender all 19 routes at build time via src/entry-server.jsx + scripts/prerender.js - Hoist title/meta/canonical/JSON-LD into <head>; renderToString does not do this and react-helmet-async's context is empty under React 19 - Serve prerendered HTML; return a real 404 for unknown paths instead of 200 - Hydrate instead of discarding the prerendered markup SEO/perf: - Titles <=60 and descriptions <=160 chars across all pages - Add BreadcrumbList to interior pages, WebSite to home - Generate sitemap.xml from the route list with git-derived lastmod - 301 duplicate URL forms (trailing slash, //, /index.html), preserving query - Immutable caching for content-hashed assets; no-cache for HTML - Split the 522 KB bundle into app/react-vendor/router/icons - loading/decoding/fetchpriority + per-route hero preload; drop unused asset Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 01:45:52 -05:00
// Build-time prerenderer.
//
// Renders every route to static HTML so crawlers that do not execute JavaScript
// (Meta, LinkedIn, Slack, Bing) receive real content, correct per-route <title>,
// meta description, canonical, and Open Graph tags. Google also benefits: pages
// no longer sit in its deferred JS-rendering queue.
//
// Output layout (consumed by server/index.js):
// dist/index.html -> /
// dist/about/index.html -> /about
// dist/services/<slug>/index.html
// dist/404.html -> served with a real 404 status
//
// Run automatically as part of `npm run build`.
import { execFileSync } from 'child_process'
import { mkdirSync, readFileSync, writeFileSync } from 'fs'
import path from 'path'
import { fileURLToPath } from 'url'
import { render } from '../dist-ssr/entry-server.js'
import { services } from '../src/data/services.js'
import { industries } from '../src/data/industries.js'
const __dirname = path.dirname(fileURLToPath(import.meta.url))
const distDir = path.join(__dirname, '../dist')
const STATIC_ROUTES = [
'/',
'/about',
'/services',
'/industries',
'/contact',
'/support',
'/privacy-policy',
]
const routes = [
...STATIC_ROUTES,
...services.map((s) => `/services/${s.id}`),
...industries.map((i) => `/industries/${i.id}`),
]
// Tags the SEO component owns per-route. They are stripped from the template so
// Helmet's values replace them instead of duplicating them.
const TEMPLATE_TAGS_TO_STRIP = [
/<title>[\s\S]*?<\/title>\s*/,
/<meta name="description"[^>]*>\s*/,
/<meta property="og:[^"]*"[^>]*>\s*/g,
/<meta name="twitter:[^"]*"[^>]*>\s*/g,
/<!-- Open Graph fallback for crawlers that don't execute JavaScript -->\s*/,
/<!-- Twitter \/ X Card fallback -->\s*/,
]
// Metadata React renders inside the component tree. React 19 hoists these into
// <head> in the browser and in its streaming renderer, but renderToString leaves
// them inline, so the prerenderer performs the same hoist. Leaving them in <body>
// would put every title, canonical, and og: tag somewhere crawlers ignore.
const HOISTABLE_TAGS =
/<title[^>]*>[\s\S]*?<\/title>|<meta\b[^>]*?\/?>|<link\b[^>]*?\/?>|<script[^>]*type="application\/ld\+json"[^>]*>[\s\S]*?<\/script>/g
const buildPage = (template, url) => {
const { html } = render(url)
const hoisted = html.match(HOISTABLE_TAGS) || []
const body = html.replace(HOISTABLE_TAGS, '')
// Preload this route's own LCP image. The hero is the one marked eager during
// render, so the hint always matches what the page actually paints first.
const heroSrc = html
.match(/<img[^>]*loading="eager"[^>]*>/)?.[0]
.match(/src="([^"]+)"/)?.[1]
const head = []
if (heroSrc) {
head.push(`<link rel="preload" as="image" href="${heroSrc}" fetchpriority="high" />`)
}
// React emits its own image preload for the eager hero; drop it so the hint
// above isn't duplicated.
for (const tag of hoisted) {
if (/rel="preload"[^>]*as="image"/.test(tag)) continue
head.push(tag)
}
let page = template
for (const pattern of TEMPLATE_TAGS_TO_STRIP) {
page = page.replace(pattern, '')
}
page = page.replace('</head>', ` ${head.join('\n ')}\n </head>`)
page = page.replace('<div id="root"></div>', `<div id="root">${body}</div>`)
return page
}
const outputPathFor = (url) =>
url === '/'
? path.join(distDir, 'index.html')
: path.join(distDir, url, 'index.html')
const template = readFileSync(path.join(distDir, 'index.html'), 'utf8')
const written = []
for (const url of routes) {
const page = buildPage(template, url)
const outPath = outputPathFor(url)
mkdirSync(path.dirname(outPath), { recursive: true })
writeFileSync(outPath, page)
written.push([url, page.length])
}
// Dedicated 404 document. Rendering an unmatched path hits the catch-all route,
// which carries `noindex, follow` — now visible to crawlers in static HTML.
const notFoundPage = buildPage(template, '/__not_found__')
writeFileSync(path.join(distDir, '404.html'), notFoundPage)
written.push(['404.html', notFoundPage.length])
console.log(`\nPrerendered ${written.length} pages:`)
for (const [url, size] of written) {
console.log(` ${url.padEnd(42)} ${(size / 1024).toFixed(1)} KB`)
}
// --- sitemap.xml -------------------------------------------------------------
// Generated from the same route list that drives prerendering, so the sitemap can
// never drift out of sync with what the site actually serves.
const SITE_URL = 'https://queuenorth.com'
// lastmod comes from git history for the files that produce each route. A build
// timestamp would mark every page as changed on every deploy, which search engines
// learn to distrust.
const lastModifiedFor = (files) => {
let newest = null
for (const file of files) {
try {
const iso = execFileSync('git', ['log', '-1', '--format=%cI', '--', file], {
cwd: path.join(__dirname, '..'),
encoding: 'utf8',
stdio: ['ignore', 'pipe', 'ignore'],
}).trim()
if (iso && (!newest || iso > newest)) newest = iso
} catch {
// git unavailable or file untracked — fall through
}
}
return newest ? newest.slice(0, 10) : null
}
const ROUTE_SOURCES = {
'/': ['src/pages/Home.jsx'],
'/about': ['src/pages/About.jsx'],
'/services': ['src/pages/Services.jsx', 'src/data/services.js'],
'/industries': ['src/pages/Industries.jsx', 'src/data/industries.js'],
'/contact': ['src/pages/Contact.jsx'],
'/support': ['src/pages/Support.jsx'],
'/privacy-policy': ['src/pages/PrivacyPolicy.jsx', 'src/data/privacyPolicy.js'],
}
const PRIORITY = {
'/': '1.0',
'/services': '0.9',
'/contact': '0.9',
'/about': '0.8',
'/industries': '0.8',
'/support': '0.8',
'/privacy-policy': '0.3',
}
const CHANGEFREQ = { '/': 'weekly', '/privacy-policy': 'yearly' }
const sourcesFor = (url) => {
if (ROUTE_SOURCES[url]) return ROUTE_SOURCES[url]
if (url.startsWith('/services/')) return ['src/pages/ServiceDetail.jsx', 'src/data/services.js']
return ['src/pages/IndustryDetail.jsx', 'src/data/industries.js']
}
const entries = routes.map((url) => {
const loc = url === '/' ? SITE_URL : `${SITE_URL}${url}`
const lastmod = lastModifiedFor(sourcesFor(url))
return [
' <url>',
` <loc>${loc}</loc>`,
lastmod ? ` <lastmod>${lastmod}</lastmod>` : null,
` <changefreq>${CHANGEFREQ[url] || 'monthly'}</changefreq>`,
` <priority>${PRIORITY[url] || '0.7'}</priority>`,
' </url>',
]
.filter(Boolean)
.join('\n')
})
const sitemap = [
'<?xml version="1.0" encoding="UTF-8"?>',
'<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">',
...entries,
'</urlset>',
'',
].join('\n')
writeFileSync(path.join(distDir, 'sitemap.xml'), sitemap)
console.log(`\nGenerated sitemap.xml with ${routes.length} URLs`)