// 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/<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`)