Skip to content

@itsy/html/check ​

ts
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 ​

ts
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.

ts
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 ​

ts
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 ​

ts
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:

ts
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.

rulefires on
img-altan image the browser exposes with no name: <img> with no alt, <input type="image">, role="img"
img-alt-filenamealt 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-titlean <iframe> in the tab order with no title
empty-headinga heading — <h1>…<h6> or role="heading" — with no text and nothing naming it
empty-linka link — <a href> or role="link" — with no text and nothing naming it; <area href> with no alt
empty-buttona button — <button>, role="button", <input type="button"> — with no text and nothing naming it
empty-titlethe page's <title> with no text, or a whole page with no <title> at all
field-labela form field with no <label>, aria-label, aria-labelledby or title
label-control<label> with no for and no control inside it
label-forfor= pointing at something that is not a form control
aria-unknownan aria-* name that does not exist
aria-emptyan aria-* attribute or role with an empty value
aria-booleana true/false ARIA attribute given something else
aria-valuean ARIA number, whole number or token given a value it does not take
aria-livearia-live outside polite, assertive and off
aria-hidden-focussomething the keyboard can tab to, on or inside aria-hidden="true"
positive-tabindextabindex above zero
figcaption-parent<figcaption> that is not a direct child of <figure>
misplaced-scopescope on anything but <th>
role-unknowna role that is not an ARIA, DPUB-ARIA or Graphics ARIA role
role-redundanta role the element already had
role-required-propsa role with no state to read, like checkbox with no aria-checked
role-presentation-conflictrole="none" or alt="" the browser must ignore: on something focusable, or with a global aria-*
ts
check('<img src="cat.jpg"><button><svg></svg></button><nav role="navigation">x</nav>');
txt
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 ​

ts
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:

ts
check(view).some((p) => 'rule' in p && p.rule === 'img-altt'); // ✗ type error

Reach 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:

ts
[
  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>'),
];
json
[
  [],
  [],
  [],
  []
]

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 what aria-hidden hides, which is what aria-hidden-focus is 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" and role="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 as aria-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 with role="switch" may carry its aria-checked through ElementInternals.
  • A name from aria-label, aria-labelledby or title, on the element or on anything inside it, counts as text. <button><img src="i.svg" alt="Delete"></button> is silent. A name from aria-labelledby is 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">, or role="table", role="caption", role="rowgroup" and role="row" on the table elements that already have them. Safari drops the list role from a list styled list-style: none, browsers have dropped the table roles from a table given another display, 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 no aria-checked — ARIA in HTML forbids one. A text input with a list is a combobox already, and needs no aria-expanded.
  • role takes a fallback list, and the browser uses the first entry it knows. WebKit's role="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(): a failed example must be reported, a passed or inapplicable one 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.

ts
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 });
txt
no-inline-style: `<p style>`: use a utility class — near "<p style="color:red">x</p>"
ts
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. attrs has lowercased names and verbatim values; a bare attribute is present with an empty value. ancestors is 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 its ancestors as open gives 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, so TO<!-- -->DO arrives 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 — at is where the element started, so it pairs with open. hadText says 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 ​

ts
check.enabled; // true here, false in the production build

check() 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:

ts
test('the checks are active', () => assert(check.enabled));

See confirming the dev build.

MIT licensed.