diff --git a/docs/TOOLS.md b/docs/TOOLS.md index fa7d15c..67de206 100644 --- a/docs/TOOLS.md +++ b/docs/TOOLS.md @@ -63,6 +63,7 @@ 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 +node scripts/audit-html.js # what the built pages actually say ``` `npm run verify` runs that scan too, as guard `20-secrets`, together with @@ -140,9 +141,10 @@ Run from the repository root. **There is no `npm test`, and that is not an omission in this table.** There is no test runner in the project. `docs/qa/ClaudeQACoverage.md` carries it as a -standing gap — and it is why `npm run release` says out loud that its gate is a -build, a secret scan and a doc-header check rather than pretending those are -tests. +standing gap — and it is why `npm run release` says out loud what its gate +actually is: a build that validates the content layer, an audit of the built +HTML, a secret scan of the tree and the bundle, and a doc-header check. None of +those exercises a form, an API response, or a page in a browser. **`release` and `deploy` are two commands on purpose.** Publishing an image and running it are separate decisions; see `docs/OPERATIONS.md`. A deploy recreates @@ -201,9 +203,12 @@ Exit 2 means playwright was missing or the site was unreachable — nothing was checked, which is not a pass. **`prove-guard.sh` is deliberately absent.** It breaks what a guard protects and -requires the guard to go red. This project has three guards, all shell scripts -that fail visibly, so §1 of `architecture/GUARDS.md` was performed by hand -instead — see `docs/history/DEVELOPMENT_LOG.md` for 2026-08-18. +requires the guard to go red. This project has four guards, all shell scripts +that fail visibly, so §1 of `architecture/GUARDS.md` is performed by hand +instead: see `docs/history/DEVELOPMENT_LOG.md` for 2026-08-18 and 2026-09-10. +The four are `10-build` (which also runs the content check and the route-drift +check inside the prerenderer), `15-built-html`, `20-secrets` (tracked tree and +bundle) and `30-doc-headers`. ## Adding a script diff --git a/docs/architecture/GUARDS.md b/docs/architecture/GUARDS.md index a571fba..2479806 100644 --- a/docs/architecture/GUARDS.md +++ b/docs/architecture/GUARDS.md @@ -12,7 +12,7 @@ Review trigger: A guard is found to have been passing while the thing it guards > **prove-guard.sh is not in this repository.** It lives in the template at > `~/.openclaw/Projects/Template/docs/architecture/scripts/`, and this project -> declined it on adoption — its guards are three shell scripts in +> declined it on adoption — its guards are four shell scripts in > `scripts/verify.d/` that fail visibly on their own. It is named without > backticks throughout for that reason. §1 below still applies and was performed > by hand on every guard here; `docs/history/DEVELOPMENT_LOG.md` for 2026-08-18 diff --git a/docs/architecture/README.md b/docs/architecture/README.md index 492708b..7f6c769 100644 --- a/docs/architecture/README.md +++ b/docs/architecture/README.md @@ -203,6 +203,7 @@ 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/audit-html.js` | what the site actually serves: over `dist/` as guard `15-built-html`, and with `--url` against a live origin once per crawler user agent. The URL run is a check to make after a deploy, not a gate | | `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 | diff --git a/scripts/audit-html.js b/scripts/audit-html.js new file mode 100644 index 0000000..f7cbbe6 --- /dev/null +++ b/scripts/audit-html.js @@ -0,0 +1,178 @@ +#!/usr/bin/env node +// +// Audits what the site actually serves, in two modes. +// +// node scripts/audit-html.js # dist/, run as guard 15-built-html +// node scripts/audit-html.js --url https://queuenorth.com +// node scripts/audit-html.js --url http://localhost:3001 --agents Googlebot +// +// Build mode is a gate: it reads dist/ and refuses on a finding. URL mode +// fetches every page in the live sitemap once per crawler user agent and reports +// what those crawlers actually receive. URL mode is a check to run AFTER a +// deploy, deliberately not wired into deploy.sh: a check that runs after +// publication cannot stop it, and pretending otherwise is worse than not having +// it (GUARDS.md rule 6). +// +// Exit 0 clean, 1 findings, 2 nothing was audited. +import { existsSync, readFileSync, readdirSync, statSync } from 'fs' +import path from 'path' +import { fileURLToPath } from 'url' +import { services } from '../src/data/services.js' +import { industries } from '../src/data/industries.js' +import { auditLinks, auditPage, auditSitemap, parseSitemap } from './lib/html-audit.js' +import { ROUTES } from './lib/routes.js' + +const root = path.join(path.dirname(fileURLToPath(import.meta.url)), '..') +const distDir = path.join(root, 'dist') + +// The crawlers this site is written for. A spoofed agent string is not the real +// crawler, and a "verified bots only" rule at the edge would answer this with a +// 403 while serving the real one, so treat a pass as evidence the ORIGIN is not +// blocking, not as proof the crawler is happy. +const AGENTS = { + 'OAI-SearchBot': 'Mozilla/5.0 (compatible; OAI-SearchBot/1.0; +https://openai.com/searchbot)', + PerplexityBot: 'Mozilla/5.0 (compatible; PerplexityBot/1.0; +https://perplexity.ai/perplexitybot)', + ClaudeBot: 'Mozilla/5.0 (compatible; ClaudeBot/1.0; +claudebot@anthropic.com)', + Googlebot: 'Mozilla/5.0 (compatible; Googlebot/2.1; +http://www.google.com/bot.html)', + bingbot: 'Mozilla/5.0 (compatible; bingbot/2.0; +http://www.bing.com/bingbot.htm)', +} + +const argv = process.argv.slice(2) +const flag = (name) => { + const at = argv.indexOf(`--${name}`) + return at === -1 ? null : argv[at + 1] +} +const origin = flag('url') +const agents = (flag('agents')?.split(',') ?? Object.keys(AGENTS)).filter((name) => AGENTS[name]) + +const descriptionSource = (routePath) => { + const service = services.find((item) => routePath === `/services/${item.id}`) + if (service) return { shortDesc: service.shortDesc, approvedDescription: Boolean(service.page?.seo?.description) } + const industry = industries.find((item) => routePath === `/industries/${item.id}`) + if (industry) return { shortDesc: industry.shortDesc, approvedDescription: false } + return { shortDesc: null, approvedDescription: false } +} + +const report = (findings, checked, what) => { + if (findings.length) { + console.error(`audit: ${findings.length} finding(s) in ${what}:`) + for (const finding of findings) console.error(` ${finding}`) + process.exit(1) + } + console.log(`audit: ${checked} page(s) in ${what}, nothing wrong.`) +} + +// --- build mode -------------------------------------------------------------- + +const newestSourceMtime = () => { + let newest = 0 + const walk = (dir) => { + for (const entry of readdirSync(dir, { withFileTypes: true })) { + const full = path.join(dir, entry.name) + if (entry.isDirectory()) walk(full) + else newest = Math.max(newest, statSync(full).mtimeMs) + } + } + for (const dir of ['src', 'public', 'scripts/lib']) walk(path.join(root, dir)) + for (const file of ['index.html', 'scripts/prerender.js']) newest = Math.max(newest, statSync(path.join(root, file)).mtimeMs) + return newest +} + +const auditBuild = () => { + const sitemapPath = path.join(distDir, 'sitemap.xml') + if (!existsSync(sitemapPath)) { + console.error('audit: dist/sitemap.xml is missing, so the build never finished and NOTHING was audited. Run npm run build.') + process.exit(2) + } + // The prerender writes the sitemap last. A source file newer than it means + // dist/ is stale, and auditing stale output is auditing nothing. + if (newestSourceMtime() > statSync(sitemapPath).mtimeMs) { + console.error('audit: dist/ is older than the sources, so NOTHING was audited. Run npm run build.') + process.exit(2) + } + + const entries = parseSitemap(readFileSync(sitemapPath, 'utf8')) + const findings = [...auditSitemap(entries)] + const pages = [] + + for (const routePath of ROUTES) { + const file = routePath === '/' ? path.join(distDir, 'index.html') : path.join(distDir, routePath, 'index.html') + if (!existsSync(file)) { + findings.push(`${routePath}: the build produced no page, so the server would answer it with 404.html`) + continue + } + const html = readFileSync(file, 'utf8') + pages.push([routePath, html]) + findings.push(...auditPage(html, { path: routePath, ...descriptionSource(routePath) })) + } + + const notFound = path.join(distDir, '404.html') + if (!existsSync(notFound)) findings.push('404.html: missing from the build') + else findings.push(...auditPage(readFileSync(notFound, 'utf8'), { path: '/404', notFound: true })) + + findings.push(...auditLinks(pages, { fileExists: (href) => existsSync(path.join(distDir, href.replace(/^\//, ''))) })) + + report(findings, pages.length, 'the build') +} + +// --- url mode ---------------------------------------------------------------- + +const fetchAs = async (url, agent) => { + const response = await fetch(url, { + headers: { 'User-Agent': AGENTS[agent] }, + redirect: 'manual', + signal: AbortSignal.timeout(20000), + }) + return { status: response.status, body: await response.text() } +} + +const auditOrigin = async () => { + let entries + try { + const response = await fetch(`${origin}/sitemap.xml`, { signal: AbortSignal.timeout(20000) }) + if (!response.ok) throw new Error(`HTTP ${response.status}`) + entries = parseSitemap(await response.text()) + } catch (error) { + console.error(`audit: could not read ${origin}/sitemap.xml (${error.message}), so NOTHING was audited.`) + process.exit(2) + } + if (!entries.length) { + console.error(`audit: ${origin}/sitemap.xml lists no pages, so NOTHING was audited.`) + process.exit(2) + } + + const findings = [...auditSitemap(entries, { requireLastmod: true })] + const pages = [] + + for (const entry of entries) { + const url = `${origin}${entry.path}` + const bodies = new Map() + for (const agent of agents) { + let result + try { + result = await fetchAs(url, agent) + } catch (error) { + findings.push(`${entry.path}: ${agent} could not fetch it (${error.message})`) + continue + } + if (result.status !== 200) findings.push(`${entry.path}: ${agent} got HTTP ${result.status}`) + bodies.set(agent, result.body) + } + + const distinct = new Set(bodies.values()) + if (distinct.size > 1) { + findings.push(`${entry.path}: crawlers were served different bytes (${bodies.size} agents, ${distinct.size} versions)`) + } + const body = bodies.values().next().value + if (body) { + pages.push([entry.path, body]) + findings.push(...auditPage(body, { path: entry.path, ...descriptionSource(entry.path) })) + } + } + + findings.push(...auditLinks(pages, { fileExists: () => true })) + report(findings, pages.length, `${origin} as ${agents.length} crawler(s)`) +} + +if (origin) await auditOrigin() +else auditBuild() diff --git a/scripts/lib/html-audit.js b/scripts/lib/html-audit.js new file mode 100644 index 0000000..e404e50 --- /dev/null +++ b/scripts/lib/html-audit.js @@ -0,0 +1,162 @@ +// What must be true of a page after it is built, and again after it is served. +// +// ## Why this exists +// +// Everything else in this repository checks an input: the content check reads +// the data, the secret scan reads the diff, the build reads the source. Nothing +// read the OUTPUT, and the output is the only thing a visitor or a crawler ever +// sees. Two live defects made the case: every page preloaded the wrong image for +// months, and eleven pages shipped a description that read as one run-on +// sentence. Both are obvious in the built HTML and invisible in the source. +// +// It also guards a specific hazard. react-helmet-async on React 19 does not +// merge: a second anywhere on a page silently emits a second title and a +// second canonical, and search engines pick whichever they like. +// +// ## Two modes, one set of rules +// +// Build mode reads dist/ and is a gate. URL mode fetches a live origin once per +// crawler user agent and is a check to run after a deploy, not a gate, because a +// check that runs after publication cannot stop it. +import { SITE_URL } from '../../src/lib/seo.js' +import { ROUTES } from './routes.js' + +// React writes its own text separators as comments, and the index.html template +// carries a commented-out preload. Anything counting tags must strip comments +// first or it will count that one. +export const stripComments = (html) => html.replace(//g, '') + +export const parseSitemap = (xml) => + [...xml.matchAll(/([\s\S]*?)<\/url>/g)].map((entry) => { + const loc = entry[1].match(/([^<]+)<\/loc>/)?.[1] ?? '' + const lastmod = entry[1].match(/([^<]+)<\/lastmod>/)?.[1] ?? null + let path = '/' + try { + path = new URL(loc).pathname + } catch { + path = loc + } + return { loc, path, lastmod } + }) + +const all = (html, pattern) => [...html.matchAll(pattern)] + +/** + * Every rule that applies to one page. + * @returns {string[]} findings, each a sentence naming what is wrong + */ +export const auditPage = (rawHtml, { path, notFound = false, shortDesc = null, approvedDescription = false }) => { + const html = stripComments(rawHtml) + const [head = '', body = ''] = html.split('') + const findings = [] + const say = (detail) => findings.push(`${notFound ? '404.html' : path}: ${detail}`) + + const titles = all(head, /]*>([\s\S]*?)<\/title>/g) + if (titles.length !== 1) say(`${titles.length} tags in <head>, expected exactly 1`) + else if (!titles[0][1].trim()) say('an empty <title>') + + const descriptions = all(head, /<meta name="description" content="([^"]*)"/g) + if (descriptions.length !== 1) say(`${descriptions.length} meta descriptions, expected exactly 1`) + else if (!descriptions[0][1].trim()) say('an empty meta description') + + const canonicals = all(head, /<link rel="canonical" href="([^"]+)"/g) + if (notFound) { + if (canonicals.length) say('a canonical, which a 404 must not claim') + if (!/name="robots" content="[^"]*noindex/.test(head)) say('no noindex, so the 404 page invites indexing') + } else if (canonicals.length !== 1) { + say(`${canonicals.length} canonicals, expected exactly 1`) + } else { + const expected = `${SITE_URL}${path === '/' ? '' : path}` + if (canonicals[0][1] !== expected) say(`canonical is ${canonicals[0][1]}, expected ${expected}`) + } + + const h1s = all(body, /<h1[\s>]/g) + if (h1s.length !== 1) say(`${h1s.length} <h1> tags, expected exactly 1`) + + for (const block of all(html, /<script[^>]*application\/ld\+json[^>]*>([\s\S]*?)<\/script>/g)) { + try { + const parsed = JSON.parse(block[1]) + const types = JSON.stringify(parsed).match(/"@type":"([A-Za-z]+)"/g) || [] + // The approved sheets are explicit: the FAQ is visible page copy and gets + // no FAQPage markup. + if (types.some((type) => type.includes('FAQPage'))) say('FAQPage structured data, which the owner ruled out') + } catch (error) { + say(`structured data that does not parse: ${error.message}`) + } + } + + if (/—/.test(html)) say('an em dash, which Null asked for nowhere a visitor or crawler reads') + if (/�/.test(html)) say('a U+FFFD replacement character, so something was decoded with the wrong encoding') + + // The preload must name the image the page actually paints first. + const hero = body.match(/<img\b[^>]*\bfetchpriority="high"[^>]*>/i)?.[0] + const heroSrc = hero?.match(/\bsrc="([^"]+)"/i)?.[1] + const preloads = all(head, /<link rel="preload"[^>]*as="image"[^>]*href="([^"]+)"/g) + if (heroSrc) { + if (preloads.length !== 1) say(`${preloads.length} image preloads, expected exactly 1 for ${heroSrc}`) + else if (preloads[0][1] !== heroSrc) say(`preloads ${preloads[0][1]} while the hero image is ${heroSrc}`) + } else if (preloads.length) { + say(`preloads ${preloads[0][1]} while the page paints no high-priority image`) + } + + // The run-on this project shipped for months: a short description followed + // straight by the next sentence with no full stop between them. + if (shortDesc && !approvedDescription && descriptions.length === 1) { + const stripped = shortDesc.replace(/\s+/g, ' ').trim() + const description = descriptions[0][1].replace(/\s+/g, ' ') + if (description.includes(`${stripped} `) && !description.includes(`${stripped}. `)) { + say('the description runs its short description into the next sentence with no full stop') + } + } + + return findings +} + +/** ids and internal links, across every page at once. */ +export const auditLinks = (pages, { fileExists = () => true } = {}) => { + const findings = [] + const idsByPath = new Map() + for (const [path, html] of pages) { + idsByPath.set(path, new Set([...stripComments(html).matchAll(/\bid="([^"]+)"/g)].map((m) => m[1]))) + } + + for (const [path, html] of pages) { + const body = stripComments(html).split('</head>')[1] ?? '' + const seen = new Set() + for (const match of all(body, /href="(\/[^"]*)"/g)) { + const href = match[1] + if (seen.has(href)) continue + seen.add(href) + const [route, fragment] = href.split('#') + const target = route === '' ? path : route + + if (route && !ROUTES.includes(route)) { + // Not a route, so it must be a file the build actually emits. + if (!fileExists(route)) findings.push(`${path}: links to ${route}, which is neither a route nor a file in the build`) + continue + } + if (fragment) { + const ids = idsByPath.get(target) + if (ids && !ids.has(fragment)) findings.push(`${path}: links to ${href}, and #${fragment} is not on that page`) + } + } + } + return findings +} + +/** The sitemap must list exactly the routes this site serves. */ +export const auditSitemap = (entries, { requireLastmod = false } = {}) => { + const findings = [] + const listed = new Set(entries.map((entry) => entry.path)) + for (const route of ROUTES) if (!listed.has(route)) findings.push(`sitemap.xml: does not list ${route}`) + for (const path of listed) if (!ROUTES.includes(path)) findings.push(`sitemap.xml: lists ${path}, which is not a route`) + if (requireLastmod) { + const undated = entries.filter((entry) => !entry.lastmod).length + if (undated) { + findings.push( + `sitemap.xml: ${undated} of ${entries.length} URLs carry no lastmod, so search engines cannot tell what changed`, + ) + } + } + return findings +} diff --git a/scripts/release.sh b/scripts/release.sh index b07f2c8..075b6cd 100755 --- a/scripts/release.sh +++ b/scripts/release.sh @@ -315,9 +315,11 @@ if ! bash scripts/verify.sh; then exit 1 fi -say "NOTE: those guards are a build, a secret scan and a doc-header check." -say " There is no test suite in this repository, so nothing above" -say " exercised a single route, form or API response." +say "NOTE: those guards are a build (which validates the content layer), an" +say " audit of the built HTML, a secret scan of the tree and the bundle," +say " and a doc-header check. There is no test suite in this repository," +say " so nothing above exercised a form or an API response, and nothing" +say " loaded a page in a browser." # --------------------------------------------------------------------------- # Build, verify what came out, then push. Nothing is committed until all three diff --git a/scripts/verify.d/15-built-html b/scripts/verify.d/15-built-html new file mode 100755 index 0000000..ad2f9d3 --- /dev/null +++ b/scripts/verify.d/15-built-html @@ -0,0 +1,21 @@ +#!/usr/bin/env bash +# +# What the site actually serves, checked after it is built. +# +# Every other guard here reads an input: the content check reads the data, the +# secret scan reads the diff, the build reads the source. This one reads the +# OUTPUT, which is the only thing a visitor or a crawler ever sees. Two live +# defects made the case for it: every page preloaded the wrong image for months, +# and eleven pages shipped a description that read as one run-on sentence. Both +# are plain in the built HTML and invisible in the source. +# +# It sorts after 10-build on purpose: there is nothing to read until the build +# has run, and it refuses (exit 2) rather than pass when dist/ is missing or +# older than the sources. +# +# Exit 0 clean, 1 findings, 2 nothing was audited. +set -uo pipefail +cd "$(git rev-parse --show-toplevel)" || exit 1 + +[ -f scripts/audit-html.js ] || { echo "audit: scripts/audit-html.js is missing, so nothing was audited." >&2; exit 2; } +node scripts/audit-html.js