From 0d575f2977d505640cc010ba9301f419e65e264e Mon Sep 17 00:00:00 2001 From: null Date: Thu, 10 Sep 2026 04:43:01 -0500 Subject: [PATCH] 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) --- docs/TOOLS.md | 1 + docs/architecture/README.md | 27 +++- scripts/lib/content.js | 278 ++++++++++++++++++++++++++++++++++++ scripts/lib/routes.js | 52 +++++++ scripts/prerender.js | 47 +++--- scripts/validate-content.js | 29 ++++ src/entry-server.jsx | 5 + 7 files changed, 421 insertions(+), 18 deletions(-) create mode 100644 scripts/lib/content.js create mode 100644 scripts/lib/routes.js create mode 100644 scripts/validate-content.js diff --git a/docs/TOOLS.md b/docs/TOOLS.md index 7607131..fa7d15c 100644 --- a/docs/TOOLS.md +++ b/docs/TOOLS.md @@ -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 diff --git a/docs/architecture/README.md b/docs/architecture/README.md index a25ddf5..b8d420b 100644 --- a/docs/architecture/README.md +++ b/docs/architecture/README.md @@ -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/.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 | diff --git a/scripts/lib/content.js b/scripts/lib/content.js new file mode 100644 index 0000000..668f230 --- /dev/null +++ b/scripts/lib/content.js @@ -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'], + [/�/, '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 } +} diff --git a/scripts/lib/routes.js b/scripts/lib/routes.js new file mode 100644 index 0000000..ddbf29e --- /dev/null +++ b/scripts/lib/routes.js @@ -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)), + ), +] diff --git a/scripts/prerender.js b/scripts/prerender.js index caac327..f89015c 100644 --- a/scripts/prerender.js +++ b/scripts/prerender.js @@ -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) diff --git a/scripts/validate-content.js b/scripts/validate-content.js new file mode 100644 index 0000000..006b61f --- /dev/null +++ b/scripts/validate-content.js @@ -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.`) diff --git a/src/entry-server.jsx b/src/entry-server.jsx index 913a316..54d6c77 100644 --- a/src/entry-server.jsx +++ b/src/entry-server.jsx @@ -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'