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:
parent
a6b87c7123
commit
0d575f2977
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 |
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
}
|
||||
|
|
@ -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)),
|
||||
),
|
||||
]
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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.`)
|
||||
|
|
@ -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'
|
||||
|
|
|
|||
Loading…
Reference in New Issue