Catalog PyPI
PACKAGE EXPLORERView in registry

accessibility-champion

PyPI

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

a11yaccessibilityhtmllinterstatic-analysiswcag
Versions collected1
Direct dependencies4From release 0.4.0
Last observed20 Oct, 17:57 UTC

README

Version 0.4.0 documentation

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

FlagDescription
--jsonOutput results as a JSON array (one entry per file)
--fragmentTreat input as an HTML fragment; skip full-page landmark and single-<h1> checks
--full-pageForce full-page mode even when <html> / <body> tags are absent
--axeMerge axe-core results when Node.js and axe-core + jsdom are installed (see below)
--extractExtract HTML from framework templates (.tsx, .jsx, .vue, .svelte, .component.html) before linting
--globTreat each argument as a glob pattern, expanded recursively (CI batch mode)
--no-sidecarSkip writing the *.extract-map.json sidecar during extraction
--no-cssSkip the CSS pass (contrast, touch targets, font size) for faster CI runs
--base-urlBase 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.

ExtensionExtractor
.html, .htmPassthrough (identity mapping)
.tsx, .jsxFirst JSX return ( … ) block
.vue<template> block
.svelteMarkup outside <script> / <style>
.component.htmlFull 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.

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

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

Violations of same ruleMultiplierExample (image-alt, critical)
11× base cap−20
2–41.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 IDWhat it checks
html-has-lang<html> missing lang attribute
image-alt<img> missing alt attribute
image-alt-qualityGeneric 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-titleFull page missing non-empty <title> in <head>
decorative-img-roleDecorative alt="" image missing role="presentation" (advisory)
color-contrastText/background contrast below 4.5:1 from inline/<style>/linked CSS (heuristic)
font-size-px-onlyInline font-size in px without a rem/em fallback (heuristic)

Pillar 2 — Operable

Rule IDWhat 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-visibleoutline: none / outline: 0 in CSS without a matching :focus / :focus-visible fallback rule
skip-linkFull page with <nav> missing a skip-to-main-content link
tabindex-positiveAny tabindex value greater than 0
button-type-missing<button> inside <form> without explicit type attribute
target-blank-no-warningtarget="_blank" without accessible new-window warning
empty-link<a> with no meaningful href (missing or href="#")
filename-link-textLink text that is just a filename (report.pdf) (heuristic)
touch-target-sizeInteractive element sized below 44×44 CSS px from declared CSS (heuristic)
pointer-events-none-interactiveInteractive 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 IDWhat it checks
input-unlabelledForm control has no id, is not wrapped in <label>, and has no aria-label
input-missing-labelControl has an id but no matching <label for="...">
placeholder-as-labelplaceholder used without a real label or aria-label
input-autocompletePersonal-data inputs (email, password, tel, or name/address-like fields) missing autocomplete
aria-invalid-no-descaria-invalid="true" without aria-describedby pointing to an error element
required-indicatorrequired / aria-required control without a visible required indicator (heuristic)
select-empty-label<select> using an empty first <option> as its sole label
lang-subtagInline lang attribute too short to be a valid BCP 47 tag (heuristic)

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

Pillar 4 — Robust & Semantic Structure

Rule IDWhat it checks
duplicate-idDuplicate id attribute values
form-group-fieldsetMultiple radio/checkbox inputs sharing a name not wrapped in <fieldset> + <legend> (one violation per group)
aria-describedby-missing-targetaria-describedby references an id that does not exist
aria-labelledby-targetaria-labelledby references an id that does not exist
heading-orderSkipped heading levels (e.g., <h1> → <h3>)
heading-single-h1More than one <h1> on a full page
frame-title<iframe> missing title
table-thData table missing <th> header cells
table-captionData table missing <caption>
missing-mainFull page missing <main> landmark
missing-header-landmarkFull page missing <header> / role="banner"
missing-nav-landmarkFull page missing <nav> / role="navigation"
missing-footer-landmarkFull page missing <footer> / role="contentinfo"
landmark-nesting<main> nested inside another <main> / duplicate role="main"
empty-heading<h1>–<h6> with no text content
list-structure3+ consecutive links in bare <div>s (heuristic) — use <ul>/<nav>
aria-hidden-focusableFocusable element inside an aria-hidden="true" subtree
redundant-roleRedundant 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:

WorkflowWhen to use
FULL_AUDITUser shares code or a page — run a11y-lint on .html first, then agent manual checks for contrast, keyboard, ARIA behavior, and intent
AUTO_FIXUser 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

FilePurpose
demo/broken_page.htmlIntentionally broken markup; demonstrates linter output
demo/passing_page.htmlAccessible page that scores 100/100
demo/expected_output.txtReference text output for broken_page.html

Project Layout

PathPurpose
a11y_lint.pyCLI entry point and thin HTML parser dispatcher
a11y_context.pyShared parse context and attribute helpers
a11y_rules/Individual accessibility rule implementations
a11y_focus.pyFocus-outline checks (delegates to the shared CSS walker)
a11y_css.pyCSS engine — contrast, touch targets, pointer-events, font-size, focus
a11y_css_fetch.pyBest-effort linked-stylesheet resolution (--base-url)
a11y_axe.pyOptional axe-core merge (--axe)
a11y_extract.pyFramework-template HTML extractors (--extract)
a11y_mapping.pyLine-mapping model and *.extract-map.json sidecar I/O
fixtures/Framework-template + CSS fixtures
demo/broken_page.htmlFixture demonstrating failing checks
demo/passing_page.htmlFixture demonstrating passing checks
test_a11y_lint.py, test_a11y_extract.py, test_phase2.py, test_phase3.pyAutomated test suite
SKILL.mdAI 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.

Downloads & popularity

Awaiting observations

No metric observations yet

Download counts and popularity will appear here as the collector receives them.

Versions 1

Collected releases
VersionPublishedDirect dependenciesOpen version
0.4.09 Jul 20264

Package advisories

0
No matching advisories collected

This describes our collected data; it is not a security assessment.

Repository

PENDING
noteheng/accessibility-champion

Shared repository data · GitHub

License detected in repository
Not collected

Metadata history

Meaningful changes
Current collected state Current

Observed since 20 Oct, 17:57 UTC

No superseded metadata states collected. Unchanged observations do not create new revisions.

Record identity

package:efa178b39fac666aa8b5c42aa2218e9239552152f088d2eb9f88fa3a26781cb5