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) <noreply@anthropic.com>
This commit is contained in:
null 2026-09-10 04:43:01 -05:00
parent a6b87c7123
commit 0d575f2977
7 changed files with 421 additions and 18 deletions

View File

@ -62,6 +62,7 @@ npm install
git config core.hooksPath .githooks # per clone. Not optional. See below
bash scripts/check-env.sh --file .env # what is configured, before anything reads it
bash scripts/secrets.sh --tracked # what is already committed
node scripts/validate-content.js # whether the copy in src/data is publishable
```
`npm run verify` runs that scan too, as guard `20-secrets`, together with

View File

@ -4,8 +4,9 @@
Status: Current
Owner: _null
Last reviewed: 2026-08-18
Governs: docs/architecture/**, server/index.js, scripts/** — the module
boundaries, the database schema and the API response shapes
Governs: docs/architecture/**, server/index.js, scripts/**, src/data/** — the
module boundaries, the content layer, the database schema and the API
response shapes
Review trigger: Any new module, any change to a module boundary or a data shape;
any new table or column; any new external service the server
calls; any change to how routes are prerendered
@ -50,6 +51,22 @@ this is not quite true is the privacy policy, below.
policy text live there as plain data, imported by both the client pages and
`src/entry-server.jsx`. Adding a service is a data edit, not a component edit.
A service may carry a `page` object: owner-approved long-form copy, in
`src/data/serviceContent/<slug>.js`, shaped as `{ seo, hero, sections, related }`
where each section is `{ id, title, kind?, blocks }` and a block is one of `p`,
`h3`, `ul`, `ol`, `callout`, `image` or `links`. A service without one keeps the
short layout, so the two shapes coexist. Content files are **leaf modules**:
they import nothing, because `services.js` imports them and the prerenderer
imports that directly in Node, where Vite's resolution does not exist.
`scripts/lib/content.js` decides whether that data is publishable, and the build
refuses when it is not. It is the only thing standing between the copy sheets'
directions to the website manager ("Place an official screenshot", "Keep this
factual:") and a customer-facing page, since those lines look exactly like copy.
It also holds the rules that make a page quotable: a section opens with its
direct answer, ids start with a letter and are unique, every link resolves, and
no em dash, markdown or HTML tag survives into a string.
**`scripts/prerender.js` is a build step, not a runtime.** It renders every route
to static HTML at build time using `src/entry-server.jsx`. Nothing at request
time renders React on the server.
@ -64,6 +81,10 @@ names its route:
crawler
- a template that already carries a canonical, which means the script is being
run over its own output, since `dist/index.html` is both template and home page
- content that does not hold together, checked by `scripts/lib/content.js`
before a single page renders
- a route the router declares that this build would not produce, which the
server would answer with `404.html`
Rules about what it moves, each bought with a defect:
@ -176,6 +197,8 @@ each row says what it does *here*.
| `scripts/verify.sh` | every check this project has, in one table. Honestly thin — there is no test suite, and it says so rather than printing a green row |
| `scripts/doc-triggers.py` | which documents a pending change fires, read from the `Governs:` headers. Run it before committing, not after |
| `scripts/forgejo-issue.py` | files and closes issues in the tracker convention, refusing malformed ones before they are filed |
| `scripts/validate-content.js` | the content check on its own, for proving it fails and for a fast answer while writing copy. `npm run build` runs the same check inside the prerenderer, so a clean run here is not a substitute for a build |
| `scripts/lib/` | shared, side-effect-free modules: `routes.js` (the one route list, and the drift check against the router's own table) and `content.js` (what must be true of `src/data/**` before a page is built from it) |
| `scripts/status.sh` | what is running on **nebula** as `qn-website-dev`, its version and its restart count. Read-only |
| `scripts/healthcheck.sh` | a liveness tick against `queuenorth.com`, asserting HTTP 200 **and** `"status":"ok"` **and** `"db":"ok"` — a 503 with a JSON body is a real answer, not an outage. `HEALTHCHECK_BASE_URL` points it at the other front door |
| `scripts/preflight.sh` | headers and TLS against the live origin. No `--auth` checks: there are no accounts |

278
scripts/lib/content.js Normal file
View File

@ -0,0 +1,278 @@
// What must be true of the content layer before a page is built from it.
//
// ## Why this exists
//
// `src/data/*.js` is prose in a data structure, and nothing checked it. A
// misspelt block type, a link to a route that does not exist, a section whose
// answer paragraph is missing, a missing `benefits` list that crashes the
// prerender with `Cannot read properties of undefined`: each was found by a
// person looking at a page, or not found at all.
//
// The long-form service pages made that worse in a specific way. Their copy is
// owner-approved and arrives as a 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 screenshot". Those lines
// look exactly like copy. Publishing one puts an internal instruction on a
// customer-facing page, and no build step would have noticed.
//
// So this refuses to build rather than publish. It runs inside prerender.js,
// before any page renders, which means every `npm run build` enforces it: the
// pre-commit hook, `npm run verify`, and the Docker image build.
//
// Exit codes for the runner: 0 clean, 1 findings, 2 nothing was checked.
import { existsSync } from 'fs'
import path from 'path'
import { fileURLToPath } from 'url'
import { ROUTES } from './routes.js'
const publicDir = path.join(path.dirname(fileURLToPath(import.meta.url)), '../../public')
// A block type, and the field it cannot be missing.
const BLOCK_FIELDS = {
p: 'text',
h3: 'text',
ul: 'items',
ol: 'items',
callout: 'text',
image: 'alt',
links: 'items',
}
const ID_SHAPE = /^[a-z][a-z0-9]*(-[a-z0-9]+)*$/
// Ids already used by the layout. A section id that collides with one of these
// makes `#contact-form` scroll to the wrong element.
const RESERVED_IDS = new Set(['contact-form', 'mobile-nav-content', 'support-portal', 'root'])
// Directions to the website manager, which the sheets mix into their copy.
const SHEET_DIRECTIONS = [
/website manager/i,
/not for the (public )?webpage/i,
/place an official/i,
/do not add faq/i,
/this section is (important|highly useful)/i,
/\bQ\d+:/,
/approved visual/i,
/suggested (alt text|caption)/i,
/before publication/i,
/avoid (unsupported|promising|implying)/i,
/link naturally to/i,
/keep this factual/i,
/factual differentiators/i,
/reliability wording/i,
/^direct[- ]answer/i,
]
// Markdown that would print literally on the page, and characters that must not
// reach it. React escapes text, so pasted markup shows up as itself.
const TEXT_FAULTS = [
[/—/, 'an em dash (U+2014), which Null asked for nowhere in site copy'],
[/<2F>/, 'a U+FFFD replacement character, so the source was decoded with the wrong encoding'],
[/\*\*/, 'markdown bold markers'],
[/`/, 'a backtick'],
[/\]\(/, 'a markdown link'],
[/<\/?[a-z][^>]*>/i, 'an HTML tag'],
]
export const validateContent = ({ services, industries }) => {
const errors = []
const warnings = []
let checked = 0
const fail = (where, detail) => errors.push(`${where}: ${detail}`)
// Every string that reaches a page, wherever it sits in the tree.
const checkText = (where, value) => {
if (typeof value !== 'string') return
checked += 1
for (const [pattern, detail] of TEXT_FAULTS) {
if (pattern.test(value)) fail(where, `${detail} in ${JSON.stringify(value.slice(0, 60))}`)
}
for (const pattern of SHEET_DIRECTIONS) {
if (pattern.test(value)) {
fail(where, `reads as a direction to the website manager, not page copy: ${JSON.stringify(value.slice(0, 80))}`)
break
}
}
}
const checkLink = (where, to, ownIds) => {
checked += 1
if (typeof to !== 'string' || !to) return fail(where, 'link has no destination')
const [route, fragment] = to.split('#')
if (route && !ROUTES.includes(route)) fail(where, `links to ${route}, which is not a route this site serves`)
if (fragment && !route && !ownIds.has(fragment) && !RESERVED_IDS.has(fragment)) {
fail(where, `links to #${fragment}, which is not a section on this page`)
}
}
const checkBlocks = (where, section, ownIds) => {
const blocks = section.blocks
if (!Array.isArray(blocks) || blocks.length === 0) return fail(where, 'has no blocks')
blocks.forEach((block, index) => {
const at = `${where} blocks[${index}]`
const field = BLOCK_FIELDS[block?.type]
if (!field) {
return fail(at, `unknown block type ${JSON.stringify(block?.type)}. Known types: ${Object.keys(BLOCK_FIELDS).join(', ')}`)
}
if (block[field] == null) return fail(at, `a ${block.type} block has no ${field}`)
checked += 1
if (block.type === 'p') {
if (Array.isArray(block.text)) {
block.text.forEach((part, i) => {
if (typeof part === 'string') checkText(`${at}.text[${i}]`, part)
else {
checkText(`${at}.text[${i}].text`, part?.text)
checkLink(`${at}.text[${i}]`, part?.to, ownIds)
}
})
} else checkText(`${at}.text`, block.text)
} else if (block.type === 'ul' || block.type === 'ol') {
if (!Array.isArray(block.items) || !block.items.length) fail(at, 'a list with no items')
block.items?.forEach((item, i) => {
if (typeof item === 'string') checkText(`${at}.items[${i}]`, item)
else {
checkText(`${at}.items[${i}].text`, item?.text)
checkText(`${at}.items[${i}].detail`, item?.detail)
if (item?.text == null) fail(`${at}.items[${i}]`, 'a step with no text')
}
})
} else if (block.type === 'links') {
block.items?.forEach((item, i) => {
checkText(`${at}.items[${i}].label`, item?.label)
checkLink(`${at}.items[${i}]`, item?.to, ownIds)
})
} else if (block.type === 'image') {
checkText(`${at}.alt`, block.alt)
checkText(`${at}.caption`, block.caption)
if (!block.alt) fail(at, 'an image with no alt text')
// A slot waiting for a file the owner has to supply carries src: null and
// renders nothing. A src that IS set must point at a file that exists,
// or the page ships a broken image nobody sees until a visitor does.
if (block.src) {
if (!block.src.startsWith('/') || block.src.includes('..')) {
fail(at, `image src ${JSON.stringify(block.src)} must be a site-absolute path under public/`)
} else if (!existsSync(path.join(publicDir, block.src.replace(/^\//, '')))) {
fail(at, `image src ${JSON.stringify(block.src)} does not exist under public/`)
}
if (!block.width || !block.height) fail(at, 'an image with a src needs width and height, or the page shifts as it loads')
}
} else {
checkText(`${at}.text`, block.text)
}
})
// The opening block decides whether an answer engine can quote the section.
const first = blocks[0]?.type
if (section.kind === 'faq') {
blocks.forEach((block, index) => {
if (block.type !== 'h3') return
if (blocks[index + 1]?.type !== 'p') fail(`${where} blocks[${index}]`, 'a FAQ question with no answer paragraph after it')
})
if (first !== 'h3') fail(where, 'a FAQ section must open with its first question')
} else if (section.kind === 'list') {
if (first !== 'ul' && first !== 'ol') fail(where, "kind 'list' says the section opens with a list, and it does not")
} else if (first !== 'p') {
fail(where, `opens with a ${first} block. A section opens with its direct answer, which is what a search result or an AI answer quotes. Use kind 'list' when the approved copy genuinely has no answer paragraph.`)
}
}
const checkPage = (where, page) => {
const ownIds = new Set((page.sections || []).map((section) => section.id))
if (!page.seo?.title) fail(`${where}.page.seo`, 'no title')
if (!page.seo?.description) fail(`${where}.page.seo`, 'no description')
checkText(`${where}.page.seo.title`, page.seo?.title)
checkText(`${where}.page.seo.description`, page.seo?.description)
if (page.seo?.description && page.seo.description.length > 160) {
warnings.push(
`${where}.page.seo.description is ${page.seo.description.length} characters. Search results show about 155 to 160, so the tail may not be seen. Owner-approved copy is published as written.`,
)
}
for (const field of ['h1', 'subheading']) {
if (!page.hero?.[field]) fail(`${where}.page.hero`, `no ${field}`)
checkText(`${where}.page.hero.${field}`, page.hero?.[field])
}
if (!Array.isArray(page.hero?.intro) || !page.hero.intro.length) fail(`${where}.page.hero`, 'no intro copy')
page.hero?.intro?.forEach((text, i) => checkText(`${where}.page.hero.intro[${i}]`, text))
for (const cta of ['primaryCta', 'secondaryCta']) {
const value = page.hero?.[cta]
if (!value) {
if (cta === 'primaryCta') fail(`${where}.page.hero`, 'no primaryCta')
continue
}
checkText(`${where}.page.hero.${cta}.label`, value.label)
checkLink(`${where}.page.hero.${cta}`, value.to, ownIds)
}
if (!Array.isArray(page.sections) || !page.sections.length) return fail(`${where}.page`, 'no sections')
const seen = new Set()
page.sections.forEach((section, index) => {
const at = `${where}.page.sections[${index}]${section?.id ? ` (${section.id})` : ''}`
if (!ID_SHAPE.test(section?.id || '')) {
fail(at, `id ${JSON.stringify(section?.id)} must be lowercase, start with a letter and join words with single hyphens. An id starting with a digit cannot be used as a CSS selector`)
}
if (RESERVED_IDS.has(section?.id)) fail(at, `id ${JSON.stringify(section.id)} is already used by the page layout`)
if (seen.has(section?.id)) fail(at, `id ${JSON.stringify(section.id)} appears twice on this page`)
seen.add(section?.id)
if (!section?.title) fail(at, 'no title')
checkText(`${at}.title`, section?.title)
checkBlocks(at, section, ownIds)
})
page.related?.forEach((link, i) => {
checkText(`${where}.page.related[${i}].label`, link?.label)
checkLink(`${where}.page.related[${i}]`, link?.to, ownIds)
})
}
for (const service of services || []) {
const where = `services/${service?.id}`
for (const field of ['id', 'name', 'shortDesc', 'homeDesc', 'icon']) {
if (!service?.[field]) fail(where, `no ${field}`)
}
checkText(`${where}.shortDesc`, service.shortDesc)
checkText(`${where}.homeDesc`, service.homeDesc)
if (service.page) checkPage(where, service.page)
else {
// The layout used by every service without long-form copy maps these with
// no guard, so a missing one is a build crash with no useful message.
if (!service.fullDesc) fail(where, 'no fullDesc, and no page copy either')
for (const field of ['benefits', 'idealFor']) {
if (!Array.isArray(service[field]) || !service[field].length) fail(where, `no ${field}, which the service layout maps without checking`)
}
checkText(`${where}.fullDesc`, service.fullDesc)
}
// Every service is a card on the Services index, which shows idealFor[0].
if (!Array.isArray(service.idealFor) || !service.idealFor.length) {
fail(where, 'no idealFor. The Services index prints idealFor[0] as "Best fit"')
}
service.related?.forEach((link, i) => {
checkText(`${where}.related[${i}].label`, link?.label)
checkLink(`${where}.related[${i}]`, link?.to, new Set())
})
}
for (const industry of industries || []) {
const where = `industries/${industry?.id}`
for (const field of ['id', 'name', 'shortDesc', 'fullDesc', 'icon']) {
if (!industry?.[field]) fail(where, `no ${field}`)
}
for (const field of ['painPoints', 'solutions']) {
if (!Array.isArray(industry?.[field]) || !industry[field].length) fail(where, `no ${field}, which the industry layout maps without checking`)
}
checkText(`${where}.shortDesc`, industry?.shortDesc)
checkText(`${where}.fullDesc`, industry?.fullDesc)
industry?.related?.forEach((link, i) => {
checkText(`${where}.related[${i}].label`, link?.label)
checkLink(`${where}.related[${i}]`, link?.to, new Set())
})
}
return { errors, warnings, checked }
}

52
scripts/lib/routes.js Normal file
View File

@ -0,0 +1,52 @@
// 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.
import { services } from '../../src/data/services.js'
import { industries } from '../../src/data/industries.js'
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)
.filter((path) => !path.includes(':') && !path.includes('*'))
.filter((path) => !ROUTES.includes(path)),
),
]

View File

@ -18,28 +18,17 @@ import { mkdirSync, readFileSync, writeFileSync } from 'fs'
import path from 'path'
import { fileURLToPath } from 'url'
import { render } from '../dist-ssr/entry-server.js'
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, STATIC_ROUTES, 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')
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}`),
]
// 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.
@ -169,6 +158,32 @@ if (/rel="canonical"/.test(template)) {
)
}
// 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)

View File

@ -0,0 +1,29 @@
#!/usr/bin/env node
//
// The content check, on its own, for proving it fails and for a quick answer
// while writing copy. The build runs the same check inside prerender.js, so a
// clean run here is not a substitute for `npm run build`.
//
// node scripts/validate-content.js
//
// Exit 0 nothing wrong, 1 findings, 2 nothing was checked.
import { services } from '../src/data/services.js'
import { industries } from '../src/data/industries.js'
import { validateContent } from './lib/content.js'
const { errors, warnings, checked } = validateContent({ services, industries })
for (const warning of warnings) console.warn(`content: note: ${warning}`)
if (!services?.length || !industries?.length || checked === 0) {
console.error('content: nothing was checked, which is not a pass. Did the data modules fail to import?')
process.exit(2)
}
if (errors.length) {
console.error(`content: ${errors.length} problem(s):`)
for (const error of errors) console.error(` ${error}`)
process.exit(1)
}
console.log(`content: ${checked} field(s) checked across ${services.length} service(s) and ${industries.length} industr(ies), nothing wrong.`)

View File

@ -8,6 +8,11 @@ import './index.css'
const AppRoutes = () => useRoutes(routes)
// Re-exported so the prerenderer can compare what the router declares against
// what it is about to build. A route in one list and not the other is a page
// the server answers with a 404.
export { routes }
/**
* Renders a single route to static HTML at build time.
* @param {string} url route path, e.g. '/about'