All checks were successful
PR checks / checks (pull_request) Successful in 9m36s
PLAN.md §13 phase 10, with four decisions of record — D47-D50, taking the count
to fifty. Three were straightforward; the CSP turned into the phase's real work,
because the thing meant to be a configuration flag was broken in a dependency and
broken silently.
D47 — search reaches the marketing pages, and the header gets a box.
Base.astro marks its <main> as a Pagefind body, so all ten join the index the
docs already query, and Search.astro opens it in a <dialog>. Nothing is fetched
until the dialog is opened (the bundle is 120 kB and these pages otherwise ship
almost no JavaScript). Pagefind titles a result from the first <h1>, and these
pages have editorial ones — "The app for a deployment you already use" — so the
index is given the page's short name instead. applyBrand.mjs now re-indexes after
a rewrite, closing a note phase 2 left for this phase.
D48 — the CSP is a real response header, sent by the container. Not a <meta>,
which ignores frame-ancestors, and not advice for someone's reverse proxy, which
puts the strictest promise in §6 outside what this repo tests. Three things
fought it, all the same shape — correct build, broken page, no error:
* Astro does not hash <script is:inline>, and Starlight ships six per docs
page, so the first build with CSP on had a strict header and a dead theme
switcher. The hashes are now generated into src/config/cspHashes.mjs and
checkCsp.mjs verifies every inline block against its own page's policy.
* Expressive Code writes ~3,700 inline style ATTRIBUTES, which cannot be
hashed, hence style-src-attr 'unsafe-inline' — scoped to that directive, so
script-src is untouched.
* @astrojs/node matched a request to a policy with pathname.includes(), a
substring test: /modules/ was served /docs/modules/building-a-module's
policy and rendered with its own stylesheet refused. scripts/serve.mjs keeps
the same _headers.json and matches by equality; test/headers.test.mjs starts
the server and reads the responses, because nothing that reads dist/ can see
this.
D49 — robots.txt allows everything and names the sitemap (there was no way to
find it: no robots.txt, and D9 rules out a search console). D50 — Organization
and SoftwareApplication, no ratings and no docs-wide Article markup.
checkA11y.mjs is the eleventh check: seven structural rules over all fifty pages,
verified by breaking each in turn. The walk at 390/768/1280 found no overflow
anywhere, the CSP violations above, a 17x17 consent checkbox (WCAG 2.2 SC 2.5.8
wants 24), and a skip link that moved the scroll but not the focus.
npm run verify is green: fourteen steps, both test suites, all eleven checks.
Co-Authored-By: Claude <noreply@anthropic.com>
279 lines
13 KiB
JavaScript
279 lines
13 KiB
JavaScript
#!/usr/bin/env node
|
|
/**
|
|
* checkA11y.mjs — PLAN.md §13 phase 10.
|
|
*
|
|
* Every other rule this repository cares about is enforced by a script — the facts, the
|
|
* links, the tokens, the sidebar, the screenshots, the CSP. Accessibility was the exception:
|
|
* it was a thing someone checked once, by hand, on the pages they happened to open. This
|
|
* makes it the eleventh check so a regression fails a build instead of waiting for a reader
|
|
* who cannot use the page and will not file an issue.
|
|
*
|
|
* node scripts/checkA11y.mjs
|
|
*
|
|
* ── What it checks, and why each one ────────────────────────────────────────
|
|
* A static check cannot measure contrast against a rendered page or find a focus trap, and
|
|
* pretending otherwise would be worse than not checking. What it CAN do is catch the class
|
|
* of defect that is invisible to a sighted author and permanent once shipped:
|
|
*
|
|
* 1. **One `<h1>` per page, and no skipped heading level.** The heading tree is the
|
|
* document outline a screen-reader user navigates by. Two `<h1>`s or an `<h2>` under
|
|
* nothing reads as a page with no structure at all.
|
|
* 2. **Every `<img>` has an `alt`.** Not "a non-empty alt": `alt=""` is correct and
|
|
* deliberate for the header mark, which sits inside a link that already says the
|
|
* product's name. A MISSING attribute is what makes a screen reader read the filename.
|
|
* 3. **Every form control has a label.** `<label for>`, a wrapping `<label>`,
|
|
* `aria-label` or `aria-labelledby`. The signup form is the only place on this site
|
|
* where a person is asked to type something, so it is the one place this must hold.
|
|
* 4. **Every link and button has an accessible name.** An icon-only control with no text
|
|
* and no `aria-label` is announced as "link", which is no name at all. The search
|
|
* button is icon-only under 46rem, which is exactly this hazard.
|
|
* 5. **`<html lang>` is set**, or a screen reader reads English prose with whatever voice
|
|
* the reader last used.
|
|
* 6. **One `<main>` per page and a skip link that points at it.** The site's header is a
|
|
* lockup, four links and a search box in front of every page; without a working skip
|
|
* link a keyboard user walks all six on every navigation.
|
|
* 7. **No positive `tabindex`.** It reorders the tab sequence away from the visual one
|
|
* and is almost never what the author meant.
|
|
*
|
|
* Both chromes are checked — the marketing pages and Starlight's forty. Starlight is
|
|
* generally careful, so the docs half is a regression alarm on a dependency rather than a
|
|
* review of our own markup, and it has already earned its place once: it is what would have
|
|
* caught the `<h2>`-without-`<h1>` shape if a docs page had ever lost its title.
|
|
*
|
|
* No token and no network: everything read here is in `dist/`.
|
|
*/
|
|
|
|
import fs from 'node:fs';
|
|
import path from 'node:path';
|
|
import { fileURLToPath } from 'node:url';
|
|
|
|
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
const clientDir = path.join(root, 'dist', 'client');
|
|
|
|
if (!fs.existsSync(clientDir)) {
|
|
console.error('\ncheckA11y: dist/client does not exist. Run `npm run build` first.\n');
|
|
process.exit(1);
|
|
}
|
|
|
|
const failures = [];
|
|
const fail = (page, what, detail) => failures.push({ page, what, detail });
|
|
|
|
const pages = [];
|
|
const walk = (dir) => {
|
|
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
const full = path.join(dir, entry.name);
|
|
if (entry.isDirectory()) walk(full);
|
|
else if (entry.name.endsWith('.html')) pages.push(full);
|
|
}
|
|
};
|
|
walk(clientDir);
|
|
|
|
/* ---------------------------------------------------------------------------------------
|
|
A very small amount of HTML reading
|
|
|
|
Not a parser. Everything below is a tag-level question — does this element carry this
|
|
attribute, what text sits between these two tags — and a regex answers those on
|
|
generated, well-formed output. A DOM parser would be a dependency, and this repository's
|
|
checks are dependency-free on purpose (§12): the reader runs them the same way CI does.
|
|
--------------------------------------------------------------------------------------- */
|
|
|
|
/** Takes the ATTRIBUTE STRING — what is between the tag name and the `>` — not the tag. */
|
|
const attrs = (attrString) => {
|
|
const found = new Map();
|
|
const re = /([a-zA-Z_:][-a-zA-Z0-9_:.]*)(?:\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s"'>]+)))?/g;
|
|
let m;
|
|
while ((m = re.exec(attrString))) {
|
|
found.set(m[1].toLowerCase(), m[2] ?? m[3] ?? m[4] ?? '');
|
|
}
|
|
return found;
|
|
};
|
|
|
|
/** Text a screen reader would announce: markup and comments stripped, entities loosened. */
|
|
const textOf = (html) =>
|
|
html
|
|
.replace(/<!--[\s\S]*?-->/g, '')
|
|
.replace(/<[^>]*>/g, ' ')
|
|
.replace(/&[a-zA-Z#0-9]+;/g, ' ')
|
|
.replace(/\s+/g, ' ')
|
|
.trim();
|
|
|
|
/** An element's own accessible name, near enough for "is there one at all". */
|
|
const named = (tag, inner) => {
|
|
const a = attrs(tag);
|
|
if (a.get('aria-label')?.trim()) return true;
|
|
if (a.get('aria-labelledby')?.trim()) return true;
|
|
if (a.get('title')?.trim()) return true;
|
|
if (textOf(inner)) return true;
|
|
// An image child with alt text names the control.
|
|
for (const img of inner.matchAll(/<img\b([^>]*)>/gi)) {
|
|
if (attrs(img[1]).get('alt')?.trim()) return true;
|
|
}
|
|
// An SVG with a title element does too.
|
|
if (/<svg\b[^>]*>[\s\S]*?<title\b[^>]*>[^<]+<\/title>/i.test(inner)) return true;
|
|
return false;
|
|
};
|
|
|
|
for (const file of pages) {
|
|
const page = '/' + path.relative(clientDir, file).replace(/\\/g, '/');
|
|
|
|
/**
|
|
* Comments are stripped before anything is counted, and that is not a nicety: this
|
|
* repository comments its markup heavily, and several of those comments quote the tags
|
|
* they are explaining. `Base.astro`'s note about `data-pagefind-body` contains the text
|
|
* "<main>", and the first run of this check reported every marketing page as having two
|
|
* `<main>` landmarks because of it. Stripping once, up front, also keeps every offset
|
|
* below measured against the same string.
|
|
*/
|
|
const html = fs.readFileSync(file, 'utf8').replace(/<!--[\s\S]*?-->/g, '');
|
|
|
|
// ── 5. lang ───────────────────────────────────────────────────────────────
|
|
const htmlTag = /<html\b([^>]*)>/i.exec(html);
|
|
if (!htmlTag) fail(page, '<html>', 'has no <html> element');
|
|
else if (!attrs(htmlTag[1]).get('lang')?.trim()) fail(page, '<html>', 'has no lang attribute');
|
|
|
|
// ── 1. headings ───────────────────────────────────────────────────────────
|
|
const headings = [...html.matchAll(/<h([1-6])\b([^>]*)>([\s\S]*?)<\/h\1>/gi)]
|
|
// `aria-hidden` headings are decorative and out of the outline by definition.
|
|
.filter((m) => attrs(m[2]).get('aria-hidden') !== 'true')
|
|
.map((m) => ({ level: Number(m[1]), text: textOf(m[3]) }));
|
|
|
|
const h1s = headings.filter((h) => h.level === 1);
|
|
if (h1s.length === 0) fail(page, 'headings', 'has no <h1>');
|
|
if (h1s.length > 1) {
|
|
fail(page, 'headings', `has ${h1s.length} <h1>s: ${h1s.map((h) => JSON.stringify(h.text)).join(', ')}`);
|
|
}
|
|
|
|
let previous = 0;
|
|
for (const heading of headings) {
|
|
if (previous && heading.level > previous + 1) {
|
|
fail(
|
|
page,
|
|
'headings',
|
|
`jumps from h${previous} to h${heading.level} at ${JSON.stringify(heading.text.slice(0, 50))}`,
|
|
);
|
|
}
|
|
previous = heading.level;
|
|
}
|
|
|
|
// ── 2. images ─────────────────────────────────────────────────────────────
|
|
for (const img of html.matchAll(/<img\b([^>]*)>/gi)) {
|
|
const a = attrs(img[1]);
|
|
if (!a.has('alt')) {
|
|
fail(page, '<img>', `has no alt attribute: src=${a.get('src') ?? '(none)'}`);
|
|
}
|
|
}
|
|
|
|
// ── 3. form controls ──────────────────────────────────────────────────────
|
|
const labelledIds = new Set(
|
|
[...html.matchAll(/<label\b([^>]*)>/gi)]
|
|
.map((m) => attrs(m[1]).get('for'))
|
|
.filter(Boolean),
|
|
);
|
|
/**
|
|
* A control wrapped in its own `<label>` needs no `for` and — this is the part that took
|
|
* a wrong answer to find — needs no `id` either, so it cannot be recorded by id. Starlight
|
|
* labels its theme and language selects exactly this way. What is recorded instead is the
|
|
* character offset of each wrapped control, which identifies it uniquely without
|
|
* requiring it to have any attributes at all.
|
|
*/
|
|
const wrappedAt = new Set();
|
|
for (const label of html.matchAll(/<label\b[^>]*>([\s\S]*?)<\/label>/gi)) {
|
|
const base = label.index + label[0].indexOf(label[1]);
|
|
for (const control of label[1].matchAll(/<(input|select|textarea)\b[^>]*>/gi)) {
|
|
wrappedAt.add(base + control.index);
|
|
}
|
|
}
|
|
|
|
for (const control of html.matchAll(/<(input|select|textarea)\b([^>]*)>/gi)) {
|
|
const a = attrs(control[2]);
|
|
const type = (a.get('type') ?? 'text').toLowerCase();
|
|
// These are not things a person types into and are named by other means.
|
|
if (['hidden', 'submit', 'button', 'reset', 'image'].includes(type)) continue;
|
|
|
|
const id = a.get('id');
|
|
const hasLabel =
|
|
wrappedAt.has(control.index) ||
|
|
(id && labelledIds.has(id)) ||
|
|
a.get('aria-label')?.trim() ||
|
|
a.get('aria-labelledby')?.trim() ||
|
|
a.get('title')?.trim();
|
|
|
|
if (!hasLabel) {
|
|
fail(
|
|
page,
|
|
`<${control[1]}>`,
|
|
`has no label: ${id ? `id="${id}"` : `name="${a.get('name') ?? '(none)'}"`} — ` +
|
|
'a placeholder is not a label',
|
|
);
|
|
}
|
|
}
|
|
|
|
// ── 4. link and button names ──────────────────────────────────────────────
|
|
for (const [, tag, attrString, inner] of html.matchAll(/<(a|button)\b([^>]*)>([\s\S]*?)<\/\1>/gi)) {
|
|
const a = attrs(attrString);
|
|
if (a.get('aria-hidden') === 'true') continue;
|
|
// An <a> with no href is not a link; it is a target for one.
|
|
if (tag.toLowerCase() === 'a' && !a.has('href')) continue;
|
|
if (named(attrString, inner)) continue;
|
|
|
|
fail(
|
|
page,
|
|
`<${tag}>`,
|
|
`has no accessible name: ${a.get('href') ? `href="${a.get('href')}"` : `class="${a.get('class') ?? ''}"`}`,
|
|
);
|
|
}
|
|
|
|
// ── 6. main and the skip link ─────────────────────────────────────────────
|
|
const mains = [...html.matchAll(/<main\b([^>]*)>/gi)];
|
|
if (mains.length === 0) fail(page, '<main>', 'has no <main> landmark');
|
|
if (mains.length > 1) fail(page, '<main>', `has ${mains.length} <main> elements`);
|
|
|
|
const skip = /<a\b([^>]*class="[^"]*skip-link[^"]*"[^>]*)>/i.exec(html);
|
|
if (skip) {
|
|
const target = attrs(skip[1]).get('href') ?? '';
|
|
if (!target.startsWith('#')) {
|
|
fail(page, 'skip link', `points at ${JSON.stringify(target)}, which is not an in-page anchor`);
|
|
} else {
|
|
const id = target.slice(1);
|
|
if (!new RegExp(`\\bid=["']${id}["']`).test(html)) {
|
|
fail(page, 'skip link', `points at #${id}, and nothing on the page has that id`);
|
|
}
|
|
}
|
|
}
|
|
|
|
// ── 7. positive tabindex ──────────────────────────────────────────────────
|
|
for (const m of html.matchAll(/\btabindex\s*=\s*["']?(-?\d+)/gi)) {
|
|
if (Number(m[1]) > 0) {
|
|
fail(page, 'tabindex', `is ${m[1]} — a positive tabindex reorders the tab sequence`);
|
|
}
|
|
}
|
|
}
|
|
|
|
/* ---------------------------------------------------------------------------------------
|
|
Report
|
|
--------------------------------------------------------------------------------------- */
|
|
|
|
if (failures.length === 0) {
|
|
console.log(`checkA11y: ${pages.length} built pages pass all seven structural checks.`);
|
|
} else {
|
|
// Grouped by page: a shared component's defect otherwise prints fifty times and buries
|
|
// the one page that has a real problem of its own.
|
|
const byPage = new Map();
|
|
for (const f of failures) {
|
|
if (!byPage.has(f.page)) byPage.set(f.page, []);
|
|
byPage.get(f.page).push(f);
|
|
}
|
|
|
|
console.error(`\ncheckA11y: ${failures.length} problem(s) across ${byPage.size} page(s):\n`);
|
|
for (const [page, items] of byPage) {
|
|
console.error(` ${page}`);
|
|
for (const item of items) console.error(` ✗ ${item.what}: ${item.detail}`);
|
|
}
|
|
console.error(`
|
|
These are structural, so they are the same in every browser and for every reader. A defect
|
|
repeated across many pages is usually one shared component — fix it there rather than on
|
|
each page.
|
|
`);
|
|
process.exit(1);
|
|
}
|