Queue-North-Website/scripts/prerender.js

286 lines
11 KiB
JavaScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 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, routes as routerTable } from '../dist-ssr/entry-server.js'
import { services } from '../src/data/services.js'
import { industries } from '../src/data/industries.js'
import { ROUTES as routes, STATIC_ROUTES, routeDrift } from './lib/routes.js'
import { validateContent } from './lib/content.js'
const __dirname = path.dirname(fileURLToPath(import.meta.url))
const distDir = path.join(__dirname, '../dist')
// The route list and the router's own table both come from elsewhere now, so
// this file cannot disagree with either. See scripts/lib/routes.js.
// 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.
//
// JSON-LD is deliberately NOT in this list. React hoists only async scripts with
// a src, so on the client the ld+json script stays where its component renders
// it, in the body. Moving it to <head> here made the prerendered DOM disagree
// with the client's first render, and React threw out the whole prerendered page
// and re-rendered it (error #418, on every page). Structured data is valid
// anywhere in the document, so the honest fix is to leave it alone.
const HOISTABLE_TAGS = /<title[^>]*>[\s\S]*?<\/title>|<meta\b[^>]*?\/?>|<link\b[^>]*?\/?>/g
// An inline <svg> may carry its own <title>, and microdata rides in
// <meta itemprop> tags. Neither belongs in <head>: hoisting an SVG title gives
// the page two titles, which is the exact shape of defect this hoist exists to
// prevent. So SVG blocks are parked before the hoist and put back after.
const SVG_BLOCK = /<svg\b[\s\S]*?<\/svg>/gi
const SVG_TOKEN = 'svg'
// renderToString does not wait for Suspense: it emits the fallback and marks it.
// A page carrying one of these lost its content silently, and crawlers would
// receive the fallback as the page.
const SUSPENSE_MARKERS = ['<!--$!-->', '<!--$?-->']
// Substitute a marker that must appear exactly once, without regex replacement
// semantics. String.replace interprets `$&`, `$'` and `$$` inside the
// REPLACEMENT, and the replacement here is page copy, which is not ours to
// trust: one `$` before an escaped entity would inject markup into the page.
const replaceOnce = (text, marker, replacement, url) => {
const parts = text.split(marker)
if (parts.length !== 2) {
throw new Error(
`prerender: ${url}: expected exactly one ${marker} in the template, found ${parts.length - 1}.`,
)
}
return `${parts[0]}${replacement}${parts[1]}`
}
const buildPage = (template, url) => {
let html
try {
;({ html } = render(url))
} catch (error) {
// Without the route, the build fails with a stack trace and no clue which
// of the nineteen pages produced it.
throw new Error(`prerender: ${url} could not be rendered: ${error.message}`, { cause: error })
}
const svgs = []
const parked = html.replace(SVG_BLOCK, (svg) => `${SVG_TOKEN}${svgs.push(svg) - 1}${SVG_TOKEN}`)
const hoisted = []
const body = parked
.replace(HOISTABLE_TAGS, (tag) => {
if (/\bitemprop=/i.test(tag)) return tag
hoisted.push(tag)
return ''
})
.replace(new RegExp(`${SVG_TOKEN}(\\d+)${SVG_TOKEN}`, 'g'), (_, index) => svgs[Number(index)])
// Preload this route's own LCP image: the one React marked with a high fetch
// priority. Keying on `loading="eager"` matched the header logo, which is on
// every page, so every page preloaded the logo and no page preloaded its own
// hero. React writes the attribute camelCase in HTML, hence the /i.
const heroSrc = html
.match(/<img\b[^>]*\bfetchpriority="high"[^>]*>/i)?.[0]
?.match(/\bsrc="([^"]+)"/i)?.[1]
const head = []
if (heroSrc) {
head.push(`<link rel="preload" as="image" href="${heroSrc}" fetchpriority="high" />`)
}
// React emits its own preload for that hero; drop it so the hint above is not
// 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 = replaceOnce(page, '</head>', ` ${head.join('\n ')}\n </head>`, url)
page = replaceOnce(page, '<div id="root"></div>', `<div id="root">${body}</div>`, url)
const marker = SUSPENSE_MARKERS.find((m) => page.includes(m))
if (marker) {
throw new Error(
`prerender: ${url} shipped a Suspense fallback (${marker}) instead of its content. ` +
'renderToString does not wait, so a lazy import or a Suspense boundary above this route ' +
'silently empties the page for every crawler.',
)
}
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')
// dist/index.html is both the template and the output for `/`, so running this
// script twice without a rebuild would treat a finished page as the template and
// give every page two canonicals and two of every head tag.
if (/rel="canonical"/.test(template)) {
throw new Error(
'prerender: dist/index.html already carries a canonical, so it is a rendered page rather than the ' +
'template. Run `vite build` before prerendering.',
)
}
// Content before pages. A page built from broken data is worse than no build:
// it looks finished. This is the check that keeps a website-manager direction
// out of the copy, and it runs on every build, including the image build.
const content = validateContent({ services, industries })
for (const warning of content.warnings) console.warn(`prerender: note: ${warning}`)
if (content.checked === 0) {
throw new Error('prerender: the content check examined nothing, which is not a pass. Did src/data fail to import?')
}
if (content.errors.length) {
console.error(`\nprerender: ${content.errors.length} content problem(s):`)
for (const error of content.errors) console.error(` ${error}`)
throw new Error('prerender: refusing to build pages from content that does not hold together.')
}
// The router and this script must agree on which pages exist. A route declared
// in src/routes.jsx and missing here is never written to dist/, and the server
// serves 404.html for it.
const drift = routeDrift(routerTable)
if (drift.length) {
throw new Error(
`prerender: ${drift.join(', ')} ${drift.length === 1 ? 'is a route' : 'are routes'} the router declares and this ` +
`build does not produce, so the server would answer ${drift.length === 1 ? 'it' : 'them'} with 404.html. ` +
'Add to STATIC_ROUTES in scripts/lib/routes.js.',
)
}
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`)