mirror of
https://github.com/tabler/tabler.git
synced 2026-10-12 21:27:06 +04:00
239 lines
11 KiB
TypeScript
239 lines
11 KiB
TypeScript
// 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<string, string> = {
|
|
'.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<string[]> => {
|
|
const xml = dir ? readFileSync(join(dir, 'sitemap.xml'), 'utf8') : await (await fetch(`${base}/sitemap.xml`)).text()
|
|
return [...xml.matchAll(/<loc>([^<]+)<\/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<string, string>()
|
|
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<Inspection>(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 <dist-dir | http://host:port> ...')
|
|
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)
|
|
})
|