Queue-North-Website/scripts/lib/routes.js

133 lines
4.8 KiB
JavaScript
Raw Permalink Normal View History

feat(build): the copy is checked before a single page is built from it src/data is prose in a data structure, and nothing checked it. The long-form service pages make that dangerous in a specific way: their copy arrives as an owner-approved markdown sheet that MIXES DIRECTIONS TO THE WEBSITE MANAGER INTO THE COPY. "Do not promise that every number is always portable." "Keep this factual:" "Place an official 8x8 Work screenshot beside this section." Those lines look exactly like copy, and publishing one puts an internal instruction on a customer-facing page. scripts/lib/content.js decides whether the content layer is publishable, and prerender.js runs it before rendering anything, so every build enforces it: the pre-commit hook, npm run verify, and the Docker image build. It refuses a website-manager direction, an em dash, a U+FFFD, markdown or an HTML tag left in a string, an unknown block type, a section id that is not letter-first, unique and free of the layout's own ids, a section that does not open with its direct answer (unless it declares kind list or faq), a FAQ question with no answer, a link to a route or fragment that does not exist, an image whose src is missing from public/ or has no alt or no dimensions, and the missing benefits or idealFor list that the short layout maps without checking. A description over 160 characters is a note, not a failure: owner-approved copy is published as written. scripts/lib/routes.js is now the one route list. prerender.js built its own while src/routes.jsx built the router's, and nothing compared them: a route in one and not the other is never prerendered, so the server answers it with 404.html while the site's own navigation links to it. entry-server.jsx exports the router table so the build can compare the two. Proven by mutation, seventeen of them, each expecting exactly one finding and getting it: unknown block type, FAQ answer removed, answer moved below its list, duplicate id, digit-leading id, link to /services/contact-centre, #no-such-section, missing image file, image without dimensions, image without alt, em dash, a manager direction, markdown bold, U+FFFD, an HTML tag, missing h1, empty section. Both generated content modules pass unmutated. Against a real build: an em dash added to industries.js failed npm run build naming the field, and a /pricing route added to src/routes.jsx failed it naming the route. Closes #230. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-09-10 04:43:01 -05:00
// Every route this site serves, in one place.
//
// `scripts/prerender.js` used to build its own list while `src/routes.jsx` built
// the router's, and nothing compared them. A route added to one and not the
// other is not a small bug: the page is never prerendered, so `server/index.js`
// answers a direct request for it with `dist/404.html`, and every visitor
// following a link and every crawler reading the sitemap gets a 404 on a page
// the site's own navigation points at.
//
// Plain JavaScript with no side effects, because prerender imports it in Node
// before Vite exists, and so does the validator runner.
fix(seo): the production sitemap had no dates, and the build context had secrets Three things, all in the path between this repository and the running image. Closes #225, #224 and #223. 1. THE PRODUCTION SITEMAP CARRIED NO LASTMOD AT ALL. Dates come from git history, and the image build cannot see git: .dockerignore excludes .git and node:alpine has no git binary. prerender.js read the failure into an empty catch commented "git unavailable or file untracked", so all 18 URLs came out undated while the build printed a success line. Local builds looked perfect, which is why nobody caught it. release.sh now computes the map where git exists, passes it as the SITEMAP_LASTMOD build arg, and then asks the built image whether its sitemap has dates, refusing to publish one that does not. prerender prints the count on every run, so "18 URLs, 0 dated" can never again read as success. The route-to-source map moved into scripts/lib/routes.js, where a service page now also counts its own content file, so editing one page's copy moves that page's date and no other. Proven: an image built with the arg carries 18 lastmod entries; a build with git deliberately unreadable and no arg reports "18 URLs, 0 carrying a lastmod" and warns. 2. THE DOCKER BUILD CONTEXT CARRIED CLIENT MATERIAL AND LIVE SECRETS. .drop/, zoho.md (the reCAPTCHA secret and the Zoho tokens), Levi.md and two 30 MB zips were all sent to the daemon on every build, along with four agent workspaces. The final image copies only built output, so none of it ever shipped, but one careless COPY would have changed that. Proven by listing the context from inside a throwaway image: before, all of it; after, none of it. 3. UNTRACKED FILES PASSED SILENTLY. docker build packs the working tree, so an untracked module the code imports produces an image that works and a tag that cannot rebuild it. release.sh now refuses while untracked files are present, and pre-commit's note counts them too. #223 also claimed post-commit hides a refused push. It does not: it printed "push was refused. The commit is safe locally and the branch is now ahead." during this batch. The issue was corrected on the tracker rather than acted on. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-09-10 04:58:37 -05:00
import { execFileSync } from 'child_process'
import path from 'path'
import { fileURLToPath } from 'url'
feat(build): the copy is checked before a single page is built from it src/data is prose in a data structure, and nothing checked it. The long-form service pages make that dangerous in a specific way: their copy arrives as an owner-approved markdown sheet that MIXES DIRECTIONS TO THE WEBSITE MANAGER INTO THE COPY. "Do not promise that every number is always portable." "Keep this factual:" "Place an official 8x8 Work screenshot beside this section." Those lines look exactly like copy, and publishing one puts an internal instruction on a customer-facing page. scripts/lib/content.js decides whether the content layer is publishable, and prerender.js runs it before rendering anything, so every build enforces it: the pre-commit hook, npm run verify, and the Docker image build. It refuses a website-manager direction, an em dash, a U+FFFD, markdown or an HTML tag left in a string, an unknown block type, a section id that is not letter-first, unique and free of the layout's own ids, a section that does not open with its direct answer (unless it declares kind list or faq), a FAQ question with no answer, a link to a route or fragment that does not exist, an image whose src is missing from public/ or has no alt or no dimensions, and the missing benefits or idealFor list that the short layout maps without checking. A description over 160 characters is a note, not a failure: owner-approved copy is published as written. scripts/lib/routes.js is now the one route list. prerender.js built its own while src/routes.jsx built the router's, and nothing compared them: a route in one and not the other is never prerendered, so the server answers it with 404.html while the site's own navigation links to it. entry-server.jsx exports the router table so the build can compare the two. Proven by mutation, seventeen of them, each expecting exactly one finding and getting it: unknown block type, FAQ answer removed, answer moved below its list, duplicate id, digit-leading id, link to /services/contact-centre, #no-such-section, missing image file, image without dimensions, image without alt, em dash, a manager direction, markdown bold, U+FFFD, an HTML tag, missing h1, empty section. Both generated content modules pass unmutated. Against a real build: an em dash added to industries.js failed npm run build naming the field, and a /pricing route added to src/routes.jsx failed it naming the route. Closes #230. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-09-10 04:43:01 -05:00
import { services } from '../../src/data/services.js'
import { industries } from '../../src/data/industries.js'
fix(seo): the production sitemap had no dates, and the build context had secrets Three things, all in the path between this repository and the running image. Closes #225, #224 and #223. 1. THE PRODUCTION SITEMAP CARRIED NO LASTMOD AT ALL. Dates come from git history, and the image build cannot see git: .dockerignore excludes .git and node:alpine has no git binary. prerender.js read the failure into an empty catch commented "git unavailable or file untracked", so all 18 URLs came out undated while the build printed a success line. Local builds looked perfect, which is why nobody caught it. release.sh now computes the map where git exists, passes it as the SITEMAP_LASTMOD build arg, and then asks the built image whether its sitemap has dates, refusing to publish one that does not. prerender prints the count on every run, so "18 URLs, 0 dated" can never again read as success. The route-to-source map moved into scripts/lib/routes.js, where a service page now also counts its own content file, so editing one page's copy moves that page's date and no other. Proven: an image built with the arg carries 18 lastmod entries; a build with git deliberately unreadable and no arg reports "18 URLs, 0 carrying a lastmod" and warns. 2. THE DOCKER BUILD CONTEXT CARRIED CLIENT MATERIAL AND LIVE SECRETS. .drop/, zoho.md (the reCAPTCHA secret and the Zoho tokens), Levi.md and two 30 MB zips were all sent to the daemon on every build, along with four agent workspaces. The final image copies only built output, so none of it ever shipped, but one careless COPY would have changed that. Proven by listing the context from inside a throwaway image: before, all of it; after, none of it. 3. UNTRACKED FILES PASSED SILENTLY. docker build packs the working tree, so an untracked module the code imports produces an image that works and a tag that cannot rebuild it. release.sh now refuses while untracked files are present, and pre-commit's note counts them too. #223 also claimed post-commit hides a refused push. It does not: it printed "push was refused. The commit is safe locally and the branch is now ahead." during this batch. The issue was corrected on the tracker rather than acted on. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-09-10 04:58:37 -05:00
const repoRoot = path.join(path.dirname(fileURLToPath(import.meta.url)), '../..')
feat(build): the copy is checked before a single page is built from it src/data is prose in a data structure, and nothing checked it. The long-form service pages make that dangerous in a specific way: their copy arrives as an owner-approved markdown sheet that MIXES DIRECTIONS TO THE WEBSITE MANAGER INTO THE COPY. "Do not promise that every number is always portable." "Keep this factual:" "Place an official 8x8 Work screenshot beside this section." Those lines look exactly like copy, and publishing one puts an internal instruction on a customer-facing page. scripts/lib/content.js decides whether the content layer is publishable, and prerender.js runs it before rendering anything, so every build enforces it: the pre-commit hook, npm run verify, and the Docker image build. It refuses a website-manager direction, an em dash, a U+FFFD, markdown or an HTML tag left in a string, an unknown block type, a section id that is not letter-first, unique and free of the layout's own ids, a section that does not open with its direct answer (unless it declares kind list or faq), a FAQ question with no answer, a link to a route or fragment that does not exist, an image whose src is missing from public/ or has no alt or no dimensions, and the missing benefits or idealFor list that the short layout maps without checking. A description over 160 characters is a note, not a failure: owner-approved copy is published as written. scripts/lib/routes.js is now the one route list. prerender.js built its own while src/routes.jsx built the router's, and nothing compared them: a route in one and not the other is never prerendered, so the server answers it with 404.html while the site's own navigation links to it. entry-server.jsx exports the router table so the build can compare the two. Proven by mutation, seventeen of them, each expecting exactly one finding and getting it: unknown block type, FAQ answer removed, answer moved below its list, duplicate id, digit-leading id, link to /services/contact-centre, #no-such-section, missing image file, image without dimensions, image without alt, em dash, a manager direction, markdown bold, U+FFFD, an HTML tag, missing h1, empty section. Both generated content modules pass unmutated. Against a real build: an em dash added to industries.js failed npm run build naming the field, and a /pricing route added to src/routes.jsx failed it naming the route. Closes #230. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-09-10 04:43:01 -05:00
export const STATIC_ROUTES = [
'/',
'/about',
'/services',
'/industries',
'/contact',
'/support',
'/privacy-policy',
]
export const ROUTES = [
...STATIC_ROUTES,
...services.map((service) => `/services/${service.id}`),
...industries.map((industry) => `/industries/${industry.id}`),
]
const join = (base, path) => `${base}/${path}`.replace(/\/{2,}/g, '/')
/** Flattens the router's nested table into the paths it declares. */
export const routerPaths = (table, base = '') =>
table.flatMap((route) => {
const self = route.index ? base || '/' : route.path === '/' ? '/' : join(base, route.path ?? '')
const children = route.children ? routerPaths(route.children, self === '/' ? '' : self) : []
return [self, ...children]
})
/**
* Routes the router declares that nothing prerenders. A path with a parameter
* (`/services/:slug`) or the catch-all is covered by the data lists above
* rather than by a literal, so neither counts as drift.
*/
export const routeDrift = (table) => [
...new Set(
routerPaths(table)
fix(seo): the production sitemap had no dates, and the build context had secrets Three things, all in the path between this repository and the running image. Closes #225, #224 and #223. 1. THE PRODUCTION SITEMAP CARRIED NO LASTMOD AT ALL. Dates come from git history, and the image build cannot see git: .dockerignore excludes .git and node:alpine has no git binary. prerender.js read the failure into an empty catch commented "git unavailable or file untracked", so all 18 URLs came out undated while the build printed a success line. Local builds looked perfect, which is why nobody caught it. release.sh now computes the map where git exists, passes it as the SITEMAP_LASTMOD build arg, and then asks the built image whether its sitemap has dates, refusing to publish one that does not. prerender prints the count on every run, so "18 URLs, 0 dated" can never again read as success. The route-to-source map moved into scripts/lib/routes.js, where a service page now also counts its own content file, so editing one page's copy moves that page's date and no other. Proven: an image built with the arg carries 18 lastmod entries; a build with git deliberately unreadable and no arg reports "18 URLs, 0 carrying a lastmod" and warns. 2. THE DOCKER BUILD CONTEXT CARRIED CLIENT MATERIAL AND LIVE SECRETS. .drop/, zoho.md (the reCAPTCHA secret and the Zoho tokens), Levi.md and two 30 MB zips were all sent to the daemon on every build, along with four agent workspaces. The final image copies only built output, so none of it ever shipped, but one careless COPY would have changed that. Proven by listing the context from inside a throwaway image: before, all of it; after, none of it. 3. UNTRACKED FILES PASSED SILENTLY. docker build packs the working tree, so an untracked module the code imports produces an image that works and a tag that cannot rebuild it. release.sh now refuses while untracked files are present, and pre-commit's note counts them too. #223 also claimed post-commit hides a refused push. It does not: it printed "push was refused. The commit is safe locally and the branch is now ahead." during this batch. The issue was corrected on the tracker rather than acted on. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-09-10 04:58:37 -05:00
.filter((route) => !route.includes(':') && !route.includes('*'))
.filter((route) => !ROUTES.includes(route)),
feat(build): the copy is checked before a single page is built from it src/data is prose in a data structure, and nothing checked it. The long-form service pages make that dangerous in a specific way: their copy arrives as an owner-approved markdown sheet that MIXES DIRECTIONS TO THE WEBSITE MANAGER INTO THE COPY. "Do not promise that every number is always portable." "Keep this factual:" "Place an official 8x8 Work screenshot beside this section." Those lines look exactly like copy, and publishing one puts an internal instruction on a customer-facing page. scripts/lib/content.js decides whether the content layer is publishable, and prerender.js runs it before rendering anything, so every build enforces it: the pre-commit hook, npm run verify, and the Docker image build. It refuses a website-manager direction, an em dash, a U+FFFD, markdown or an HTML tag left in a string, an unknown block type, a section id that is not letter-first, unique and free of the layout's own ids, a section that does not open with its direct answer (unless it declares kind list or faq), a FAQ question with no answer, a link to a route or fragment that does not exist, an image whose src is missing from public/ or has no alt or no dimensions, and the missing benefits or idealFor list that the short layout maps without checking. A description over 160 characters is a note, not a failure: owner-approved copy is published as written. scripts/lib/routes.js is now the one route list. prerender.js built its own while src/routes.jsx built the router's, and nothing compared them: a route in one and not the other is never prerendered, so the server answers it with 404.html while the site's own navigation links to it. entry-server.jsx exports the router table so the build can compare the two. Proven by mutation, seventeen of them, each expecting exactly one finding and getting it: unknown block type, FAQ answer removed, answer moved below its list, duplicate id, digit-leading id, link to /services/contact-centre, #no-such-section, missing image file, image without dimensions, image without alt, em dash, a manager direction, markdown bold, U+FFFD, an HTML tag, missing h1, empty section. Both generated content modules pass unmutated. Against a real build: an em dash added to industries.js failed npm run build naming the field, and a /pricing route added to src/routes.jsx failed it naming the route. Closes #230. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-09-10 04:43:01 -05:00
),
]
fix(seo): the production sitemap had no dates, and the build context had secrets Three things, all in the path between this repository and the running image. Closes #225, #224 and #223. 1. THE PRODUCTION SITEMAP CARRIED NO LASTMOD AT ALL. Dates come from git history, and the image build cannot see git: .dockerignore excludes .git and node:alpine has no git binary. prerender.js read the failure into an empty catch commented "git unavailable or file untracked", so all 18 URLs came out undated while the build printed a success line. Local builds looked perfect, which is why nobody caught it. release.sh now computes the map where git exists, passes it as the SITEMAP_LASTMOD build arg, and then asks the built image whether its sitemap has dates, refusing to publish one that does not. prerender prints the count on every run, so "18 URLs, 0 dated" can never again read as success. The route-to-source map moved into scripts/lib/routes.js, where a service page now also counts its own content file, so editing one page's copy moves that page's date and no other. Proven: an image built with the arg carries 18 lastmod entries; a build with git deliberately unreadable and no arg reports "18 URLs, 0 carrying a lastmod" and warns. 2. THE DOCKER BUILD CONTEXT CARRIED CLIENT MATERIAL AND LIVE SECRETS. .drop/, zoho.md (the reCAPTCHA secret and the Zoho tokens), Levi.md and two 30 MB zips were all sent to the daemon on every build, along with four agent workspaces. The final image copies only built output, so none of it ever shipped, but one careless COPY would have changed that. Proven by listing the context from inside a throwaway image: before, all of it; after, none of it. 3. UNTRACKED FILES PASSED SILENTLY. docker build packs the working tree, so an untracked module the code imports produces an image that works and a tag that cannot rebuild it. release.sh now refuses while untracked files are present, and pre-commit's note counts them too. #223 also claimed post-commit hides a refused push. It does not: it printed "push was refused. The commit is safe locally and the branch is now ahead." during this batch. The issue was corrected on the tracker rather than acted on. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-09-10 04:58:37 -05:00
// --- sitemap dates -----------------------------------------------------------
//
// The files that produce each page. A page's `lastmod` is the newest commit
// date among them, never the build timestamp: a sitemap that marks every page
// as changed on every deploy is one search engines learn to ignore.
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'],
}
export const sourcesFor = (url) => {
if (ROUTE_SOURCES[url]) return ROUTE_SOURCES[url]
if (url.startsWith('/services/')) {
// A page with owner-approved copy has its own content file, so editing that
// copy moves that page's date and no other.
const slug = url.slice('/services/'.length)
return ['src/pages/ServiceDetail.jsx', 'src/data/services.js', `src/data/serviceContent/${slug}.js`]
}
return ['src/pages/IndustryDetail.jsx', 'src/data/industries.js']
}
const gitLastModified = (files) => {
let newest = null
for (const file of files) {
try {
const iso = execFileSync('git', ['log', '-1', '--format=%cI', '--', file], {
cwd: repoRoot,
encoding: 'utf8',
stdio: ['ignore', 'pipe', 'ignore'],
}).trim()
if (iso && (!newest || iso > newest)) newest = iso
} catch {
// git is unavailable, or the file is untracked. Either way, no date from
// this file. Whether that leaves the ROUTE undated is the caller's
// problem to report, and it must not pass silently: the production
// sitemap carried no dates at all for months because this catch was the
// end of the story.
}
}
return newest ? newest.slice(0, 10) : null
}
/**
* Route to YYYY-MM-DD, for the sitemap.
*
* The image build has no git: `.dockerignore` excludes `.git` and node:alpine
* ships no git binary. So a map computed where git DOES exist can be injected
* through SITEMAP_LASTMOD, which is what scripts/release.sh does.
*/
export const lastModByRoute = () => {
const injected = process.env.SITEMAP_LASTMOD
if (injected) {
try {
const parsed = JSON.parse(injected)
if (parsed && typeof parsed === 'object') return parsed
console.warn('routes: SITEMAP_LASTMOD is not an object, so falling back to git.')
} catch (error) {
console.warn(`routes: SITEMAP_LASTMOD is not valid JSON (${error.message}), so falling back to git.`)
}
}
const dates = {}
for (const route of ROUTES) {
const date = gitLastModified(sourcesFor(route))
if (date) dates[route] = date
}
return dates
}