163 lines
7.2 KiB
JavaScript
163 lines
7.2 KiB
JavaScript
// 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
|
||
}
|