// 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 }