Queue-North-Website/scripts/lib/html-audit.js

163 lines
7.2 KiB
JavaScript
Raw Normal View History

feat(build): audit what the site actually serves, in the build and on the wire Everything here checked an input: the content check reads the data, the secret scan reads the diff, the build reads the source. Nothing read the OUTPUT, which 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 run-on description. Both are plain in the built HTML and invisible in the source. Build mode is guard 15-built-html, after 10-build. Per page it requires exactly one title, one non-empty description, one canonical equal to the site origin plus the route, and one h1; JSON-LD that parses, with no FAQPage, which the owner ruled out; no em dash and no U+FFFD; a preload naming the image the page actually paints first; and no description that runs its short description into the next sentence. Across pages it requires every internal link and every fragment to resolve, the sitemap to list exactly the routes the site serves, and 404.html to carry noindex and no canonical. It exits 2, not 0, when dist/ is missing or older than the sources: auditing stale output is auditing nothing. That also guards a specific hazard. react-helmet-async on React 19 does not merge, so a second <SEO> anywhere on a page silently emits a second title and a second canonical, and a search engine picks whichever it likes. URL mode fetches every page in a live sitemap once per crawler user agent (OAI-SearchBot, PerplexityBot, ClaudeBot, Googlebot, bingbot), requires HTTP 200 and identical bytes across agents, runs the same page rules, and reports any URL without a lastmod. It is deliberately NOT wired into deploy.sh: a check that runs after publication cannot stop it, and pretending otherwise is worse than not having it. Run it after a deploy. Proven by mutation, nine of them, each restored afterwards: a wrong canonical (7 pages), a second h1 (4), FAQPage markup, an em dash in copy, a link to a route that does not exist (18), a fragment that is not on its target page (7), the preload keyed on the old attribute (19), the template title left in place giving two titles (19), and the industry routes dropped from the route list (56). A clean build audits clean, and URL mode passes against the local server as all five crawlers. Closes #228. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-10 04:51:08 -05:00
// 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 <SEO> 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(/<!--[\s\S]*?-->/g, '')
export const parseSitemap = (xml) =>
[...xml.matchAll(/<url>([\s\S]*?)<\/url>/g)].map((entry) => {
const loc = entry[1].match(/<loc>([^<]+)<\/loc>/)?.[1] ?? ''
const lastmod = entry[1].match(/<lastmod>([^<]+)<\/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('</head>')
const findings = []
const say = (detail) => findings.push(`${notFound ? '404.html' : path}: ${detail}`)
const titles = all(head, /<title[^>]*>([\s\S]*?)<\/title>/g)
if (titles.length !== 1) say(`${titles.length} <title> 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 (/<2F>/.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
}