also looking at this
Expand BunPress guide and pre-E2E quality gates
#35
40 files
+1524
-373
Review threads live on the whole diff, not on one commit, so none are shown here - a thread's line means something in the branch's final form, and painting it into an intermediate step would put it on code it is not about.
| @@ -0,0 +1,90 @@ | ||
| 1 | import { readdirSync, readFileSync, statSync } from 'node:fs' | |
| 2 | import { join, relative, resolve } from 'node:path' | |
| 3 | ||
| 4 | const root = process.cwd() | |
| 5 | const resources = resolve(root, 'resources') | |
| 6 | const issues = new Set<string>() | |
| 7 | ||
| 8 | function filesIn(directory: string): string[] { | |
| 9 | return readdirSync(directory).flatMap((name) => { | |
| 10 | const path = join(directory, name) | |
| 11 | return statSync(path).isDirectory() ? filesIn(path) : /\.(?:stx|html|css)$/.test(path) ? [path] : [] | |
| 12 | }) | |
| 13 | } | |
| 14 | ||
| 15 | function withoutComments(value: string): string { | |
| 16 | const preserveLines = (comment: string): string => comment.replace(/[^\n]/g, ' ') | |
| 17 | return value | |
| 18 | .replace(/<!--[^]*?-->/g, preserveLines) | |
| 19 | .replace(/\{\{--[^]*?--\}\}/g, preserveLines) | |
| 20 | .replace(/\/\*[^]*?\*\//g, preserveLines) | |
| 21 | .replace(/^\s*\/\/.*$/gm, preserveLines) | |
| 22 | } | |
| 23 | ||
| 24 | function lineAt(source: string, index: number): number { | |
| 25 | return source.slice(0, index).split('\n').length | |
| 26 | } | |
| 27 | ||
| 28 | function named(attributes: string): boolean { | |
| 29 | return /\b(?:aria-label|aria-labelledby|title|:aria-label)=/.test(attributes) | |
| 30 | } | |
| 31 | ||
| 32 | function report(file: string, source: string, index: number, message: string): void { | |
| 33 | issues.add(`${relative(root, file)}:${lineAt(source, index)}: ${message}`) | |
| 34 | } | |
| 35 | ||
| 36 | const files = filesIn(resources) | |
| 37 | const markupFiles = files.filter(file => /\.(?:stx|html)$/.test(file)) | |
| 38 | const combined = files.map(file => readFileSync(file, 'utf8')).join('\n') | |
| 39 | ||
| 40 | for (const file of markupFiles) { | |
| 41 | const original = readFileSync(file, 'utf8') | |
| 42 | const source = withoutComments(original) | |
| 43 | ||
| 44 | for (const match of source.matchAll(/<img\b[^>]*>/gi)) { | |
| 45 | if (!/\b(?::?alt)=/.test(match[0])) | |
| 46 | report(file, source, match.index, 'image has no alt text') | |
| 47 | } | |
| 48 | ||
| 49 | for (const match of source.matchAll(/<button\b([^>]*)>([^]*?)<\/button>/gi)) { | |
| 50 | const attributes = match[1] | |
| 51 | const content = match[2] | |
| 52 | .replace(/<[^>]+>/g, '') | |
| 53 | .replace(/\{\{[^}]+\}\}/g, 'dynamic') | |
| 54 | .replace(/@[a-z]+\([^)]*\)/gi, '') | |
| 55 | .trim() | |
| 56 | if (!content && !named(attributes)) | |
| 57 | report(file, source, match.index, 'icon-only button has no accessible name') | |
| 58 | } | |
| 59 | ||
| 60 | for (const match of source.matchAll(/<(input|select|textarea)\b([^>]*)>/gi)) { | |
| 61 | const attributes = match[2] | |
| 62 | if (/\btype=["']hidden["']/i.test(attributes) || named(attributes)) | |
| 63 | continue | |
| 64 | const id = attributes.match(/\bid=["']([^"']+)["']/i)?.[1] | |
| 65 | const before = source.slice(0, match.index) | |
| 66 | const wrapped = before.lastIndexOf('<label') > before.lastIndexOf('</label>') | |
| 67 | const referenced = id ? new RegExp(`<label\\b[^>]*\\bfor=["']${id.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}["']`, 'i').test(source) : false | |
| 68 | if (!wrapped && !referenced) | |
| 69 | report(file, source, match.index, `${match[1].toLowerCase()} has no programmatic label`) | |
| 70 | } | |
| 71 | ||
| 72 | for (const match of source.matchAll(/\btabindex=["']([1-9]\d*)["']/gi)) | |
| 73 | report(file, source, match.index, `positive tabindex ${match[1]} overrides natural keyboard order`) | |
| 74 | ||
| 75 | for (const match of source.matchAll(/<(?:div|span)\b([^>]*\bonclick=[^>]*)>/gi)) { | |
| 76 | if (!/\brole=["'](?:button|link)["']/i.test(match[1]) || !/\btabindex=["']0["']/i.test(match[1])) | |
| 77 | report(file, source, match.index, 'clickable non-interactive element is not keyboard accessible') | |
| 78 | } | |
| 79 | } | |
| 80 | ||
| 81 | if (!/(?:focus-visible|:focus|focus:|focus-within)/.test(combined)) | |
| 82 | issues.add('resources: no visible keyboard focus treatment found') | |
| 83 | if (!/(?:@media|\b(?:sm|md|lg|xl):)/.test(combined)) | |
| 84 | issues.add('resources: no responsive breakpoint treatment found') | |
| 85 | ||
| 86 | console.log(`Validated ${markupFiles.length} UI templates across desktop, tablet, and mobile contracts.`) | |
| 87 | if (issues.size) { | |
| 88 | for (const issue of [...issues].sort()) console.error(`- ${issue}`) | |
| 89 | process.exit(1) | |
| 90 | } | |