ultra11y 4.2.0 → 4.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/.codex-plugin/plugin.json +1 -1
- package/.opencode/plugins/ultra11y.js +1 -1
- package/README.md +40 -4
- package/package.json +1 -1
- package/scripts/build-standards.mjs +290 -1
- package/scripts/ultra11y.mjs +4131 -786
- package/scripts/vendor/wcag-2.2-fr.json +945 -1
- package/scripts/vendor/wcag-2.2-sc-universe.json +1 -1
- package/scripts/vendor/wcag-2.2-text.json +749 -0
- package/skills/review-a11y/SKILL.md +1 -1
- package/skills/review-a11y/scripts/ultra11y.mjs +4131 -786
- package/skills/ultra11y/SKILL.md +13 -4
- package/skills/ultra11y/references/guidance.md +33 -7
- package/skills/ultra11y/references/harnesses.md +10 -0
- package/skills/ultra11y/references/mcp.md +215 -0
- package/skills/ultra11y/references/packs.md +8 -0
- package/skills/ultra11y/references/standards.md +18 -0
- package/skills/ultra11y/scripts/ultra11y.mjs +4131 -786
- package/src/adjudicate.ts +9 -15
- package/src/adjudication-data.ts +38 -0
- package/src/check.ts +51 -1
- package/src/cli.ts +6 -2
- package/src/config.ts +27 -4
- package/src/criteria-view.ts +463 -0
- package/src/criteria.ts +14 -11
- package/src/data/guidance/wcag.json +268 -0
- package/src/data/wcag-universe.json +1 -1
- package/src/data/wcag.json +1112 -55
- package/src/guidance/index.ts +11 -0
- package/src/guidance/resolve.ts +71 -0
- package/src/mcp/handlers.ts +160 -15
- package/src/mcp/prompts.ts +13 -2
- package/src/mcp/server.ts +18 -4
- package/src/mcp/standards-resources.ts +174 -0
- package/src/mcp/tools.ts +140 -6
- package/src/method-view.ts +227 -0
- package/src/prd.ts +5 -16
- package/src/standards/coverage.ts +237 -0
- package/src/standards/index.ts +1 -0
- package/src/standards/pack.ts +19 -0
- package/src/standards/registry.ts +115 -9
- package/src/types.ts +24 -1
- package/src/vendor/codeindex-engine.d.mts +324 -3
- package/src/vendor/codeindex-engine.mjs +1146 -175
- package/src/vendor/engine.meta.json +5 -5
- package/src/wcag.ts +21 -1
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
"name": "ultra11y",
|
|
11
11
|
"source": "./",
|
|
12
12
|
"description": "Automatic accessibility review of the code under change before every commit, push and pull request, plus full WCAG 2.2 AA / RGAA audits and conformance deliverables on demand.",
|
|
13
|
-
"version": "4.
|
|
13
|
+
"version": "4.4.0",
|
|
14
14
|
"author": { "name": "maxgfr" },
|
|
15
15
|
"homepage": "https://github.com/maxgfr/ultra11y",
|
|
16
16
|
"repository": "https://github.com/maxgfr/ultra11y",
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ultra11y",
|
|
3
3
|
"description": "Accessibility for WCAG 2.2 AA and country standards (RGAA…): an automatic review of the code under change before every commit, push and pull request, plus full audits and conformance deliverables on demand.",
|
|
4
|
-
"version": "4.
|
|
4
|
+
"version": "4.4.0",
|
|
5
5
|
"author": { "name": "maxgfr" },
|
|
6
6
|
"homepage": "https://github.com/maxgfr/ultra11y",
|
|
7
7
|
"repository": "https://github.com/maxgfr/ultra11y",
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ultra11y",
|
|
3
3
|
"description": "Accessibility for WCAG 2.2 AA and country standards (RGAA…): an automatic review of the code under change before every commit, push and pull request, plus full audits and conformance deliverables on demand.",
|
|
4
|
-
"version": "4.
|
|
4
|
+
"version": "4.4.0",
|
|
5
5
|
"author": { "name": "maxgfr", "url": "https://github.com/maxgfr" },
|
|
6
6
|
"homepage": "https://github.com/maxgfr/ultra11y",
|
|
7
7
|
"repository": "https://github.com/maxgfr/ultra11y",
|
|
@@ -27,7 +27,7 @@ import { fileURLToPath } from "node:url";
|
|
|
27
27
|
// Ownership marker: `ultra11y install --opencode` refuses to overwrite a plugin file that
|
|
28
28
|
// does not carry it, so a user's own ultra11y.js is never clobbered.
|
|
29
29
|
const ULTRA11Y_OPENCODE_PLUGIN = "ULTRA11Y_OPENCODE_PLUGIN";
|
|
30
|
-
const ULTRA11Y_PLUGIN_VERSION = "4.
|
|
30
|
+
const ULTRA11Y_PLUGIN_VERSION = "4.4.0";
|
|
31
31
|
|
|
32
32
|
const here = dirname(fileURLToPath(import.meta.url));
|
|
33
33
|
|
package/README.md
CHANGED
|
@@ -196,15 +196,15 @@ the one output an accessibility tool must never produce.
|
|
|
196
196
|
|
|
197
197
|
### Tools
|
|
198
198
|
|
|
199
|
-
|
|
199
|
+
Sixteen read tools, in two blocks. **The audit block** tells you what the page does:
|
|
200
200
|
|
|
201
201
|
| Tool | What it does |
|
|
202
202
|
|------|--------------|
|
|
203
203
|
| `ultra11y_audit` | The static pass — findings keyed by success criterion |
|
|
204
204
|
| `ultra11y_adjudicate` | The criteria the engine **cannot** decide, with their evidence |
|
|
205
|
-
| `ultra11y_criteria` | The offline standards reference — look a criterion up, don't recall it |
|
|
206
205
|
| `ultra11y_report` | The dated conformance report, WCAG or a country pack |
|
|
207
206
|
| `ultra11y_prd` | Non-conformities → remediation units with effort |
|
|
207
|
+
| `ultra11y_tickets` | The same backlog as a ticket plan — dry-run, files nothing |
|
|
208
208
|
| `ultra11y_check` | The gate: nothing asserted conformant that was never tested |
|
|
209
209
|
| `ultra11y_verify` | Claim↔evidence worklist |
|
|
210
210
|
| `ultra11y_pack_check` | Validate a country standards pack against what it really ships |
|
|
@@ -212,6 +212,28 @@ Eleven read tools. `ultra11y_audit` is the entry point:
|
|
|
212
212
|
| `ultra11y_pages` | The per-page view: the criterion × page grid, or one dossier per page |
|
|
213
213
|
| `ultra11y_read` | A file, or a line range, from the project |
|
|
214
214
|
|
|
215
|
+
**The reference block** tells you what the standard requires — the half that keeps an agent
|
|
216
|
+
from auditing out of memory. `cwd` is optional on all five; it selects whose packs are visible:
|
|
217
|
+
|
|
218
|
+
| Tool | What it does |
|
|
219
|
+
|------|--------------|
|
|
220
|
+
| `ultra11y_standards` | Which standards exist here, and how much of each any engine can decide |
|
|
221
|
+
| `ultra11y_criteria` | One criterion in full — wording, **numbered tests**, techniques, mapping, defined terms — or the index, or one theme |
|
|
222
|
+
| `ultra11y_glossary` | What a term the standard **defines** means, and which criteria it governs |
|
|
223
|
+
| `ultra11y_guidance` | The before/after implementation pattern, inherited through the WCAG mapping when the pack has none |
|
|
224
|
+
| `ultra11y_method` | The work plan: what the engine settles, what needs a render, what is yours |
|
|
225
|
+
|
|
226
|
+
`ultra11y_method` is the one to call first. It partitions the standard into evidence tiers —
|
|
227
|
+
`source`, `cross-file`, `rendered-page`, `browser`, `judgment`, `out-of-scope` — from the
|
|
228
|
+
engine's own per-criterion rule applicability, never from guessing at the wording of a test.
|
|
229
|
+
For RGAA it reports that **58 of 106 criteria declare that no engine rule can evidence them**.
|
|
230
|
+
That is the standard saying they are yours, and a plan that hides it reads as coverage that
|
|
231
|
+
does not exist.
|
|
232
|
+
|
|
233
|
+
`standard` carries no enum, deliberately: a country pack arrives with a project, so an enum
|
|
234
|
+
pinned when the tool list was built would reject a pack that is perfectly valid for the
|
|
235
|
+
project being asked about. The handler validates against the registry and names what it knows.
|
|
236
|
+
|
|
215
237
|
`--allow-write` additionally exposes `ultra11y_fix` (safe codemods, dry-run by
|
|
216
238
|
default) and `ultra11y_init` (hook/CI/baseline) — the tools that change **your**
|
|
217
239
|
project. `ultra11y_scan` is declared but declines over MCP: it drives a headless
|
|
@@ -237,12 +259,26 @@ Each carries the coverage arithmetic: of the 55 WCAG 2.2 AA criteria the static
|
|
|
237
259
|
engine decides a handful, a browser decides fourteen, and **thirty-eight are
|
|
238
260
|
yours**.
|
|
239
261
|
|
|
240
|
-
### Resources — the
|
|
262
|
+
### Resources — the documentation, and the standards themselves
|
|
241
263
|
|
|
242
|
-
`SKILL.md` and all
|
|
264
|
+
`SKILL.md` and all 37 `references/*.md` are served under `skill://`, read off
|
|
243
265
|
disk at request time — so a documentation fix reaches every client without a
|
|
244
266
|
rebuild.
|
|
245
267
|
|
|
268
|
+
The standards are served too, under `std://`, because in MCP documentation is a
|
|
269
|
+
*resource*, not a tool call:
|
|
270
|
+
|
|
271
|
+
```
|
|
272
|
+
std://rgaa/criteria/8.3 std://rgaa/glossary/lien std://rgaa/method
|
|
273
|
+
std://rgaa/themes/8 std://rgaa/guidance/13.2 std://rgaa/pack.json
|
|
274
|
+
std://wcag/criteria/1.4.3 std://wcag/glossary std://wcag/method
|
|
275
|
+
```
|
|
276
|
+
|
|
277
|
+
`resources/list` carries a bounded index per standard; the per-item URIs are
|
|
278
|
+
**templates** (`resources/templates/list`), because enumerating RGAA's 106
|
|
279
|
+
criteria and 119 glossary terms would bloat every client's listing and go stale
|
|
280
|
+
the moment a project's own pack registers.
|
|
281
|
+
|
|
246
282
|
Two things worth knowing:
|
|
247
283
|
|
|
248
284
|
- **A criterion nobody tested is untested, never conformant.** Every tool
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ultra11y",
|
|
3
|
-
"version": "4.
|
|
3
|
+
"version": "4.4.0",
|
|
4
4
|
"description": "Audit HTML/CSS/JSX against WCAG 2.2 AA and produce a dated compliance report, or author/review accessible markup (native-HTML-first). A skills.sh agent skill: an install-free static engine (bundled, no npm install) plus the agent's judgment, with check/verify gates against hallucinated non-conformities, and pluggable in-repo standards packs (RGAA France first; Section 508 & EN 301 549 next).",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"packageManager": "pnpm@10.33.0",
|
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
// node scripts/build-standards.mjs --refresh <dir> # re-derive the vendored AA snapshot from a w3c/wcag checkout
|
|
15
15
|
// node scripts/build-standards.mjs --refresh-universe # re-fetch (network) the vendored FULL SC universe (all levels + removed 4.1.1)
|
|
16
16
|
// node scripts/build-standards.mjs --refresh-fr # re-fetch (network) the vendored French SC/guideline/principle titles
|
|
17
|
+
// node scripts/build-standards.mjs --refresh-text # re-fetch (network) the vendored NORMATIVE SC text + the WCAG glossary
|
|
17
18
|
import { writeFileSync, readFileSync, readdirSync, existsSync, mkdirSync } from "node:fs";
|
|
18
19
|
import { execFileSync } from "node:child_process";
|
|
19
20
|
import { dirname, join } from "node:path";
|
|
@@ -24,6 +25,7 @@ const DATA = join(root, "src", "data");
|
|
|
24
25
|
const VENDOR = join(root, "scripts", "vendor", "wcag-2.2-sc.json");
|
|
25
26
|
const VENDOR_UNIVERSE = join(root, "scripts", "vendor", "wcag-2.2-sc-universe.json");
|
|
26
27
|
const VENDOR_FR = join(root, "scripts", "vendor", "wcag-2.2-fr.json");
|
|
28
|
+
const VENDOR_TEXT = join(root, "scripts", "vendor", "wcag-2.2-text.json");
|
|
27
29
|
const PACKS_DIR = join(DATA, "standards");
|
|
28
30
|
const BIOME = join(root, "node_modules", ".bin", "biome");
|
|
29
31
|
|
|
@@ -235,6 +237,88 @@ function deHtmlFr(s) {
|
|
|
235
237
|
.trim();
|
|
236
238
|
}
|
|
237
239
|
|
|
240
|
+
// The French page's own chrome, normalized to the shapes `deHtmlText` already knows.
|
|
241
|
+
//
|
|
242
|
+
// The single-page translation wraps a note or an example in
|
|
243
|
+
// `<div class="note"><div role="heading">…Note 1…</div><p>…</p></div>`, where the English
|
|
244
|
+
// fragments use a flat `<p class="note">`. Collapsing the heading AND the paragraph opener
|
|
245
|
+
// that follows keeps the label on the same line as the text it labels — "Note : …", not a
|
|
246
|
+
// bare "Note :" followed by an orphaned sentence.
|
|
247
|
+
function deHtmlFrBody(html, opts) {
|
|
248
|
+
let s = html;
|
|
249
|
+
s = s.replace(/<a class="self-link"[\s\S]*?<\/a>/gi, "");
|
|
250
|
+
s = s.replace(/<div class="doclinks">[\s\S]*?<\/div>/gi, "");
|
|
251
|
+
s = s.replace(/<div class="(note|example)"[^>]*>\s*<div role="heading"[^>]*>([\s\S]*?)<\/div>\s*(?:<p[^>]*>)?/gi, (_, _kind, heading) => {
|
|
252
|
+
// "Note 1" / "Note 2" are numbered per document; the number is noise in a lookup.
|
|
253
|
+
const label = heading
|
|
254
|
+
.replace(/<[^>]+>/g, "")
|
|
255
|
+
.replace(/\s+/g, " ")
|
|
256
|
+
.trim()
|
|
257
|
+
.replace(/\s*\d+$/, "");
|
|
258
|
+
return `${label} : `;
|
|
259
|
+
});
|
|
260
|
+
return deHtmlText(s, opts);
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/** The criterion bodies and the glossary, off the French single-page translation. */
|
|
264
|
+
function deriveFrBodies(html, snap) {
|
|
265
|
+
// Every numbered heading, in document order: an SC's body is what sits between its own
|
|
266
|
+
// heading and the next one. The page numbers headings exactly like the English source.
|
|
267
|
+
const HEAD = /<h([234])[^>]*>\s*<bdi class="secno">([^<]*)<\/bdi>([\s\S]*?)<\/h\1>/g;
|
|
268
|
+
const heads = [];
|
|
269
|
+
let m;
|
|
270
|
+
while ((m = HEAD.exec(html))) {
|
|
271
|
+
const id = (m[2].match(/(\d+(?:\.\d+)*)/) || [])[1];
|
|
272
|
+
heads.push({ level: Number(m[1]), id, end: m.index + m[0].length, start: m.index });
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
const criteriaText = {};
|
|
276
|
+
const criteriaTerms = {};
|
|
277
|
+
for (let i = 0; i < heads.length; i++) {
|
|
278
|
+
const h = heads[i];
|
|
279
|
+
if (h.level !== 4 || !h.id || h.id.split(".").length !== 3) continue;
|
|
280
|
+
const slice = html.slice(h.end, heads[i + 1]?.start ?? html.length);
|
|
281
|
+
const body = deHtmlFrBody(slice);
|
|
282
|
+
if (body) criteriaText[h.id] = body;
|
|
283
|
+
// The FR page names its definitions with its OWN slugs — often the plural
|
|
284
|
+
// ("user-agents" where the English source file is "user-agent"), sometimes a different
|
|
285
|
+
// phrase entirely ("purpose-of-each-link" for "link-purpose"). Mapping one onto the
|
|
286
|
+
// other would be a guess, so each language keeps its own term list and its own
|
|
287
|
+
// glossary keys, and neither has to know about the other.
|
|
288
|
+
const cited = [...new Set([...slice.matchAll(/href="#dfn-([a-z0-9-]+)"/g)].map((x) => x[1]))];
|
|
289
|
+
if (cited.length) criteriaTerms[h.id] = cited;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
// The glossary: `<dfn id="dfn-<slug>">title</dfn></dt>` then the definition up to the
|
|
293
|
+
// next term. The slugs are the SAME as the English source's file names, so the two
|
|
294
|
+
// languages key identically and a criterion's term links resolve in either.
|
|
295
|
+
// Attribute order is not guaranteed: the FR page writes `data-lt` before `id`.
|
|
296
|
+
const DFN = /<dfn\b[^>]*\bid="dfn-([a-z0-9-]+)"[^>]*>([\s\S]*?)<\/dfn>/g;
|
|
297
|
+
const glossary = {};
|
|
298
|
+
while ((m = DFN.exec(html))) {
|
|
299
|
+
const title = m[2]
|
|
300
|
+
.replace(/<[^>]+>/g, "")
|
|
301
|
+
.replace(/\s+/g, " ")
|
|
302
|
+
.trim();
|
|
303
|
+
const after = html.slice(m.index + m[0].length);
|
|
304
|
+
const stop = after.search(/<dt[\s>]|<\/dl>/i);
|
|
305
|
+
const body = deHtmlFrBody(after.slice(0, stop === -1 ? 8000 : stop));
|
|
306
|
+
if (title && body) glossary[m[1]] = { title, body };
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
// Same completeness rule as the titles: every SHIPPED criterion carries its French body
|
|
310
|
+
// or the build refuses. A half-translated reference is worse than a known-absent one,
|
|
311
|
+
// because the reader cannot tell which criteria they are missing.
|
|
312
|
+
const missing = snap.criteria.filter((c) => !criteriaText[c.sc]).map((c) => c.sc);
|
|
313
|
+
if (missing.length) {
|
|
314
|
+
console.error(
|
|
315
|
+
`build-standards --refresh-fr: no French body found on ${FR_SOURCE} for SC: ${missing.join(", ")}. Nothing invented — refusing to vendor a partial set.`,
|
|
316
|
+
);
|
|
317
|
+
process.exit(1);
|
|
318
|
+
}
|
|
319
|
+
return { criteriaText, criteriaTerms, glossary };
|
|
320
|
+
}
|
|
321
|
+
|
|
238
322
|
async function deriveFr() {
|
|
239
323
|
if (!existsSync(VENDOR)) {
|
|
240
324
|
console.error(`build-standards --refresh-fr: missing vendored English snapshot ${VENDOR}. Run: node scripts/build-standards.mjs --refresh <w3c/wcag checkout> first.`);
|
|
@@ -290,21 +374,178 @@ async function deriveFr() {
|
|
|
290
374
|
process.exit(1);
|
|
291
375
|
}
|
|
292
376
|
|
|
377
|
+
// The same page carries each criterion's NORMATIVE BODY and the glossary, not just the
|
|
378
|
+
// titles. Vendoring only the titles left `--lang fr` rendering a French heading over
|
|
379
|
+
// English requirement prose — the jarring half-translation an RGAA auditor reads first.
|
|
380
|
+
const { criteriaText, criteriaTerms, glossary } = deriveFrBodies(html, snap);
|
|
381
|
+
|
|
293
382
|
const out = {
|
|
294
383
|
source: FR_SOURCE,
|
|
295
384
|
fetchedAt: new Date().toISOString().slice(0, 10),
|
|
296
385
|
principles,
|
|
297
386
|
guidelines,
|
|
298
387
|
criteria,
|
|
388
|
+
criteriaText,
|
|
389
|
+
criteriaTerms,
|
|
390
|
+
glossary,
|
|
299
391
|
};
|
|
300
392
|
mkdirSync(dirname(VENDOR_FR), { recursive: true });
|
|
301
393
|
writeFileSync(VENDOR_FR, JSON.stringify(out, null, 2) + "\n");
|
|
302
394
|
console.log(
|
|
303
|
-
`build-standards --refresh-fr: ${Object.keys(criteria).length} SC + ${Object.keys(guidelines).length} guideline + ${Object.keys(principles).length} principle French titles
|
|
395
|
+
`build-standards --refresh-fr: ${Object.keys(criteria).length} SC + ${Object.keys(guidelines).length} guideline + ${Object.keys(principles).length} principle French titles, ` +
|
|
396
|
+
`${Object.keys(criteriaText).length} SC bodies, ${Object.keys(glossary).length} glossary terms → ${VENDOR_FR}`,
|
|
304
397
|
);
|
|
305
398
|
return out;
|
|
306
399
|
}
|
|
307
400
|
|
|
401
|
+
// ---------------------------------------------------------------------------
|
|
402
|
+
// Text mode: the NORMATIVE WORDING of every shipped success criterion, plus the terms
|
|
403
|
+
// WCAG itself defines.
|
|
404
|
+
//
|
|
405
|
+
// The dataset used to carry only ids, titles and an Understanding URL — so an offline
|
|
406
|
+
// agent could name a criterion but not read it, and had to recall the requirement from
|
|
407
|
+
// memory. That is exactly how invented non-conformities get written. The RGAA pack ships
|
|
408
|
+
// its full criterion text and a 119-entry glossary; the worldwide core shipped neither.
|
|
409
|
+
//
|
|
410
|
+
// Nothing new is fetched: `deriveUniverse` already downloads these very fragments and
|
|
411
|
+
// keeps only the <h4> and the conformance level. WCAG 2.2 © W3C, reused under the W3C
|
|
412
|
+
// Document License; see NOTICE.
|
|
413
|
+
//
|
|
414
|
+
// node scripts/build-standards.mjs --refresh-text
|
|
415
|
+
// ---------------------------------------------------------------------------
|
|
416
|
+
const ENTITIES = {
|
|
417
|
+
" ": " ",
|
|
418
|
+
"&": "&",
|
|
419
|
+
"<": "<",
|
|
420
|
+
">": ">",
|
|
421
|
+
""": '"',
|
|
422
|
+
"'": "'",
|
|
423
|
+
"’": "’",
|
|
424
|
+
"‘": "‘",
|
|
425
|
+
"“": "“",
|
|
426
|
+
"”": "”",
|
|
427
|
+
"—": "—",
|
|
428
|
+
"–": "–",
|
|
429
|
+
"…": "…",
|
|
430
|
+
"×": "×",
|
|
431
|
+
};
|
|
432
|
+
|
|
433
|
+
function decodeEntities(s) {
|
|
434
|
+
return s
|
|
435
|
+
.replace(/&#(\d+);/g, (_, d) => String.fromCodePoint(Number(d)))
|
|
436
|
+
.replace(/&#x([0-9a-f]+);/gi, (_, h) => String.fromCodePoint(Number.parseInt(h, 16)))
|
|
437
|
+
.replace(/&[a-z]+;/gi, (m) => ENTITIES[m] ?? m);
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
// W3C fragment → normative plaintext. Structure that CHANGES THE READING is kept as its
|
|
441
|
+
// own labelled line — an exception's name ("Large Text:") decides whether the exception
|
|
442
|
+
// applies, and a note is not normative the way the sentence above it is. Everything else
|
|
443
|
+
// is flattened, because the source wraps mid-sentence.
|
|
444
|
+
function deHtmlText(html, { dropDfn = false } = {}) {
|
|
445
|
+
const SEP = "";
|
|
446
|
+
let s = html;
|
|
447
|
+
s = s.replace(/<!--[\s\S]*?-->/g, "");
|
|
448
|
+
// The fragment's own chrome: title, conformance level, the 2.2 "New"/"Updated" marker.
|
|
449
|
+
s = s.replace(/<h4>[\s\S]*?<\/h4>/gi, "");
|
|
450
|
+
s = s.replace(/<p class="conformance-level">[\s\S]*?<\/p>/gi, "");
|
|
451
|
+
s = s.replace(/<p class="change">[\s\S]*?<\/p>/gi, "");
|
|
452
|
+
if (dropDfn) s = s.replace(/<dt>[\s\S]*?<\/dt>/i, "");
|
|
453
|
+
s = s.replace(/<p class="note">/gi, `${SEP}Note: `);
|
|
454
|
+
s = s.replace(/<aside class="example">/gi, `${SEP}Example: `);
|
|
455
|
+
s = s.replace(/<dt>([\s\S]*?)<\/dt>\s*<dd>/gi, (_, t) => `${SEP}${t.replace(/<[^>]+>/g, "").trim()}: `);
|
|
456
|
+
s = s.replace(/<li>/gi, `${SEP}• `);
|
|
457
|
+
s = s.replace(/<\/(p|dd|li|dl|ul|ol|aside|section|div|blockquote)>/gi, SEP);
|
|
458
|
+
s = s.replace(/<(p|dd|aside|div|blockquote)\b[^>]*>/gi, SEP);
|
|
459
|
+
s = s.replace(/<[^>]+>/g, "");
|
|
460
|
+
s = decodeEntities(s);
|
|
461
|
+
return s
|
|
462
|
+
.split(SEP)
|
|
463
|
+
.map((block) => block.replace(/\s+/g, " ").trim())
|
|
464
|
+
.filter(Boolean)
|
|
465
|
+
.join("\n");
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
const VER_OF = { "2.0": "20", "2.1": "21", "2.2": "22" };
|
|
469
|
+
|
|
470
|
+
async function fetchJson(url) {
|
|
471
|
+
const r = await fetch(url, { headers: { accept: "application/vnd.github+json" } });
|
|
472
|
+
if (!r.ok) throw new Error(`build-standards --refresh-text: GET ${url} → HTTP ${r.status}`);
|
|
473
|
+
return r.json();
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
async function deriveText() {
|
|
477
|
+
if (!existsSync(VENDOR)) {
|
|
478
|
+
console.error(`build-standards --refresh-text: missing vendored snapshot ${VENDOR}. Run --refresh first.`);
|
|
479
|
+
process.exit(1);
|
|
480
|
+
}
|
|
481
|
+
const snap = JSON.parse(readFileSync(VENDOR, "utf8"));
|
|
482
|
+
|
|
483
|
+
// The terms WCAG defines, across all three version directories. Built FIRST, because a
|
|
484
|
+
// criterion's wording links to them and those links are what makes the definitions
|
|
485
|
+
// findable from the criterion.
|
|
486
|
+
const glossary = {};
|
|
487
|
+
const bySurface = new Map();
|
|
488
|
+
const fold = (s) =>
|
|
489
|
+
s
|
|
490
|
+
.toLowerCase()
|
|
491
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
492
|
+
.replace(/^-+|-+$/g, "");
|
|
493
|
+
for (const ver of ["20", "21", "22"]) {
|
|
494
|
+
const listing = await fetchJson(`https://api.github.com/repos/w3c/wcag/contents/guidelines/terms/${ver}`);
|
|
495
|
+
for (const file of listing) {
|
|
496
|
+
if (file.type !== "file" || !file.name.endsWith(".html")) continue;
|
|
497
|
+
const html = await fetchText(`guidelines/terms/${ver}/${file.name}`);
|
|
498
|
+
const dfn = html.match(/<dfn([^>]*)>([\s\S]*?)<\/dfn>/);
|
|
499
|
+
const title = dfn?.[2]?.replace(/<[^>]+>/g, "").replace(/\s+/g, " ").trim();
|
|
500
|
+
const body = deHtmlText(html, { dropDfn: true });
|
|
501
|
+
if (!title || !body) {
|
|
502
|
+
console.error(`build-standards --refresh-text: terms/${ver}/${file.name} has no <dfn> title or no body — refusing to vendor a partial glossary.`);
|
|
503
|
+
process.exit(1);
|
|
504
|
+
}
|
|
505
|
+
// Keyed by the W3C's own dfn slug, so a criterion's prose can point at it.
|
|
506
|
+
const slug = file.name.replace(/\.html$/, "");
|
|
507
|
+
glossary[slug] = { title, body };
|
|
508
|
+
// `data-lt` lists the other surface forms the spec links this term by ("accessibility
|
|
509
|
+
// support" → accessibility-supported), so a link in a criterion resolves either way.
|
|
510
|
+
const lt = (dfn?.[1]?.match(/data-lt="([^"]*)"/) || [])[1];
|
|
511
|
+
for (const surface of [title, ...(lt ? lt.split("|") : [])]) bySurface.set(fold(surface), slug);
|
|
512
|
+
}
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
const criteria = {};
|
|
516
|
+
const terms = {};
|
|
517
|
+
for (const c of snap.criteria) {
|
|
518
|
+
const ver = VER_OF[c.addedIn];
|
|
519
|
+
if (!ver) throw new Error(`build-standards --refresh-text: unknown addedIn "${c.addedIn}" for SC ${c.sc}`);
|
|
520
|
+
const html = await fetchText(`guidelines/sc/${ver}/${c.slug}.html`);
|
|
521
|
+
const text = deHtmlText(html);
|
|
522
|
+
if (!text) throw new Error(`build-standards --refresh-text: empty normative text for SC ${c.sc} (${c.slug})`);
|
|
523
|
+
criteria[c.sc] = text;
|
|
524
|
+
// A bare `<a>` with no href IS a glossary reference in the W3C source — that is how the
|
|
525
|
+
// spec marks a defined term. Resolved here rather than by matching prose, so a term is
|
|
526
|
+
// attached because the criterion cites it, never because a word happened to appear.
|
|
527
|
+
const cited = new Set();
|
|
528
|
+
for (const m of html.matchAll(/<a>([\s\S]*?)<\/a>/g)) {
|
|
529
|
+
const slug = bySurface.get(fold(m[1].replace(/<[^>]+>/g, "")));
|
|
530
|
+
if (slug) cited.add(slug);
|
|
531
|
+
}
|
|
532
|
+
if (cited.size) terms[c.sc] = [...cited];
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
const out = {
|
|
536
|
+
source: "https://github.com/w3c/wcag",
|
|
537
|
+
license: "W3C Document License",
|
|
538
|
+
fetchedAt: new Date().toISOString().slice(0, 10),
|
|
539
|
+
criteria,
|
|
540
|
+
terms,
|
|
541
|
+
glossary,
|
|
542
|
+
};
|
|
543
|
+
mkdirSync(dirname(VENDOR_TEXT), { recursive: true });
|
|
544
|
+
writeFileSync(VENDOR_TEXT, JSON.stringify(out, null, 2) + "\n");
|
|
545
|
+
console.log(`build-standards --refresh-text: ${Object.keys(criteria).length} SC texts + ${Object.keys(glossary).length} glossary terms → ${VENDOR_TEXT}`);
|
|
546
|
+
return out;
|
|
547
|
+
}
|
|
548
|
+
|
|
308
549
|
// ---------------------------------------------------------------------------
|
|
309
550
|
// Engine-specific decoration (the only hand-maintained part: which SCs the static
|
|
310
551
|
// engine can fully decide, which need a rendered DOM, and the per-SC rule coverage).
|
|
@@ -453,8 +694,13 @@ function build() {
|
|
|
453
694
|
console.error(`build-standards: missing vendored French titles ${VENDOR_FR}. Run: node scripts/build-standards.mjs --refresh-fr`);
|
|
454
695
|
process.exit(1);
|
|
455
696
|
}
|
|
697
|
+
if (!existsSync(VENDOR_TEXT)) {
|
|
698
|
+
console.error(`build-standards: missing vendored normative text ${VENDOR_TEXT}. Run: node scripts/build-standards.mjs --refresh-text`);
|
|
699
|
+
process.exit(1);
|
|
700
|
+
}
|
|
456
701
|
const snap = JSON.parse(readFileSync(VENDOR, "utf8"));
|
|
457
702
|
const fr = JSON.parse(readFileSync(VENDOR_FR, "utf8"));
|
|
703
|
+
const text = JSON.parse(readFileSync(VENDOR_TEXT, "utf8"));
|
|
458
704
|
const { techniques, sources } = seedFromPacks();
|
|
459
705
|
|
|
460
706
|
// Completeness guard: every shipped core-AA SC/guideline/principle MUST carry a French
|
|
@@ -467,6 +713,9 @@ function build() {
|
|
|
467
713
|
const frPrinciples = fr.principles ?? {};
|
|
468
714
|
const frGuidelines = fr.guidelines ?? {};
|
|
469
715
|
const frCriteria = fr.criteria ?? {};
|
|
716
|
+
const frText = fr.criteriaText ?? {};
|
|
717
|
+
const frTerms = fr.criteriaTerms ?? {};
|
|
718
|
+
const frGlossary = fr.glossary ?? {};
|
|
470
719
|
const missingFr = [
|
|
471
720
|
...snap.principles.filter((p) => !frPrinciples[String(p.number)]).map((p) => `principle ${p.number}`),
|
|
472
721
|
...snap.guidelines.filter((g) => !frGuidelines[g.number]).map((g) => `guideline ${g.number}`),
|
|
@@ -494,9 +743,44 @@ function build() {
|
|
|
494
743
|
understanding: `https://www.w3.org/WAI/WCAG22/Understanding/${c.slug}.html`,
|
|
495
744
|
};
|
|
496
745
|
if (techniques[c.sc]?.length) sc.techniques = techniques[c.sc];
|
|
746
|
+
if (text.criteria?.[c.sc]) sc.text = text.criteria[c.sc];
|
|
747
|
+
if (frText[c.sc]) sc.textFr = frText[c.sc];
|
|
748
|
+
if (frTerms[c.sc]?.length) sc.termsFr = frTerms[c.sc];
|
|
749
|
+
if (text.terms?.[c.sc]?.length) sc.terms = text.terms[c.sc];
|
|
497
750
|
return sc;
|
|
498
751
|
});
|
|
499
752
|
|
|
753
|
+
// Same completeness rule as the French titles: a shipped criterion either carries its
|
|
754
|
+
// normative wording or the build fails. Half a reference is worse than a known-absent
|
|
755
|
+
// one, because a reader cannot tell which criteria they are missing.
|
|
756
|
+
const missingText = snap.criteria.filter((c) => !text.criteria?.[c.sc]).map((c) => `SC ${c.sc}`);
|
|
757
|
+
if (missingText.length) {
|
|
758
|
+
console.error(`build-standards: ${VENDOR_TEXT} is missing the normative text for: ${missingText.join(", ")}. Re-run: node scripts/build-standards.mjs --refresh-text`);
|
|
759
|
+
process.exit(1);
|
|
760
|
+
}
|
|
761
|
+
// The French body is held to the same rule as the French title: complete, or the build
|
|
762
|
+
// fails. `--lang fr` must never fall back to English prose under a French heading.
|
|
763
|
+
const missingFrText = snap.criteria.filter((c) => !frText[c.sc]).map((c) => `SC ${c.sc}`);
|
|
764
|
+
if (missingFrText.length) {
|
|
765
|
+
console.error(`build-standards: ${VENDOR_FR} is missing the French body for: ${missingFrText.join(", ")}. Re-run: node scripts/build-standards.mjs --refresh-fr`);
|
|
766
|
+
process.exit(1);
|
|
767
|
+
}
|
|
768
|
+
|
|
769
|
+
// Every term a criterion CITES must resolve in the glossary of its own language — the
|
|
770
|
+
// two pages use different slugs, so a cross-language mix-up would silently drop
|
|
771
|
+
// definitions rather than fail. Caught here instead.
|
|
772
|
+
for (const c of criteria) {
|
|
773
|
+
for (const [slug, set, which] of [
|
|
774
|
+
...(c.terms ?? []).map((t) => [t, text.glossary ?? {}, "en"]),
|
|
775
|
+
...(c.termsFr ?? []).map((t) => [t, frGlossary, "fr"]),
|
|
776
|
+
]) {
|
|
777
|
+
if (!set[slug]) {
|
|
778
|
+
console.error(`build-standards: SC ${c.sc} cites the ${which} term "${slug}", which that language's glossary does not define.`);
|
|
779
|
+
process.exit(1);
|
|
780
|
+
}
|
|
781
|
+
}
|
|
782
|
+
}
|
|
783
|
+
|
|
500
784
|
const out = {
|
|
501
785
|
wcagVersion: snap.wcagVersion,
|
|
502
786
|
level: "AA",
|
|
@@ -506,6 +790,10 @@ function build() {
|
|
|
506
790
|
principles,
|
|
507
791
|
guidelines,
|
|
508
792
|
criteria,
|
|
793
|
+
glossary: text.glossary ?? {},
|
|
794
|
+
// Keyed by the SAME dfn slugs as the English glossary, so a criterion's term links
|
|
795
|
+
// resolve in either language off one list.
|
|
796
|
+
glossaryFr: frGlossary,
|
|
509
797
|
};
|
|
510
798
|
mkdirSync(DATA, { recursive: true });
|
|
511
799
|
writeFileSync(join(DATA, "wcag.json"), biomeFormat(JSON.stringify(out, null, 2) + "\n", "src/data/wcag.json"));
|
|
@@ -543,6 +831,7 @@ async function main() {
|
|
|
543
831
|
if (refreshIdx !== -1) deriveSnapshot(process.argv[refreshIdx + 1]);
|
|
544
832
|
if (process.argv.includes("--refresh-universe")) await deriveUniverse();
|
|
545
833
|
if (process.argv.includes("--refresh-fr")) await deriveFr();
|
|
834
|
+
if (process.argv.includes("--refresh-text")) await deriveText();
|
|
546
835
|
build();
|
|
547
836
|
}
|
|
548
837
|
|