Skip to content

manni a11y

Crawl a site and check every page for accessibility violations with axe-core, built for CI.

manni a11y check takes a URL, crawls the site behind it, and runs axe-core against every page it reaches. Each page gets a list of violations. Each violation carries the rule that fired, the element it fired on, and axe’s own statement of what to change. The run exits 1 when any page still has a violation, so the command you run locally is the same one that gates a pull request.

It checks what axe can decide from a rendered page. That is missing alternate text, buttons and links with no accessible name, form fields without labels, contrast, document language, and ARIA misuse.

It does not test keyboard flows, read your source, or write anything back. There is no --fix, because the check never sees the Markdown, template, or component tree that produced the page. Fix a failing check covers what it hands the person who fixes the page, and what a green run does not prove.

axe runs inside a real browser, and installing @hawkeyexl/manni does not download one. Get started has the one-time install.

The crawl stays on the host of the URL you gave it. docs.example.com and example.com are different hosts, and so is www.example.com; http:// and https:// of one host are the same site. Give several seeds and every seed’s host is in scope.

Pages come from two places, in this order:

  1. The sitemap. The site’s own list of pages, when it publishes one. It is looked up once per run, before the browser starts. See Sitemaps for when it is read, how it is found, and what happens without one.
  2. Links. Every page that loads contributes the same-host <a href> links in its rendered DOM. Anchors, mailto:, tel:, and links to assets such as PDFs, images, and stylesheets are skipped.

Pages are checked one at a time, in the order they were discovered. Each URL is checked once regardless of fragment, host casing, or a trailing slash. A page the browser reached by redirect counts as checked too. There is no cap by default, and the run goes on until nothing is queued. --max-pages <n> bounds it, and anything still queued at the cap is reported as skipped, not failed. --no-crawl checks exactly the URLs you named, with no sitemap lookup and no link following.

A seed that does not load is exit 2, because nothing was checked. A crawled page that does not load is recorded against that page and the crawl continues; the page counts as failed, so the run exits 1.

Once per run, before the browser starts, and only when crawling. The lookup uses the first seed’s origin and path. --no-crawl skips it and checks exactly the URLs you named.

Plain HTTP requests, no browser, each with a 10-second timeout. Candidates are tried in this order, and the first that answers 2xx and parses as a sitemap wins:

  1. robots.txt at the origin, and every Sitemap: line in it, in order.
  2. Walking from the seed’s own directory up to the site root, nearest first, the files sitemap.xml, sitemap-index.xml, and sitemap_index.xml in each directory.

For https://h/manni/meta/fix/ the walk asks /manni/meta/fix/, then /manni/meta/, then /manni/, then /. The walk exists for project sites published under a path. https://hawkeyexl.github.io/manni/ serves /manni/sitemap-index.xml and nothing at the origin. A body that is 2xx but not sitemap XML is skipped, and the next candidate is tried.

A <sitemapindex> is followed into its child sitemaps, up to three levels deep and fifty children in total. Every <loc> is kept when it is on the seed’s host and looks like a page. That means http or https, and not an asset such as a PDF, an image, or a stylesheet. Those URLs join the queue right after the seeds, deduped against them with a trailing slash ignored. Link following still runs on top, so a page the sitemap missed but a checked page links to is found too. --max-pages caps the whole run. The header line names the sitemap that was used, and summary.sitemap carries its URL.

A 404, a network error, or an unparseable body all mean “no sitemap”. None of them fails the run. The crawl falls back to same-host links from each checked page, breadth first, and the header reads (no sitemap; followed links).

Two gaps are recorded as deferred in proposal 0035. Disallow rules in robots.txt are not honoured, and gzipped sitemaps (.xml.gz) are not decompressed.

Each page reports a score from 0 to 100. It is the share of axe rules that applied to the page and passed. The --severity floor has already dropped the violations you chose not to count:

score = round(100 × passes ÷ (passes + violations))

It is not a Lighthouse score. Lighthouse weights rules; this does not. A page with one error and thirty passing rules scores 97, and it still fails the run. Read the score as a trend across pages and across runs, and read the exit code as the verdict. A page that did not load, or where no rule applied, has no score.

Rules axe could not decide (its incomplete results, such as contrast over a background image) are counted and reported but never treated as failures.

The a11y: key of manni.config.yaml sits beside meta: in the same file. Every key is optional; a flag on the command line wins over the same key in config, and config wins over the default.

manni.config.yaml
a11y:
urls: ["https://docs.example.com/"] # string[] seeds; fallback for [urls...]
crawl: true # boolean default true
maxPages: 500 # integer ≥ 1; absent means no cap
tags: ["wcag2a", "wcag2aa"] # string[]
severity: error # notice | warning | error
timeout: 30000 # integer ≥ 1, ms

tags restricts axe to rules carrying any of those tags, so a gate can be “WCAG 2.1 AA and nothing else”. severity is the floor: findings below it are dropped before the score and the exit code are decided. Its values are the family’s, notice, warning, and error, the same three every manni tool uses. axe’s critical and serious both land as error, moderate as warning, and minor as notice, and each finding keeps axe’s word as impact. The configuration keys table has the types and defaults.