// Renders every page of a built site (or a running dev server) in Chromium and // fails on the mistakes a static check cannot see: dead internal links, script // errors, duplicate ids, controls that point at ids that do not exist, nested // interactive elements. Images without alt and controls without an accessible // name are reported as warnings. // // Pages come from the site's sitemap.xml. A directory target (preview/dist, // docs/dist) is served locally; an http(s) target is crawled as is. // // Run: pnpm run check-html preview/dist docs/dist // pnpm run check-html http://localhost:3000 import { createReadStream, existsSync, readFileSync, statSync } from 'node:fs' import { createServer, type Server } from 'node:http' import { availableParallelism } from 'node:os' import { extname, join, resolve } from 'node:path' import { chromium, type Browser, type Page } from 'playwright' const MIME: Record = { '.html': 'text/html', '.css': 'text/css', '.js': 'text/javascript', '.mjs': 'text/javascript', '.json': 'application/json', '.svg': 'image/svg+xml', '.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.webp': 'image/webp', '.gif': 'image/gif', '.ico': 'image/x-icon', '.woff2': 'font/woff2', '.woff': 'font/woff', '.ttf': 'font/ttf', '.xml': 'application/xml', '.txt': 'text/plain', '.md': 'text/markdown', '.map': 'application/json', '.mp4': 'video/mp4', '.webm': 'video/webm', '.mp3': 'audio/mpeg', } // Ids generated by libraries at runtime; duplicates there are theirs, not ours. const VENDOR_ID_PREFIXES = ['apexcharts-', 'jvm-', 'SvgjsSvg', 'SvgjsLinearGradient'] // Two pages per core: each page sits idle for the settle delay below, so one // worker per core leaves the machine waiting. Override with CHECK_HTML_WORKERS. const WORKERS = Number(process.env.CHECK_HTML_WORKERS) || Math.min(16, availableParallelism() * 2) // Console noise from third-party demos (WebGL driver messages, Google Maps without a key, plyr). const CONSOLE_IGNORE = [/GL Driver Message/, /Google Maps JavaScript API/, /allowfullscreen/, /web-share/, /No available adapters/, /\[vite\]/, /astro/i] type PageReport = { url: string; errors: string[]; warnings: string[] } const resolveFile = (root: string, urlPath: string): string | null => { const clean = decodeURIComponent(urlPath.split('?')[0].split('#')[0]) // `/x/` and `/x/index.html` also resolve to x.html: Astro's file format // collapses x/index.astro to x.html and the hosting serves it under both. const collapsed = clean.replace(/\/(index\.html)?$/, '') const candidates = [join(root, clean), join(root, `${clean}.html`), join(root, clean, 'index.html'), join(root, `${collapsed}.html`)] for (const c of candidates) { if (existsSync(c) && statSync(c).isFile()) return c } return null } const serve = (root: string): Promise<{ server: Server; base: string }> => new Promise((done) => { const server = createServer((req, res) => { const file = resolveFile(root, req.url ?? '/') if (!file) { res.writeHead(404).end('not found') return } res.writeHead(200, { 'content-type': MIME[extname(file)] ?? 'application/octet-stream' }) if (req.method === 'HEAD') { res.end() return } createReadStream(file).pipe(res) }) server.listen(0, '127.0.0.1', () => { const address = server.address() const port = typeof address === 'object' && address ? address.port : 0 done({ server, base: `http://127.0.0.1:${port}` }) }) }) const sitemapPaths = async (base: string, dir?: string): Promise => { const xml = dir ? readFileSync(join(dir, 'sitemap.xml'), 'utf8') : await (await fetch(`${base}/sitemap.xml`)).text() return [...xml.matchAll(/([^<]+)<\/loc>/g)].map((m) => m[1].replace(/^https?:\/\/[^/]+/, '')).filter((p) => p.startsWith('/')) } // Runs in the page as plain JavaScript (a string, so no bundler helpers such as // esbuild's __name leak into the browser): everything the DOM can tell us. const INSPECT = `(() => { const accName = (el) => { const labelledBy = el.getAttribute('aria-labelledby') if (labelledBy) { const text = labelledBy.split(/\\s+/).map((id) => (document.getElementById(id) || {}).textContent || '').join(' ').trim() if (text) return text } const own = el.getAttribute('aria-label') || el.getAttribute('title') || (el.innerText || '').trim() || (el.textContent || '').trim() if (own) return own return [...el.querySelectorAll('img[alt], [role="img"][aria-label], [aria-label], svg title')].map((e) => e.getAttribute('alt') || e.getAttribute('aria-label') || e.textContent || '').join(' ').trim() } const hidden = (el) => !!el.closest('[aria-hidden="true"]') const ids = new Map() for (const el of document.querySelectorAll('[id]')) ids.set(el.id, (ids.get(el.id) || 0) + 1) const duplicateIds = [...ids].filter(([, n]) => n > 1).map(([id]) => id) const missingTargets = [] const check = (attr, value) => { if (!value) return for (const raw of value.split(/\\s+/)) { const id = raw.replace(/^#/, '') if (!id) continue if (!document.getElementById(id)) missingTargets.push(attr + '="' + raw + '"') } } for (const el of document.querySelectorAll('[data-bs-target^="#"]')) check('data-bs-target', el.getAttribute('data-bs-target')) for (const el of document.querySelectorAll('[data-bs-toggle][href^="#"]')) check('href', el.getAttribute('href')) for (const el of document.querySelectorAll('[aria-controls]')) check('aria-controls', el.getAttribute('aria-controls')) for (const el of document.querySelectorAll('[aria-labelledby]')) check('aria-labelledby', el.getAttribute('aria-labelledby')) for (const el of document.querySelectorAll('[aria-describedby]')) check('aria-describedby', el.getAttribute('aria-describedby')) for (const el of document.querySelectorAll('label[for]')) check('for', el.getAttribute('for')) const short = (e) => e.outerHTML.slice(0, 80).replace(/\\s+/g, ' ') const nested = [...document.querySelectorAll('a a, a button, button a, button button, label label, a [role="button"], button [role="button"]')].map(short) const imgNoAlt = [...document.querySelectorAll('img:not([alt])')].filter((e) => !hidden(e)).map((e) => e.getAttribute('src') || '') const unnamed = [...document.querySelectorAll('a[href], button')].filter((e) => !hidden(e) && !accName(e)).map(short) const links = [...document.querySelectorAll('a[href]')].map((a) => a.getAttribute('href')).filter((h) => !h.startsWith('#') && !/^(https?:|mailto:|tel:|javascript:|data:)/.test(h)) return { duplicateIds: [...new Set(duplicateIds)], missingTargets: [...new Set(missingTargets)], nested, imgNoAlt, unnamed, links } })()` type Inspection = { duplicateIds: string[]; missingTargets: string[]; nested: string[]; imgNoAlt: string[]; unnamed: string[]; links: string[] } const crawl = async (browser: Browser, base: string, paths: string[]) => { const reports: PageReport[] = [] const links = new Map() const queue = [...paths] const context = await browser.newContext({ viewport: { width: 1280, height: 900 } }) const worker = async () => { while (queue.length) { const path = queue.shift()! const page: Page = await context.newPage() const report: PageReport = { url: path, errors: [], warnings: [] } // A page can send the browser off the site, like changelog.html does. What // it lands on is someone else's, so nothing it logs, throws or contains is // ours to report. const onSite = () => page.url().startsWith(base) page.on('pageerror', (e) => { if (onSite()) report.errors.push(`script error: ${String(e).slice(0, 160)}`) }) page.on('console', (m) => { if (m.type() !== 'error' || !onSite()) return const text = m.text() if (!CONSOLE_IGNORE.some((re) => re.test(text))) report.warnings.push(`console: ${text.slice(0, 160)}`) }) page.on('response', (r) => { // /_vercel/ is Vercel's own analytics script, which only exists once deployed there. if (r.status() >= 400 && r.url().startsWith(base) && !r.url().includes('/_vercel/')) report.errors.push(`${r.status()} ${r.url().slice(base.length)}`) }) try { await page.goto(base + path, { waitUntil: 'load', timeout: 60_000 }) await page.waitForTimeout(500) if (onSite()) { const r = await page.evaluate(INSPECT) for (const id of r.duplicateIds) if (!VENDOR_ID_PREFIXES.some((p) => id.startsWith(p))) report.errors.push(`duplicate id "${id}"`) for (const t of r.missingTargets) report.errors.push(`no element for ${t}`) for (const n of r.nested) report.errors.push(`nested interactive: ${n}`) for (const src of r.imgNoAlt) report.warnings.push(`img without alt: ${src}`) for (const html of r.unnamed) report.warnings.push(`no accessible name: ${html}`) for (const href of r.links) { const key = href.split(/[?#]/)[0] if (!links.has(key)) links.set(key, path) } } } catch (e) { report.errors.push(`navigation: ${String(e).slice(0, 160)}`) } reports.push(report) await page.close() } } await Promise.all(Array.from({ length: WORKERS }, worker)) await context.close() for (const [href, from] of links) { const url = href.startsWith('/') ? base + href : new URL(href, base + from).href try { let res = await fetch(url, { method: 'HEAD', redirect: 'manual' }) if (res.status === 405 || res.status === 501) res = await fetch(url, { method: 'GET', redirect: 'manual' }) if (res.status >= 400) reports.push({ url: from, errors: [`dead link ${href} (${res.status})`], warnings: [] }) } catch (e) { reports.push({ url: from, errors: [`dead link ${href} (${String(e).slice(0, 60)})`], warnings: [] }) } } return reports } const main = async () => { const targets = process.argv.slice(2).filter((a) => !a.startsWith('--')) const quiet = process.argv.includes('--quiet') if (targets.length === 0) { console.error('Usage: tsx .build/check-html.ts ...') process.exit(2) } const browser = await chromium.launch() let errorCount = 0 let warningCount = 0 for (const target of targets) { let base = target let server: Server | undefined let dir: string | undefined if (!/^https?:/.test(target)) { dir = resolve(target) ;({ server, base } = await serve(dir)) } const paths = await sitemapPaths(base, dir) const reports = await crawl(browser, base, paths) const errors = reports.filter((r) => r.errors.length) const warnings = reports.filter((r) => r.warnings.length) errorCount += errors.reduce((n, r) => n + r.errors.length, 0) warningCount += warnings.reduce((n, r) => n + r.warnings.length, 0) console.log(`${target}: ${paths.length} pages, ${errors.reduce((n, r) => n + r.errors.length, 0)} errors, ${warnings.reduce((n, r) => n + r.warnings.length, 0)} warnings`) for (const r of errors) for (const e of r.errors) console.log(` ✗ ${r.url}: ${e}`) if (!quiet) for (const r of warnings) for (const w of r.warnings) console.log(` · ${r.url}: ${w}`) server?.close() } await browser.close() if (errorCount > 0) { console.error(`\n${errorCount} error(s), ${warningCount} warning(s)`) process.exit(1) } console.log(`\nOK — no errors, ${warningCount} warning(s)`) } main().catch((e) => { console.error(e) process.exit(1) })