← back to Site Factory
critic/checks/a11y.js
142 lines
// a11y.js — static accessibility scan over .tsx / .html files.
// * <img ...> without alt= → high "missing alt"
// * <button ...> with only an icon child (no text node) → medium "icon button without aria-label"
// * <a href="#" ...> with no aria-label → low
//
// Screenshots: just log a stub — real visual a11y deferred to a future LLM check.
const fs = require('fs');
const path = require('path');
const { glob } = require('glob');
const SOURCE = 'a11y';
const SCAN_EXTS = new Set(['.tsx', '.html']);
async function expandPaths(paths) {
const files = new Set();
for (const p of paths || []) {
if (!p) continue;
let stat;
try { stat = fs.statSync(p); } catch { continue; }
if (stat.isFile()) {
if (SCAN_EXTS.has(path.extname(p))) files.add(p);
} else if (stat.isDirectory()) {
const matches = await glob('**/*.{tsx,html}', {
cwd: p,
ignore: ['**/node_modules/**', '**/.next/**', '**/dist/**', '**/build/**'],
absolute: true,
nodir: true,
});
for (const m of matches) files.add(m);
}
}
return [...files];
}
// crude tag scanners — line-anchored to give callers a useful pointer
function scanImgsMissingAlt(src) {
const out = [];
const re = /<img\b([^>]*)>/gi;
let m;
while ((m = re.exec(src)) != null) {
const attrs = m[1] || '';
if (!/\balt\s*=/.test(attrs)) {
const line = src.slice(0, m.index).split('\n').length;
out.push({ line, snippet: m[0].slice(0, 120) });
}
}
return out;
}
// Match an entire <button ...>...</button> including content; flag if content
// has no plain text node (heuristic: no characters outside tags after stripping
// whitespace and JSX expressions). aria-label / title on the open tag clears it.
function scanIconButtonsMissingLabel(src) {
const out = [];
const re = /<button\b([^>]*)>([\s\S]*?)<\/button>/gi;
let m;
while ((m = re.exec(src)) != null) {
const openAttrs = m[1] || '';
const inner = m[2] || '';
if (/\baria-label\s*=/.test(openAttrs) || /\btitle\s*=/.test(openAttrs)) continue;
// strip nested tags + JSX expression blocks
const stripped = inner
.replace(/<[^>]+>/g, '')
.replace(/\{[^}]*\}/g, '')
.replace(/\s+/g, '');
if (stripped.length === 0) {
const line = src.slice(0, m.index).split('\n').length;
out.push({ line, snippet: m[0].slice(0, 120) });
}
}
return out;
}
function scanHashAnchorsNoLabel(src) {
const out = [];
const re = /<a\b([^>]*)>/gi;
let m;
while ((m = re.exec(src)) != null) {
const attrs = m[1] || '';
const hrefHash = /\bhref\s*=\s*["']#["']/.test(attrs);
const hasLabel = /\baria-label\s*=/.test(attrs);
if (hrefHash && !hasLabel) {
const line = src.slice(0, m.index).split('\n').length;
out.push({ line, snippet: m[0].slice(0, 120) });
}
}
return out;
}
async function run({ paths, screenshots /* , domain */ }) {
const findings = [];
const files = await expandPaths(paths);
for (const file of files) {
let src;
try { src = fs.readFileSync(file, 'utf8'); } catch { continue; }
const base = path.basename(file);
const imgs = scanImgsMissingAlt(src);
if (imgs.length) {
findings.push({
source: SOURCE,
severity: 'high',
title: `Missing alt: ${imgs.length} <img> without alt in ${base}`,
detail: `${file} — line(s) ${imgs.slice(0, 5).map(x => x.line).join(', ')}${imgs.length > 5 ? '…' : ''}.`,
suggested_fix: 'Add an alt="" attribute. Use empty string for decorative images, descriptive text for content images.',
});
}
const iconBtns = scanIconButtonsMissingLabel(src);
if (iconBtns.length) {
findings.push({
source: SOURCE,
severity: 'medium',
title: `Icon-only <button> without aria-label: ${iconBtns.length} in ${base}`,
detail: `${file} — line(s) ${iconBtns.slice(0, 5).map(x => x.line).join(', ')}${iconBtns.length > 5 ? '…' : ''}.`,
suggested_fix: 'Add aria-label="…" describing the action (e.g. "Open menu", "Close panel") so screen-reader users know the button purpose.',
});
}
const hashAs = scanHashAnchorsNoLabel(src);
if (hashAs.length) {
findings.push({
source: SOURCE,
severity: 'low',
title: `<a href="#"> without aria-label: ${hashAs.length} in ${base}`,
detail: `${file} — line(s) ${hashAs.slice(0, 5).map(x => x.line).join(', ')}${hashAs.length > 5 ? '…' : ''}.`,
suggested_fix: 'Replace with a real href, a <button>, or add aria-label so the link has a clear purpose.',
});
}
}
if (Array.isArray(screenshots) && screenshots.length) {
// intentional log only — no finding emitted; real visual a11y comes later
console.log(`[a11y] screenshot a11y deferred to future LLM check (${screenshots.length} screenshot(s) skipped)`);
}
return findings;
}
module.exports = { run };