265 lines
9.4 KiB
JavaScript
265 lines
9.4 KiB
JavaScript
// 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
|
||
|
||
// 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.',
|
||
)
|
||
}
|
||
|
||
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`)
|