a11y-why
v0.1.0
Published
Auditor de accesibilidad HTML que te explica POR QUÉ algo está mal, no solo qué. Sin navegador, sin configuración. / HTML accessibility linter that explains WHY, not just what. No browser, no config.
Maintainers
Readme
a11y-why
An HTML accessibility linter that explains why, not just what. No browser. No config. One dependency.
Léeme en español · Messages available in English and Spanish.
npx a11y-why index.htmlWhy another accessibility linter
There are good ones. They have gaps:
| Tool | Needs a browser | Explains the reason | Speed |
|---|---|---|---|
| axe-core | yes (needs a real DOM) | short reference text | fast once the browser is up |
| pa11y | yes (spawns Chrome) | short reference text | slow, heavy in CI |
| htmlhint | no | no | fast |
| a11y-why | no | yes, at length | fast |
Two consequences of not needing a browser: it runs in about a second on a folder of files, and it works on HTML that never gets served — templates, partials, static output, files in a pull request diff.
And the part that gives the package its name: when it finds something, it tells you what a real person actually experiences because of it. Not a WCAG reference number to go look up. The reason.
Example
$ npx a11y-why index.html
index.html
✖ 9:3 This <img> has no alt attribute. img-alt
<img src="photo-2847.jpg">
Why:
When a screen reader hits an image with no alt, it cannot skip it (it might
matter) so it reads the only thing it has: the filename. The user hears "IMG
dash 2 8 4 7 dot jpeg". Now picture a gallery with thirty of them.
And mind the distinction, which is the whole point of this rule: omitting alt
and writing alt="" are not the same. alt="" tells the reader "this is
decoration, skip it silently" and is the correct answer for a spacer or an
ornament. Omitting alt says nothing, so the reader has to improvise. One is a
decision, the other is an oversight.
Fix:
If the image carries information: alt="Description of what it shows or does".
If it is purely decorative: alt="" (empty, but present).
WCAG 1.1.1 Non-text Content (A)
https://www.w3.org/WAI/WCAG22/Understanding/non-text-content.html
✖ 17 errors ⚠ 2 warningsEach explanation prints once per run, no matter how many times the rule fires. Thirty images missing alt give you thirty one-line reports and one explanation, not thirty walls of text.
Usage
a11y-why index.html # one file
a11y-why src # a folder, recursively
a11y-why "**/*.html" # a pattern (expanded internally, so it works on Windows too)
cat page.html | a11y-why # stdinOptions
| Flag | What it does |
|---|---|
| -l, --lang <es\|en> | Message language. Default: es |
| -f, --format <name> | pretty (default), json, github |
| --rule <id> | Run only this rule. Repeatable |
| --exclude <id> | Skip this rule. Repeatable |
| --brief | One line per issue, no explanations |
| -q, --quiet | Errors only, hide warnings |
| --strict | Exit 1 on warnings too |
| --no-color | Disable colours |
| --list-rules | List every rule and exit |
Exit codes: 0 clean · 1 errors found · 2 bad usage.
In CI
--format github prints GitHub Actions annotations, so issues land directly on
the pull request diff at the right line:
- run: npx a11y-why "src/**/*.html" --format githubRules
17 rules. All of them detectable in static HTML — see What this does not do for the honest limits.
| Rule | Level | Catches |
|---|---|---|
| doctype | error | Missing <!DOCTYPE html> (browser falls into quirks mode) |
| meta-charset | error | No declared encoding (mojibake, and a UTF-7 XSS vector) |
| html-lang | error | <html> with no lang (screen reader uses the wrong voice) |
| doc-title | error | Missing or empty <title> |
| viewport-zoom | error | user-scalable=no or maximum-scale under 2 |
| img-alt | error | <img> with no alt; warns on useless alt text |
| link-name | error | Links with no readable text |
| button-name | error | Empty buttons, icon-only buttons with no label |
| iframe-title | error | <iframe> with no title |
| input-label | error | Fields with no label; flags placeholder-as-label |
| heading-order | warning | Skipped heading levels, page not starting at h1 |
| list-structure | error | Non-<li> children inside a list |
| th-scope | error | Missing scope in tables with headers in both directions |
| duplicate-id | error | Repeated ids (silently breaks for and aria-labelledby) |
| positive-tabindex | error | tabindex greater than 0 |
| fake-interactive | error | <div> with onclick or cursor:pointer — unreachable by keyboard |
| fake-heading | warning | <div> styled to look like a heading but announced as a paragraph |
Programmatic API
import { audit } from 'a11y-why';
const report = audit(html, { lang: 'en', file: 'index.html' });
report.errorCount; // 17
report.issues[0].ruleId; // 'img-alt'
report.issues[0].why; // the long explanation
report.issues[0].fix; // how to fix it
report.issues[0].line; // 9Full TypeScript types included. Rules are exported individually and the rule format is public, so you can write your own:
import { defineRule } from 'a11y-why';What this does not do
Being clear about the limits, because a tool that oversells makes people think they are covered when they are not:
- No colour contrast. That needs computed CSS, which needs a browser. If you
need it,
axe-coreorpa11yis the right tool — this one complements them, it does not replace them. - No JavaScript-generated content. It reads the HTML you wrote, not the DOM after your framework has run. For a SPA, lint the components' output.
- No full ARIA validation. It understands
aria-label,aria-labelledby,aria-hiddenand interactive roles. It does not validate every role and attribute combination in the spec. - Only inline
styleis read, and only forfake-headingandfake-interactive. External stylesheets are invisible from here.
Passing this linter does not mean your site is accessible. No automated tool can tell you that — roughly a third of WCAG criteria are machine-checkable at all. It means the mechanical mistakes are gone, which is the floor, not the ceiling. The rest needs a keyboard, a screen reader and real people.
Requirements
Node 18.17 or newer. Single runtime dependency:
parse5, the spec-compliant HTML parser
that jsdom uses — so the tree this audits is the same tree your browser builds,
including its quirks.
Licence
MIT
