← 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 };