Files
runicgateway.com/scripts/checkA11y.mjs
wtclaude e71ff4acd4
All checks were successful
PR checks / checks (pull_request) Successful in 9m36s
feat(polish): phase 10 — search, accessibility, SEO and a real CSP
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>
2026-08-25 14:14:53 -05:00

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);
}