@itsy/html/check
import { check } from '@itsy/html/check';
import type { Problem, Finding, RuleSet, Visitor, Report, A11yRule, A11yOptions, CheckOptions } from '@itsy/html/check';Everything the library checks, including: the markup check over a rendered page, twenty-five accessibility rules that run by default, and a hook for adding additional rules. All of it is development-only — the production build compiles check down to a function that immediately returns an empty array, 28 bytes.
check
check(markup: string | Html, options?: CheckOptions): (Problem | Finding)[]Runs the checks over a rendered string rather than a single template, and returns what it found in page order. Empty means clean.
assert.deepEqual(check(Page(data)), []);Because it sees the finished page, it catches what one html call cannot:
- problems that span two templates, or hide inside
attrs()output - code 15, an id reference with no matching id
- code 16, an id used twice
- code 19, a URL the guard replaced with
about:blank#blocked - the accessibility rules, which need the finished markup for the same reason
Always [] in the production build
There is no check in the production build, so check() there returns an empty array whatever it is passed. A test suite that resolves the production build will pass every assertion based on it. Assert check.enabled once and it cannot. See confirming the dev build.
CheckOptions
interface CheckOptions {
ids?: boolean; // default true
a11y?: boolean | A11yOptions; // default true
rules?: RuleSet | readonly RuleSet[];
}
interface A11yOptions {
without?: readonly A11yRule[]; // rules to silence, by name
}ids: false turns off codes 15 and 16. Use it when checking a fragment rather than a page, where an id reference pointing outside the fragment is expected.
a11y: false leaves only the markup check, and narrows the return type back to Problem[].
rules runs custom rules in the same pass.
The ids checked are for, form, list, headers, popovertarget, commandfor, itemref and the aria-* relations.
Problem
interface Problem {
code: number; // the same numbers as HtmlError.code
message: string; // what is wrong, and what the browser does instead
at: number; // character offset into the markup
near: string; // the markup around `at`, whitespace squeezed
}In a template, a ${…} counts as those four characters when at is computed.
Finding
What a rule reports. The list holds both shapes, and the two are told apart by their first field:
interface Finding {
rule: string; // which rule found it, e.g. img-alt
message: string; // what is wrong, and what it means for a person using the page
at: number;
near: string;
}
for (const p of check(view)) {
if ('rule' in p) console.warn(`[${p.rule}] ${p.message}`, p.near);
else console.warn(`[html ${p.code}] ${p.message}`, p.near);
}Names, not numbers, and deliberately: these are not HtmlError codes. Nothing here ever throws, and a name says what it found without a lookup.
Accessibility
Advice rather than correctness, but in the same pass and the same list, because a finding that has to be asked for is a finding nobody sees. They cost nothing to leave on: they compile away with the rest of check().
An empty list means these rules found nothing, not that the page is accessible.
| rule | fires on |
|---|---|
img-alt | an image the browser exposes with no name: <img> with no alt, <input type="image">, role="img" |
img-alt-filename | alt that is only a file name, like photo-3.png |
a-href | <a> with no href, id, name, tabindex, role or aria-disabled |
html-lang | <html> with no lang, or only whitespace in it |
iframe-title | an <iframe> in the tab order with no title |
empty-heading | a heading — <h1>…<h6> or role="heading" — with no text and nothing naming it |
empty-link | a link — <a href> or role="link" — with no text and nothing naming it; <area href> with no alt |
empty-button | a button — <button>, role="button", <input type="button"> — with no text and nothing naming it |
empty-title | the page's <title> with no text, or a whole page with no <title> at all |
field-label | a form field with no <label>, aria-label, aria-labelledby or title |
label-control | <label> with no for and no control inside it |
label-for | for= pointing at something that is not a form control |
aria-unknown | an aria-* name that does not exist |
aria-empty | an aria-* attribute or role with an empty value |
aria-boolean | a true/false ARIA attribute given something else |
aria-value | an ARIA number, whole number or token given a value it does not take |
aria-live | aria-live outside polite, assertive and off |
aria-hidden-focus | something the keyboard can tab to, on or inside aria-hidden="true" |
positive-tabindex | tabindex above zero |
figcaption-parent | <figcaption> that is not a direct child of <figure> |
misplaced-scope | scope on anything but <th> |
role-unknown | a role that is not an ARIA, DPUB-ARIA or Graphics ARIA role |
role-redundant | a role the element already had |
role-required-props | a role with no state to read, like checkbox with no aria-checked |
role-presentation-conflict | role="none" or alt="" the browser must ignore: on something focusable, or with a global aria-* |
check('<img src="cat.jpg"><button><svg></svg></button><nav role="navigation">x</nav>');img-alt: `<img>` has no `alt`: a screen reader reads out the file name instead. Write `alt=""` if the image is decoration — near "<img src="cat.jpg"><button><sv"
empty-button: `<button>` has no text: a screen reader says only "button" — near "<img src="cat.jpg"><button><svg></svg></button><n"
role-redundant: `<nav role="navigation">`: `<nav>` is already a `navigation`, so the attribute says nothing the browser did not know — near "c="cat.jpg"><button><svg></svg></button><nav role="navigation">x</nav>"Turning rules off
check(view, { a11y: { without: ['img-alt-filename', 'positive-tabindex'] } });The names are checked against A11yRule, the union of the twenty-five above, so a typo is a type error rather than a rule that quietly stays on:
check(view).some((p) => 'rule' in p && p.rule === 'img-altt'); // ✗ type errorReach for it when a rule is wrong for a whole codebase. a11y: false turns the lot off.
Turning one element off
Prefer markup that says why. The rules already respect the attributes that mean "no name needed", and unlike a suppression comment those tell a screen reader the same thing:
[
check('<img src="hero.jpg" alt="">'), // decoration, said properly
check('<img src="hero.jpg" role="presentation">'),
check('<img src="hero.jpg" aria-hidden="true">'),
check('<div hidden><img src="hero.jpg"></div>'),
];[
[],
[],
[],
[]
]There is no data-a11y-ignore attribute and there will not be one. This library renders exactly what is written, so a suppression marker would ship to every visitor — an ESLint comment is stripped at build, an attribute is not.
Quiet by design
A rule that fires on correct markup is worse than one that misses a bug, because one wrong finding is all it takes for someone to switch the whole thing off. So the rules stay quiet whenever they cannot be sure:
- Nothing inside
hidden,inert,display: none,visibility: hidden,aria-hidden="true"or<template>is reported. None of it reaches the person the rules are about — except that the keyboard still reaches whataria-hiddenhides, which is whataria-hidden-focusis for. - Each rule goes by the role the browser gives the element, not by its tag.
<span role="button">is a button,<a href role="doc-biblioref">is a link, and<h2 role="none">is no heading. alt="",role="presentation"androle="none"mark an image as decoration, and all three are respected — until the browser has to ignore them, on anything focusable or anything with a global ARIA attribute such asaria-label. Then the element keeps its role, and the rules read it as what it is.- A custom element can hold anything, so it silences the rule around it — a
<my-input>inside a<label>counts as the control, a<my-icon>inside a<button>counts as a name, and a custom element withrole="switch"may carry itsaria-checkedthroughElementInternals. - A name from
aria-label,aria-labelledbyortitle, on the element or on anything inside it, counts as text.<button><img src="i.svg" alt="Delete"></button>is silent. A name fromaria-labelledbyis taken on trust: the rules check that it says something, not what the element it points at holds, and a reference to an id that is not on the page is code 15. - Text counts only where someone reads it: not inside
aria-hidden, and not in a<script>or a<style>. - The role tables hold only the mappings the markup settles on its own.
<header role="banner">,<aside role="complementary">,<li role="listitem">and<option role="option">depend on an ancestor, so none of them is reported as redundant. - Nor is
<ul role="list">, orrole="table",role="caption",role="rowgroup"androle="row"on the table elements that already have them. Safari drops the list role from a list styledlist-style: none, browsers have dropped the table roles from a table given anotherdisplay, and restating the role puts it back. - An
<input>keeps its own state whatever role it is given.<input type="checkbox" role="switch">is the native switch, and needs noaria-checked— ARIA in HTML forbids one. A text input with alistis a combobox already, and needs noaria-expanded. roletakes a fallback list, and the browser uses the first entry it knows. WebKit'srole="text"is known to WebKit alone, so an element that carries it has no role the rules can be sure of, and they leave it be.- The rules that need the full role-to-properties graph — which
aria-*each role allows — are left out. That table is the largest and the easiest one to be wrong with.
Held to outside sources
The rules are not checked against their own idea of what is right. The test suite holds them to:
- The W3C ACT Rules. Every example the ACT Rules Community Group publishes for a rule here runs through
check(): afailedexample must be reported, apassedorinapplicableone must not be. - ARIA and HTML-AAM, through aria-query, which is generated from them. The role, attribute and value tables are compared entry by entry.
Where the rules part ways with either on purpose — ARIA 1.3 has moved on from what the source encodes, or the markup alone cannot settle the answer — the difference is listed in the suite with its reason, and one that stops being a difference fails it. The markup check is held the same way: over the html5lib parser suite, whenever it reports nothing, or only what it repairs as the browser does, the tree it read is the one a spec-conformant parser builds; and over every three-deep nesting of the elements the parser treats specially, it reports a problem exactly when that parser changes what is written.
Custom rules
rules runs a project's own rules in the same walk, reporting into the same list: house style, design-system constraints, anything that reads as markup.
const noInlineStyle = (report) => ({
open(tag, attrs, at) {
if (attrs.has('style')) report('no-inline-style', `\`<${tag} style>\`: use a utility class`, at);
},
});
check('<p style="color:red">x</p>', { a11y: false, rules: noInlineStyle });no-inline-style: `<p style>`: use a utility class — near "<p style="color:red">x</p>"type RuleSet = (report: Report) => Visitor;
type Report = (rule: string, message: string, at: number) => void;
interface Visitor {
open?: (tag: string, attrs: ReadonlyMap<string, string>, at: number, ancestors: readonly string[]) => void;
text?: (content: string, at: number, ancestors: readonly string[]) => void;
close?: (tag: string, at: number, hadText: boolean) => void;
end?: (ids: ReadonlyMap<string, number>) => void;
}A rule set is called once per check() and returns a visitor, so per-run state goes in the closure. Every hook is optional, and near is filled in automatically — a rule never looks at the markup itself.
open— a start tag.attrshas lowercased names and verbatim values; a bare attribute is present with an empty value.ancestorsis outermost first, and holds what is really open: anything the browser would have closed already is closed. A rule may keep both. A void element opens and never closes.text— a run of text, as written, and never empty, with itsancestorsasopengives them. Entities are not decoded, and a<that opens no tag is part of the text, as the browser reads it. A comment ends a run, soTO<!-- -->DOarrives as two. It fires for the body of every element the browser reads as text too:<script>,<style>,<textarea>,<title>,<iframe>,<noscript>,<noembed>,<noframes>,<xmp>and<plaintext>.close—atis where the element started, so it pairs withopen.hadTextsays whether it held any non-whitespace.end— every id on the page and where it was seen.
Pass an array to run several. Findings from all of them are sorted into page order with the markup problems, so the list reads top to bottom whatever produced it. An entry that is not a function is left out, so rules: [strict && house] reads as it looks.
Custom rule names are open-ended, so passing rules widens the result to Finding<string>.
This is a development-only surface
It ships as nothing, so it can afford to be generous — and it is versioned more loosely than the renderer. A rule set that throws will take check() down with it.
check.enabled
check.enabled; // true here, false in the production buildcheck() returns [] in the production build, so a suite that resolves it passes every assertion built on check() without looking at anything. Assert this once and it cannot:
test('the checks are active', () => assert(check.enabled));