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.
Files changed (48) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/.codex-plugin/plugin.json +1 -1
  4. package/.opencode/plugins/ultra11y.js +1 -1
  5. package/README.md +40 -4
  6. package/package.json +1 -1
  7. package/scripts/build-standards.mjs +290 -1
  8. package/scripts/ultra11y.mjs +4131 -786
  9. package/scripts/vendor/wcag-2.2-fr.json +945 -1
  10. package/scripts/vendor/wcag-2.2-sc-universe.json +1 -1
  11. package/scripts/vendor/wcag-2.2-text.json +749 -0
  12. package/skills/review-a11y/SKILL.md +1 -1
  13. package/skills/review-a11y/scripts/ultra11y.mjs +4131 -786
  14. package/skills/ultra11y/SKILL.md +13 -4
  15. package/skills/ultra11y/references/guidance.md +33 -7
  16. package/skills/ultra11y/references/harnesses.md +10 -0
  17. package/skills/ultra11y/references/mcp.md +215 -0
  18. package/skills/ultra11y/references/packs.md +8 -0
  19. package/skills/ultra11y/references/standards.md +18 -0
  20. package/skills/ultra11y/scripts/ultra11y.mjs +4131 -786
  21. package/src/adjudicate.ts +9 -15
  22. package/src/adjudication-data.ts +38 -0
  23. package/src/check.ts +51 -1
  24. package/src/cli.ts +6 -2
  25. package/src/config.ts +27 -4
  26. package/src/criteria-view.ts +463 -0
  27. package/src/criteria.ts +14 -11
  28. package/src/data/guidance/wcag.json +268 -0
  29. package/src/data/wcag-universe.json +1 -1
  30. package/src/data/wcag.json +1112 -55
  31. package/src/guidance/index.ts +11 -0
  32. package/src/guidance/resolve.ts +71 -0
  33. package/src/mcp/handlers.ts +160 -15
  34. package/src/mcp/prompts.ts +13 -2
  35. package/src/mcp/server.ts +18 -4
  36. package/src/mcp/standards-resources.ts +174 -0
  37. package/src/mcp/tools.ts +140 -6
  38. package/src/method-view.ts +227 -0
  39. package/src/prd.ts +5 -16
  40. package/src/standards/coverage.ts +237 -0
  41. package/src/standards/index.ts +1 -0
  42. package/src/standards/pack.ts +19 -0
  43. package/src/standards/registry.ts +115 -9
  44. package/src/types.ts +24 -1
  45. package/src/vendor/codeindex-engine.d.mts +324 -3
  46. package/src/vendor/codeindex-engine.mjs +1146 -175
  47. package/src/vendor/engine.meta.json +5 -5
  48. 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.2.0",
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.2.0",
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.2.0",
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.2.0";
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
- Eleven read tools. `ultra11y_audit` is the entry point:
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 skill's own documentation
262
+ ### Resources — the documentation, and the standards themselves
241
263
 
242
- `SKILL.md` and all 32 `references/*.md` are served under `skill://`, read off
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.2.0",
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 → ${VENDOR_FR}`,
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
+ "&nbsp;": " ",
418
+ "&amp;": "&",
419
+ "&lt;": "<",
420
+ "&gt;": ">",
421
+ "&quot;": '"',
422
+ "&apos;": "'",
423
+ "&rsquo;": "’",
424
+ "&lsquo;": "‘",
425
+ "&ldquo;": "“",
426
+ "&rdquo;": "”",
427
+ "&mdash;": "—",
428
+ "&ndash;": "–",
429
+ "&hellip;": "…",
430
+ "&times;": "×",
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