// 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
,
// 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//index.html
// dist/404.html -> served with a real 404 status
//
// Run automatically as part of `npm run build`.
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, lastModByRoute, 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 = [
/[\s\S]*?<\/title>\s*/,
/ ]*>\s*/,
/ ]*>\s*/g,
/ ]*>\s*/g,
/\s*/,
/\s*/,
]
// Metadata React renders inside the component tree. React 19 hoists these into
// in the browser and in its streaming renderer, but renderToString leaves
// them inline, so the prerenderer performs the same hoist. Leaving them in
// 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 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 = /]*>[\s\S]*?<\/title>| ]*?\/?>| ]*?\/?>/g
// An inline may carry its own , and microdata rides in
// tags. Neither belongs in : 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 = //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(/ ]*\bfetchpriority="high"[^>]*>/i)?.[0]
?.match(/\bsrc="([^"]+)"/i)?.[1]
const head = []
if (heroSrc) {
head.push(` `)
}
// 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.join('\n ')}\n `, url)
page = replaceOnce(page, '
', `${body}
`, 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'
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' }
// Dates come from git where git exists, and from the map release.sh injects
// where it does not. See scripts/lib/routes.js.
const lastmodByRoute = lastModByRoute()
const entries = routes.map((url) => {
const loc = url === '/' ? SITE_URL : `${SITE_URL}${url}`
const lastmod = lastmodByRoute[url] ?? null
return [
' ',
` ${loc} `,
lastmod ? ` ${lastmod} ` : null,
` ${CHANGEFREQ[url] || 'monthly'} `,
` ${PRIORITY[url] || '0.7'} `,
' ',
]
.filter(Boolean)
.join('\n')
})
const sitemap = [
'',
'',
...entries,
' ',
'',
].join('\n')
writeFileSync(path.join(distDir, 'sitemap.xml'), sitemap)
// Say how many pages carry a date, every time. The production sitemap carried
// none at all for months: the image build has no git, and the failure to read
// it was swallowed. Silence is what let that run.
const dated = routes.filter((url) => lastmodByRoute[url]).length
console.log(`\nGenerated sitemap.xml with ${routes.length} URLs, ${dated} carrying a lastmod`)
if (dated < routes.length) {
console.warn(
`prerender: ${routes.length - dated} URL(s) have no lastmod. git history is not readable here, which is ` +
'normal inside the image build. Pass SITEMAP_LASTMOD, as scripts/release.sh does.',
)
}