202 lines
6.7 KiB
JavaScript
202 lines
6.7 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
|
|
|
|
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`)
|