Skip to main content

Static HTML, framework-template, and CSS accessibility linter for WCAG 2.2

Project description

PyPI GitHub release GitHub stars

Accessibility Champion

Accessibility Champion is a lightweight, static accessibility linter for HTML files. It helps identify common WCAG 2.2 AA violations in your markup and generates an accessibility score to help developers triage and fix issues quickly.

The linter uses Python's HTMLParser with a rule-registry architecture: a thin dispatcher forwards parse events to focused rule classes, keeping each check isolated and easy to extend.

Installation

Install from PyPI (Python 3.8+):

pip install accessibility-champion

Optional extras:

pip install accessibility-champion[css]   # tinycss2 for CSS heuristics (roadmap)
pip install accessibility-champion[dev]     # build, twine, and coverage

After install, use the a11y-lint console script:

a11y-lint path/to/your/file.html

Install from a cloned repo (development):

git clone https://github.com/notEhEnG/accessibility-champion.git
cd accessibility-champion
pip install -e .

Quick Start

Run the linter against any HTML file to get a human-readable text report:

a11y-lint path/to/your/file.html
# or, from a source checkout:
python3 a11y_lint.py path/to/your/file.html

Try the included demo fixtures:

a11y-lint demo/broken_page.html   # exits 1 — many violations
a11y-lint demo/passing_page.html  # exits 0 — score 100/100

CLI Options

Flag Description
--json Output results as a JSON array (one entry per file)
--fragment Treat input as an HTML fragment; skip full-page landmark and single-<h1> checks
--full-page Force full-page mode even when <html> / <body> tags are absent
--axe Merge axe-core results when Node.js and axe-core + jsdom are installed (see below)
--extract Extract HTML from framework templates (.tsx, .jsx, .vue, .svelte, .component.html) before linting
--glob Treat each argument as a glob pattern, expanded recursively (CI batch mode)
--no-sidecar Skip writing the *.extract-map.json sidecar during extraction
--no-css Skip the CSS pass (contrast, touch targets, font size) for faster CI runs
--base-url Base URL or directory used to resolve <link rel="stylesheet"> sheets (best-effort)

Auto-detection: When neither --fragment nor --full-page is passed, the linter treats markup as a fragment unless it contains an <html> or <body> tag. Full-page landmark checks (<main>, <header>, <nav>, <footer>, single <h1>) only run in full-page mode.

# JSON output for CI
a11y-lint path/to/your/file.html --json

# Lint a partial HTML snippet (e.g., a component template)
a11y-lint path/to/fragment.html --fragment

# Static + rendered audit (requires Node.js)
npm install axe-core jsdom
a11y-lint path/to/your/file.html --axe

Linting React / Vue / Svelte / Angular templates

The extractor pulls HTML-like markup out of framework component files, lints it, then remaps each violation's line number back to the original source file. A sidecar JSON records the mapping.

Extension Extractor
.html, .htm Passthrough (identity mapping)
.tsx, .jsx First JSX return ( … ) block
.vue <template> block
.svelte Markup outside <script> / <style>
.component.html Full Angular template
# Single component with extraction + sidecar
a11y-lint src/LoginForm.tsx --extract --json

# Skip the sidecar write
a11y-lint src/LoginForm.vue --extract --no-sidecar

# Batch a whole tree (each arg is a glob pattern)
a11y-lint --glob 'src/**/*.tsx'

# Partial component template without <html>/<body>
a11y-lint src/Card.vue --extract --fragment

Non-HTML extensions are extracted automatically even without --extract. Reported line values refer to the source file after remapping. The sidecar (<source>.extract-map.json) has the shape:

{
  "version": 1,
  "phase": "2",
  "sourceFile": "src/LoginForm.tsx",
  "extractedFile": "src/LoginForm.tsx.extracted.html",
  "fragment": true,
  "mappings": [{ "extractedLine": 3, "sourceLine": 47, "tag": "input" }]
}

Exit Codes

  • 0 — no violations found across all linted files
  • 1 — one or more violations found, or a file could not be read

Any violation severity (including minor) causes a non-zero exit when --json is not used for machine parsing.

Output Format

Text Report

The default text output provides a score out of 100, followed by violations grouped by severity. Each violation includes:

  • id — stable rule identifier (e.g., image-alt, form-group-fieldset)
  • line — source line number
  • message — human-readable description
  • fix — suggested remediation
  • wcag — relevant WCAG success criterion

JSON Report

[
  {
    "file": "demo/broken_page.html",
    "score": 0,
    "violations": [
      {
        "id": "html-has-lang",
        "severity": "serious",
        "line": 2,
        "message": "<html> tag is missing a lang attribute",
        "fix": "Add lang=\"en\" (or appropriate language code) to the <html> tag",
        "wcag": "3.1.1 Language of Page"
      },
      {
        "id": "image-alt",
        "severity": "critical",
        "line": 14,
        "message": "<img> is missing an alt attribute",
        "fix": "Add alt=\"[description]\" for informational images, or alt=\"\" role=\"presentation\" for decorative ones",
        "wcag": "1.1.1 Non-text Content"
      }
    ]
  }
]

Programmatic API

from a11y_lint import check_html, score

violations = check_html(source)                  # auto-detect fragment vs full page
violations = check_html(source, fragment=True) # force fragment mode
total = score(violations)

Scoring Model

The Accessibility Score starts at 100. Deductions are grouped by rule ID so repeated instances of the same issue do not zero out the score before other problems are reflected.

Severity Base cap (1 hit) Examples
Critical −20 Missing alt, unlabelled form controls, buttons without accessible names
Serious −10 Missing lang, duplicate IDs, generic link text, missing iframe title
Moderate −5 Skipped heading levels, missing <main>, ungrouped radio/checkbox sets
Minor −2 Missing autocomplete on personal-data fields, optional landmark regions

Per-rule scaling (applied once per rule ID, not per violation):

Violations of same rule Multiplier Example (image-alt, critical)
1 1× base cap −20
2–4 1.5× base cap −30 (capped)
5+ 2× base cap −30 (absolute max per rule)

No single rule can deduct more than −30 points. The total score is still clamped to a minimum of 0.

Worked example: Six image-alt violations (critical, base −20) use the 5+ multiplier: min(20 × 2.0, 30) = −30 total for that rule — not 6 × 20 = −120.

Current Checks

Pillar 1 — Perceivable

Rule ID What it checks
html-has-lang <html> missing lang attribute
image-alt <img> missing alt attribute
image-alt-quality Generic alt text (image, photo, logo, etc.)
no-autoplay <video> / <audio> with autoplay
video-captions <video> missing <track kind="captions">
audio-transcript <audio> without nearby transcript link/text (heuristic)
document-title Full page missing non-empty <title> in <head>
decorative-img-role Decorative alt="" image missing role="presentation" (advisory)
color-contrast Text/background contrast below 4.5:1 from inline/<style>/linked CSS (heuristic)
font-size-px-only Inline font-size in px without a rem/em fallback (heuristic)

Pillar 2 — Operable

Rule ID What it checks
button-name <button> with no accessible name (text, aria-label, child <img alt>, or <svg><title>)
link-name <a> with generic text (click here, read more, here, more, etc.)
focus-visible outline: none / outline: 0 in CSS without a matching :focus / :focus-visible fallback rule
skip-link Full page with <nav> missing a skip-to-main-content link
tabindex-positive Any tabindex value greater than 0
button-type-missing <button> inside <form> without explicit type attribute
target-blank-no-warning target="_blank" without accessible new-window warning
empty-link <a> with no meaningful href (missing or href="#")
filename-link-text Link text that is just a filename (report.pdf) (heuristic)
touch-target-size Interactive element sized below 44×44 CSS px from declared CSS (heuristic)
pointer-events-none-interactive Interactive element with inline pointer-events: none

Focus-outline analysis parses CSS rule blocks inside <style> elements and inline style="" attributes. It matches base selectors (e.g., .btn) to companion :focus-visible rules that restore a visible outline.

Pillar 3 — Understandable

Rule ID What it checks
input-unlabelled Form control has no id, is not wrapped in <label>, and has no aria-label
input-missing-label Control has an id but no matching <label for="...">
placeholder-as-label placeholder used without a real label or aria-label
input-autocomplete Personal-data inputs (email, password, tel, or name/address-like fields) missing autocomplete
aria-invalid-no-desc aria-invalid="true" without aria-describedby pointing to an error element
required-indicator required / aria-required control without a visible required indicator (heuristic)
select-empty-label <select> using an empty first <option> as its sole label
lang-subtag Inline lang attribute too short to be a valid BCP 47 tag (heuristic)

Applies to <input>, <select>, and <textarea>.

Pillar 4 — Robust & Semantic Structure

Rule ID What it checks
duplicate-id Duplicate id attribute values
form-group-fieldset Multiple radio/checkbox inputs sharing a name not wrapped in <fieldset> + <legend> (one violation per group)
aria-describedby-missing-target aria-describedby references an id that does not exist
aria-labelledby-target aria-labelledby references an id that does not exist
heading-order Skipped heading levels (e.g., <h1><h3>)
heading-single-h1 More than one <h1> on a full page
frame-title <iframe> missing title
table-th Data table missing <th> header cells
table-caption Data table missing <caption>
missing-main Full page missing <main> landmark
missing-header-landmark Full page missing <header> / role="banner"
missing-nav-landmark Full page missing <nav> / role="navigation"
missing-footer-landmark Full page missing <footer> / role="contentinfo"
landmark-nesting <main> nested inside another <main> / duplicate role="main"
empty-heading <h1><h6> with no text content
list-structure 3+ consecutive links in bare <div>s (heuristic) — use <ul>/<nav>
aria-hidden-focusable Focusable element inside an aria-hidden="true" subtree
redundant-role Redundant role restating an implicit role (role="button" on <button>)
css-link-empty <link rel="stylesheet"> with an empty href

Presentation tables (role="presentation") are exempt from table header/caption checks.

CSS engine (contrast, touch targets, focus)

The CSS pass (a11y_css.py) reads inline style="", <style> blocks, and — with --base-url — best-effort linked stylesheets. Install the optional parser for accurate tokenizing:

pip install "accessibility-champion[css]"   # adds tinycss2 (falls back to regex if absent)

All CSS findings carry source: "css" and a fix_confidence of assisted or manual. Known limits — do not over-claim:

  • No browser cascade — only inline, <style>, and best-effort linked sheets; no full inheritance/layout.
  • var() / calc() / clamp() are treated as unknown and skipped (no guessed violations).
  • Gradients / background images are not sampled — use --axe for computed-DOM contrast.
  • Touch-target size uses declared CSS only, not rendered geometry.

Pass --no-css to skip this pass entirely in speed-sensitive CI.

Architecture

a11y_lint.py      CLI entry point; thin HTMLParser dispatcher
a11y_context.py   ParseContext (shared state) and TagAttrs helpers
a11y_rules/       Individual A11yRule classes registered via all_rules()
a11y_extract.py   Framework-template HTML extraction + line remapping (--extract)
a11y_css.py       CSS engine: contrast, touch targets, focus, pointer-events, font-size
a11y_focus.py     Focus-outline checks (delegates to a11y_css)
a11y_axe.py       Optional axe-core merge via inline Node.js script (--axe)

To add a new check, create a class extending A11yRule under a11y_rules/ with on_starttag, on_endtag, on_data, and/or finalize hooks, then register it in a11y_rules/__init__.py.

AI Agent Integration

SKILL.md is an agent skill file for Claude, Cursor, Copilot, and similar LLM coding agents. It tells agents how to run accessibility audits, interpret linter output, and know which checks still need human review.

Who uses it: AI agents invoked to audit HTML/JSX/Vue/Svelte codebases, produce severity-ranked reports, or apply safe fixes.

Two workflows it defines:

Workflow When to use
FULL_AUDIT User shares code or a page — run a11y-lint on .html first, then agent manual checks for contrast, keyboard, ARIA behavior, and intent
AUTO_FIX User wants fixes applied — apply copy-paste-safe patches from violation fix fields; escalate [manual] items

Update SKILL.md when you:

  • Add or rename rule IDs
  • Add or change CLI flags (e.g. --fragment, --axe)
  • Change the violation JSON schema or scoring model
  • Change FULL_AUDIT or AUTO_FIX steps

Agents load SKILL.md as context; keeping it in sync with the linter avoids stale audit instructions.

Running Tests

python3 -m unittest test_a11y_lint test_a11y_extract test_phase2 test_phase3 -v

The suite covers fixture pages, individual rules, CLI behavior, fragment mode, scoring caps, axe mapping, framework extraction, CSS contrast/touch/focus checks, and edge cases (92 tests). Every new rule must include tests that verify both failing and passing markup.

With dev dependencies installed:

coverage run -m unittest test_a11y_lint -v && coverage report -m

Limitations

⚠️ This is a static linter and does not replace manual accessibility review.

While it catches a meaningful share of common HTML accessibility issues, automated tooling cannot validate:

  • Interaction intent — Does a custom widget actually behave like its native equivalent?
  • Meaningful text — Is the alt text actually descriptive in context?
  • Keyboard navigation — Are there keyboard traps or focus-management issues?
  • Visual contrast — Real contrast ratios require rendering the DOM and CSS.

Always supplement this tool with screen reader testing, keyboard navigation audits, and dynamic tools like axe-core.

Demo Fixtures

File Purpose
demo/broken_page.html Intentionally broken markup; demonstrates linter output
demo/passing_page.html Accessible page that scores 100/100
demo/expected_output.txt Reference text output for broken_page.html

Project Layout

Path Purpose
a11y_lint.py CLI entry point and thin HTML parser dispatcher
a11y_context.py Shared parse context and attribute helpers
a11y_rules/ Individual accessibility rule implementations
a11y_focus.py Focus-outline checks (delegates to the shared CSS walker)
a11y_css.py CSS engine — contrast, touch targets, pointer-events, font-size, focus
a11y_css_fetch.py Best-effort linked-stylesheet resolution (--base-url)
a11y_axe.py Optional axe-core merge (--axe)
a11y_extract.py Framework-template HTML extractors (--extract)
a11y_mapping.py Line-mapping model and *.extract-map.json sidecar I/O
fixtures/ Framework-template + CSS fixtures
demo/broken_page.html Fixture demonstrating failing checks
demo/passing_page.html Fixture demonstrating passing checks
test_a11y_lint.py, test_a11y_extract.py, test_phase2.py, test_phase3.py Automated test suite
SKILL.md AI agent integration guidelines — update when rule IDs or CLI flags change

Roadmap

See ROADMAP.md for the phased plan to expand coverage toward full WCAG-aligned auditing (static rules → CSS analysis → axe-core integration).

Contributing

  1. Keep changes small and surgical.
  2. Add new rules as A11yRule subclasses under a11y_rules/; register them in all_rules().
  3. Add tests to test_a11y_lint.py for both failing and passing markup.
  4. Prefer the HTML parser over regular expressions for structural checks.
  5. Update this README when adding new rule IDs, CLI flags, or architectural changes.

License

MIT License — see LICENSE.

Project details


Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

accessibility_champion-0.4.0.tar.gz (39.9 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

accessibility_champion-0.4.0-py3-none-any.whl (39.4 kB view details)

Uploaded Python 3

File details

Details for the file accessibility_champion-0.4.0.tar.gz.

File metadata

  • Download URL: accessibility_champion-0.4.0.tar.gz
  • Upload date:
  • Size: 39.9 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? No
  • Uploaded via: twine/6.2.0 CPython/3.14.6

File hashes

Hashes for accessibility_champion-0.4.0.tar.gz
Algorithm Hash digest
SHA256 f860f7114479b77396140360033d265146cb97820cb8347c2139ce2e4121d1af
MD5 44f218884963a7e81c2a9236d7e7009d
BLAKE2b-256 e3aec2968feb4a1415b6e4e5cb3430720671c1a94a597000688f8242f1a837f0

See more details on using hashes here.

File details

Details for the file accessibility_champion-0.4.0-py3-none-any.whl.

File metadata

File hashes

Hashes for accessibility_champion-0.4.0-py3-none-any.whl
Algorithm Hash digest
SHA256 9cd2eaaff7dfcf35580c56694c4dc586fa15f72580bfdfcf24d8999449b0cbc0
MD5 c3a1f2c3afd816c084de29ed2b9268dc
BLAKE2b-256 575ef4a31fde247ce285501b5d241264fd240de7618b6e457512df07f0155211

See more details on using hashes here.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Pingdom Monitoring Sentry Error logging StatusPage Status page