blob: 838dc8390e0eb4d32b9a87314635169954731111 [file]
#!/usr/bin/env node
/**
* Workspace accessibility audit. This script is the executable source of
* truth for the static checks below.
*
* Walks all .tsx source under apps/ and packages/ (excluding __tests__/,
* dist/, node_modules/) and flags common accessibility regressions:
*
* 1. **Icon-only buttons** — `<button>` whose only children are
* self-closing JSX elements (e.g. `<Icon />`) and which lack
* `aria-label` or `aria-labelledby`. These are unannounceable to
* screen readers.
*
* 2. **Positive tabIndex** — `tabIndex={N}` with N > 0. Positive
* tabIndex breaks the natural DOM tab order; UI gate § 3.2 bans it.
*
* Like `check-console.mjs`, this script lives outside ESLint to keep
* tooling surface minimal. Run via `pretest` hook in @maka/desktop.
*
* Future rules should be added to the RULES array without changing the
* caller contract (e.g. icon-only links, dialog labels, input labels).
*
* To add a legitimate exception, add a `// a11y-allow: <reason>` comment
* on the same line as the offending element. The allow-list is in-source,
* not in this file, so reviewers see the justification next to the code.
*/
import { readdir, readFile } from 'node:fs/promises';
import { join, relative } from 'node:path';
import { fileURLToPath } from 'node:url';
const REPO_ROOT = fileURLToPath(new URL('..', import.meta.url));
const ROOTS = ['apps', 'packages'];
const EXTS = new Set(['.tsx']);
// `primitives/` holds vendored upstream primitive source components that ship with
// English aria-labels. They're rewritten with Chinese labels when
// each consumer surface wires them up, so the a11y walker treats
// them like third-party source that doesn't run through the same
// gate. The wrappers in `./ui.tsx` and the call sites themselves
// remain subject to the full check.
const SKIP_DIRS = new Set(['node_modules', 'dist', '__tests__', 'primitives']);
async function walk(root) {
const out = [];
async function visit(dir) {
let entries;
try {
entries = await readdir(dir, { withFileTypes: true });
} catch {
return;
}
for (const entry of entries) {
if (SKIP_DIRS.has(entry.name)) continue;
const full = join(dir, entry.name);
if (entry.isDirectory()) {
await visit(full);
} else if (entry.isFile()) {
const dot = entry.name.lastIndexOf('.');
const ext = dot >= 0 ? entry.name.slice(dot) : '';
if (EXTS.has(ext)) out.push(full);
}
}
}
await visit(root);
return out;
}
// Each rule is { name, scan(line, prevLines, nextLines) -> message | null }.
// Lines are checked one at a time with a small lookahead/lookbehind for
// multi-line opening tags. `// a11y-allow: <reason>` on the same line
// silences the rule for that line.
const RULES = [
{
name: 'icon-only-button',
/**
* Catches `<button … >` whose immediate children are only self-closing
* JSX elements (typically icons). Skips:
* - buttons with children that include text
* - buttons with aria-label / aria-labelledby
* - lines containing `// a11y-allow:`
*/
scan(text) {
const offenders = [];
// Strip line comments first — they may contain text that looks like JSX.
const stripped = text.replace(/\/\/.*$/gm, '');
// Match `<button …>` opening tag (single-line for now; multi-line
// tags get less coverage but we accept that for the gate).
const BUTTON_OPEN_RE = /<button\b([^>]*)>/gi;
let match;
while ((match = BUTTON_OPEN_RE.exec(stripped))) {
const attrs = match[1] ?? '';
const start = match.index + match[0].length;
const close = stripped.indexOf('</button>', start);
if (close < 0) continue;
const body = stripped.slice(start, close);
// a11y-allow comment in the same opening tag → skip
const fullDecl = stripped.slice(Math.max(0, match.index - 200), close);
if (/\/\/\s*a11y-allow:/.test(fullDecl)) continue;
// Has aria-label or aria-labelledby → OK
if (/\baria-label(?:ledby)?\s*=/.test(attrs)) continue;
// Has visible text content? Two checks (either passes):
// (a) Plain text after stripping nested JSX tags
// (b) Any string literal inside `{...}` expressions (e.g.
// `{busy ? '保存中…' : '保存'}`) — common pattern for
// conditional text labels.
const textOnly = body
.replace(/<[^>]+>/g, '')
.replace(/\{[^}]*\}/g, '')
.trim();
if (textOnly.length > 0 && /[a-zA-Z一-鿿]/.test(textOnly)) continue;
if (/['"`][^'"`]*[a-zA-Z一-鿿][^'"`]*['"`]/.test(body)) continue;
// Has a {label} expression child like {label} {someText}? Accept —
// we can't statically check that the expression resolves to text,
// but it's common-enough that ruling it out has too many false positives.
if (/\{[^{}]*(label|name|text|title|alt|description)/i.test(body)) continue;
const lineIndex = text.slice(0, match.index).split('\n').length;
offenders.push({ line: lineIndex, snippet: match[0].trim() });
}
return offenders;
},
},
{
name: 'positive-tabindex',
scan(text) {
const offenders = [];
const POSITIVE_TABINDEX = /\btabIndex\s*=\s*\{?(\d+)\}?/g;
let match;
while ((match = POSITIVE_TABINDEX.exec(text))) {
const value = Number(match[1]);
if (!Number.isFinite(value) || value <= 0) continue;
// a11y-allow comment on same line → skip
const lineStart = text.lastIndexOf('\n', match.index) + 1;
const lineEnd = text.indexOf('\n', match.index);
const line = text.slice(lineStart, lineEnd < 0 ? text.length : lineEnd);
if (/\/\/\s*a11y-allow:/.test(line)) continue;
const lineIndex = text.slice(0, match.index).split('\n').length;
offenders.push({ line: lineIndex, snippet: match[0].trim() });
}
return offenders;
},
},
{
name: 'dialog-missing-label',
/**
* Catches elements with `role="dialog"` that lack a label
* (`aria-label` or `aria-labelledby`). Without a label, screen
* readers announce "dialog" with no context.
*/
scan(text) {
const offenders = [];
const DIALOG_RE = /<(\w+)\b([^>]*\brole\s*=\s*["']dialog["'][^>]*)>/g;
let match;
while ((match = DIALOG_RE.exec(text))) {
const attrs = match[2] ?? '';
// a11y-allow comment within the same element opening
const before = text.slice(Math.max(0, match.index - 200), match.index + match[0].length);
if (/\/\/\s*a11y-allow:/.test(before)) continue;
if (/\baria-label(?:ledby)?\s*=/.test(attrs)) continue;
const lineIndex = text.slice(0, match.index).split('\n').length;
offenders.push({ line: lineIndex, snippet: match[0].trim().slice(0, 120) });
}
return offenders;
},
},
{
name: 'input-missing-label',
/**
* Catches `<input>` and `<textarea>` elements that have no
* accessible name. An input is OK when:
* - it has `aria-label` / `aria-labelledby`
* - it has `placeholder` (weak, but Maka relies on it for
* several search/proxy fields — not WCAG AA but common)
* - it's a hidden / file / image / submit / reset / button type
* (those self-label or are non-interactive in this sense)
* - it's directly inside a `<label>` element (parent label
* associates implicitly)
*
* The detection is structural-regex; missing edge cases are
* acceptable false negatives.
*/
scan(text) {
const offenders = [];
// JSX arrow functions `(event) => ...` contain `>` which breaks
// greedy `[^>]*` attribute capture — the regex would stop at the
// arrow's `>` instead of the tag's `>`. Pre-replace `=>` with
// `=≫` (U+226B "much greater-than") so the `>` is no longer
// present. Both are single UTF-16 code units so character
// indices stay aligned with the original text.
const safe = text.replace(/=>/g, '=≫');
// Don't strip comments — same line-offset bug we hit on
// english-aria-label (PR-IR-05). String attributes can't
// legitimately contain `//` anyway.
const INPUT_RE = /<(input|textarea)\b([^>]*?)\/?>/g;
let match;
while ((match = INPUT_RE.exec(safe))) {
const attrs = match[2] ?? '';
// a11y-allow on same line
const lineStart = text.lastIndexOf('\n', match.index) + 1;
const lineEnd = text.indexOf('\n', match.index);
const line = text.slice(lineStart, lineEnd < 0 ? text.length : lineEnd);
if (/\/\/\s*a11y-allow:/.test(line)) continue;
// aria-label / aria-labelledby → OK
if (/\baria-label(?:ledby)?\s*=/.test(attrs)) continue;
// placeholder → soft OK (placeholders are commonly the only
// label for Maka's quick-text inputs; we mark them as weakly
// labeled but don't flag)
if (/\bplaceholder\s*=/.test(attrs)) continue;
// hidden / file / image / submit / reset / button input types
const typeMatch = attrs.match(/\btype\s*=\s*["']([^"']+)["']/);
if (typeMatch && /^(hidden|file|image|submit|reset|button)$/i.test(typeMatch[1])) continue;
// Check if a parent <label> wraps this input — look at the
// 600 chars before the match and see if any <label> opens
// without closing first.
const lookBack = text.slice(Math.max(0, match.index - 600), match.index);
const lastLabelOpen = lookBack.lastIndexOf('<label');
const lastLabelClose = lookBack.lastIndexOf('</label>');
if (lastLabelOpen > lastLabelClose) continue;
const lineIndex = text.slice(0, match.index).split('\n').length;
offenders.push({ line: lineIndex, snippet: match[0].trim().slice(0, 120) });
}
return offenders;
},
},
{
name: 'icon-only-link',
/**
* Same shape as icon-only-button but for `<a href>` links — an
* anchor with only icon children needs an aria-label so AT can
* announce it.
*/
scan(text) {
const offenders = [];
const stripped = text.replace(/\/\/.*$/gm, '');
const ANCHOR_OPEN_RE = /<a\s+([^>]*\bhref\s*=[^>]*)>/g;
let match;
while ((match = ANCHOR_OPEN_RE.exec(stripped))) {
const attrs = match[1] ?? '';
const start = match.index + match[0].length;
const close = stripped.indexOf('</a>', start);
if (close < 0) continue;
const body = stripped.slice(start, close);
const fullDecl = stripped.slice(Math.max(0, match.index - 200), close);
if (/\/\/\s*a11y-allow:/.test(fullDecl)) continue;
if (/\baria-label(?:ledby)?\s*=/.test(attrs)) continue;
const textOnly = body
.replace(/<[^>]+>/g, '')
.replace(/\{[^}]*\}/g, '')
.trim();
if (textOnly.length > 0 && /[a-zA-Z一-鿿]/.test(textOnly)) continue;
if (/['"`][^'"`]*[a-zA-Z一-鿿][^'"`]*['"`]/.test(body)) continue;
if (
/\{[^{}]*(label|name|text|title|alt|description|url|href|children|content|message)/i.test(
body,
)
)
continue;
const lineIndex = text.slice(0, match.index).split('\n').length;
offenders.push({ line: lineIndex, snippet: match[0].trim() });
}
return offenders;
},
},
{
name: 'english-aria-label',
/**
* PR-IR-05 / i18n contract: any `aria-label` / `title` /
* `placeholder` attribute with a string literal value that is all
* English (no CJK) is a likely missing translation. Real Chinese
* UI labels naturally contain `[一-鿿]` characters.
*
* Skips:
* - whitelisted technical/brand terms (URL, API, OAuth, …)
* - very short strings (< 4 chars — typically tokens or `OK` /
* `New` that are too short to risk false positives without a
* proper i18n catalog)
* - `// a11y-allow:` exception comments
* - test files / __tests__ already excluded by walker
*
* Detects user-visible-text attributes only. Internal `data-*` /
* `className` / `id` / `aria-controls` are not checked.
*/
scan(text) {
const offenders = [];
// Single-line attribute match — value can't contain quote chars or
// line breaks. We don't strip comments here because attribute string
// literals can't legitimately contain `//`, and stripping would
// throw off `match.index` line-number calculation.
const ATTR_RE = /\b(aria-label|title|placeholder)\s*=\s*(['"])([^'"\n]+)\2/g;
let match;
while ((match = ATTR_RE.exec(text))) {
const attrName = match[1] ?? '';
const value = match[3] ?? '';
if (value.length < 4) continue;
// Has any CJK char → assume Chinese / already translated
if (/[一-鿿]/.test(value)) continue;
// No Latin letters at all → not a sentence, skip
if (!/[a-zA-Z]/.test(value)) continue;
// Placeholders for example URLs / domains / slugs / tokens are
// template values, not user instructions. Skip these.
if (attrName === 'placeholder' && looksLikeExampleValue(value)) continue;
// Allow-list common technical / brand terms commonly left in English
// (model IDs, brand names, technical abbreviations). Match when the
// entire value is composed of allow-listed tokens.
if (isAllowedEnglishTerm(value)) continue;
// Same-line exception comment
const lineStart = text.lastIndexOf('\n', match.index) + 1;
const lineEnd = text.indexOf('\n', match.index);
const line = text.slice(lineStart, lineEnd < 0 ? text.length : lineEnd);
if (/\/\/\s*a11y-allow:/.test(line) || /\/\/\s*i18n-allow:/.test(line)) continue;
const lineIndex = text.slice(0, match.index).split('\n').length;
offenders.push({
line: lineIndex,
snippet: `${attrName}="${value.slice(0, 60)}${value.length > 60 ? '…' : ''}"`,
});
}
return offenders;
},
},
];
/**
* Allow-list for common technical / brand / English terms that don't need
* translation. Returns true when the entire value (after stripping
* punctuation/digits) is composed of allowed tokens.
*
* Be conservative: false negatives are OK (we err on letting English
* through; the gate is meant to catch *forgotten* translations, not
* impose Chinese on every brand name).
*/
const ALLOWED_ENGLISH_PATTERNS = [
// The app's own product name — a proper noun, kept in Latin script
// everywhere (About hero title, brand marks). Not a missing translation.
/^Maka$/,
// Model providers / brand names
/^OpenAI$/i,
/^Anthropic$/i,
/^Claude\b/i,
/^GPT-?\d/i,
/^Gemini\b/i,
/^Llama\b/i,
/^DeepSeek$/i,
/^Z\.AI$/i,
/^Z\.ai$/i,
/^GLM-[\d.]+/i,
// Common technical abbreviations
/^(API|URL|JSON|YAML|HTTP|HTTPS|HTML|CSS|UTF-?8|UUID|JWT|TLS|SSL|MCP|LLM|UI|UX|CLI|MD|PDF|PNG|SVG|JPG|JPEG|WebP)$/i,
/^OAuth(?:\s*\d)?$/i,
/^WebSocket$/i,
/^localhost(?::\d+)?$/i,
// Generic placeholders rarely user-facing in a way that matters
/^https?:\/\//i,
/^[A-Z][a-z]+ID$/, // sessionID, etc.
];
/**
* Detects placeholder values that are example formats (URL, domain,
* slug, token, hostname:port) rather than user-facing instructions.
* These don't need translation.
*/
function looksLikeExampleValue(value) {
// Strip the unicode ellipsis (U+2026) and ASCII "..." used to indicate
// truncated example text — they don't change the example nature.
const trimmed = value.trim().replace(/…+|\.\.\.+/g, '');
// Single bare word like "my-provider" or "model-id"
if (/^[a-z][a-z0-9-]*$/.test(trimmed)) return true;
// PR-BOT-WECHAT-SCAN-LOGIN-0: developer credential placeholders that
// use lowercase + underscore + 'x' wildcards as the canonical example,
// e.g. "cli_xxxx" (飞书 App ID), "dingxxxxxxxx" (钉钉 AppKey).
if (/^[a-z][a-z0-9_-]*$/.test(trimmed)) return true;
// Token-prefix style placeholders that mix uppercase + lowercase, e.g.
// "MTAx" (Discord Bot Token prefix).
if (/^[A-Z][A-Za-z0-9_-]*$/.test(trimmed)) return true;
// Numeric-prefix wildcards like "102xxxxxx" (QQ AppID).
if (/^[0-9]+x+$/i.test(trimmed)) return true;
// URL / scheme
if (/^https?:\/\//i.test(trimmed)) return true;
if (/^[a-z]+:\/\//i.test(trimmed)) return true;
// Comma-separated domains: "metaso.cn, baidu.com"
if (/^[a-z0-9.-]+\.[a-z]{2,}(,\s*[a-z0-9.-]+\.[a-z]{2,})*$/i.test(trimmed)) return true;
// Token format: "123456:ABC-DEF" / "123456:ABC-DEF…"
if (/^[0-9]+:[A-Z0-9_-]+$/.test(trimmed)) return true;
// hostname:port
if (/^[a-z0-9.-]+:\d+$/i.test(trimmed)) return true;
return false;
}
function isAllowedEnglishTerm(value) {
const trimmed = value.trim();
for (const pattern of ALLOWED_ENGLISH_PATTERNS) {
if (pattern.test(trimmed)) return true;
}
// Composite values: split by space/punct, check each token
const tokens = trimmed.split(/[\s.,:;\-_/()[\]]+/).filter(Boolean);
if (tokens.length === 0) return false;
return tokens.every((token) => ALLOWED_ENGLISH_PATTERNS.some((p) => p.test(token)));
}
async function main() {
const offenders = [];
for (const root of ROOTS) {
const files = await walk(join(REPO_ROOT, root));
for (const file of files) {
const rel = relative(REPO_ROOT, file).split('\\').join('/');
const src = await readFile(file, 'utf8');
for (const rule of RULES) {
const hits = rule.scan(src);
for (const hit of hits) {
offenders.push({ path: rel, rule: rule.name, line: hit.line, snippet: hit.snippet });
}
}
}
}
if (offenders.length === 0) {
console.log(`[check-a11y] OK — ${RULES.map((r) => r.name).join(', ')} all clean.`);
return;
}
console.error(`[check-a11y] FAILED — ${offenders.length} a11y violations:`);
for (const o of offenders) {
console.error(` [${o.rule}] ${o.path}:${o.line}`);
console.error(` ${o.snippet}`);
}
console.error('');
console.error('Fix options:');
console.error(' - icon-only-button → add `aria-label="<chinese label>"`');
console.error(' - icon-only-link → add `aria-label="<chinese label>"`');
console.error(
' - positive-tabindex → use natural DOM order; `tabIndex={0}` or `tabIndex={-1}` only',
);
console.error(' - dialog-missing-label → add `aria-label` or `aria-labelledby` to the dialog');
console.error(
' - input-missing-label → wrap with `<label>`, or add `aria-label` / `placeholder`',
);
console.error(' - english-aria-label → translate to Chinese, or `// i18n-allow: <reason>`');
console.error('');
console.error('Genuine exceptions: add `// a11y-allow: <reason>` on the same line.');
process.exit(1);
}
main().catch((err) => {
console.error('[check-a11y] unexpected error:', err);
process.exit(2);
});