ultra11y 2.22.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 (153) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +223 -0
  3. package/docker/Dockerfile +7 -0
  4. package/docker/package.json +9 -0
  5. package/docker/runner.mjs +23 -0
  6. package/package.json +73 -0
  7. package/scripts/bench.mjs +151 -0
  8. package/scripts/build-act-corpus.mjs +143 -0
  9. package/scripts/build-adjudication.mjs +503 -0
  10. package/scripts/build-pack-rgaa.mjs +397 -0
  11. package/scripts/build-standards.mjs +548 -0
  12. package/scripts/copy-bundle.mjs +39 -0
  13. package/scripts/import-pack.mjs +107 -0
  14. package/scripts/sync-engine.mjs +84 -0
  15. package/scripts/sync-version.mjs +43 -0
  16. package/scripts/ultra11y.mjs +52739 -0
  17. package/scripts/vendor/act-testcases.json +12297 -0
  18. package/scripts/vendor/rgaa/SOURCE.json +8 -0
  19. package/scripts/vendor/rgaa/criteres.json +4144 -0
  20. package/scripts/vendor/rgaa/glossaire.json +480 -0
  21. package/scripts/vendor/wcag-2.2-fr.json +82 -0
  22. package/scripts/vendor/wcag-2.2-sc-universe.json +950 -0
  23. package/scripts/vendor/wcag-2.2-sc.json +574 -0
  24. package/scripts/verify-rgaa-source.mjs +126 -0
  25. package/scripts/verify-skill-bundle.mjs +91 -0
  26. package/skills/review-a11y/SKILL.md +123 -0
  27. package/skills/review-a11y/scripts/ultra11y.mjs +52739 -0
  28. package/skills/ultra11y/SKILL.md +292 -0
  29. package/skills/ultra11y/references/act.md +163 -0
  30. package/skills/ultra11y/references/adjudication.md +599 -0
  31. package/skills/ultra11y/references/audit.md +100 -0
  32. package/skills/ultra11y/references/authoring.md +37 -0
  33. package/skills/ultra11y/references/automation.md +113 -0
  34. package/skills/ultra11y/references/correction.md +41 -0
  35. package/skills/ultra11y/references/criteria.md +129 -0
  36. package/skills/ultra11y/references/cross-file.md +82 -0
  37. package/skills/ultra11y/references/dynamic.md +152 -0
  38. package/skills/ultra11y/references/false-positives.md +81 -0
  39. package/skills/ultra11y/references/fix.md +46 -0
  40. package/skills/ultra11y/references/focus-and-logic.md +64 -0
  41. package/skills/ultra11y/references/forbidden-patterns.md +61 -0
  42. package/skills/ultra11y/references/forms-and-errors.md +147 -0
  43. package/skills/ultra11y/references/guidance.md +55 -0
  44. package/skills/ultra11y/references/judgment.md +90 -0
  45. package/skills/ultra11y/references/media-and-motion.md +116 -0
  46. package/skills/ultra11y/references/methodology.md +72 -0
  47. package/skills/ultra11y/references/naming.md +117 -0
  48. package/skills/ultra11y/references/orchestration.md +44 -0
  49. package/skills/ultra11y/references/packs.md +193 -0
  50. package/skills/ultra11y/references/prd.md +104 -0
  51. package/skills/ultra11y/references/rendered.md +110 -0
  52. package/skills/ultra11y/references/scale.md +49 -0
  53. package/skills/ultra11y/references/standards.md +97 -0
  54. package/skills/ultra11y/references/structure.md +138 -0
  55. package/skills/ultra11y/references/verify.md +88 -0
  56. package/skills/ultra11y/references/widgets.md +106 -0
  57. package/skills/ultra11y/scripts/ultra11y.mjs +52739 -0
  58. package/src/act.ts +441 -0
  59. package/src/adjudicate.ts +630 -0
  60. package/src/aria.ts +230 -0
  61. package/src/audit.ts +396 -0
  62. package/src/auditor.ts +399 -0
  63. package/src/autofill.ts +102 -0
  64. package/src/axe-map.ts +294 -0
  65. package/src/baseline.ts +75 -0
  66. package/src/capture.ts +182 -0
  67. package/src/check.ts +243 -0
  68. package/src/cli.ts +1927 -0
  69. package/src/color.ts +102 -0
  70. package/src/config.ts +182 -0
  71. package/src/crawl.ts +75 -0
  72. package/src/criteria.ts +183 -0
  73. package/src/data/adjudication.json +770 -0
  74. package/src/data/guidance/rgaa.json +2395 -0
  75. package/src/data/standards/rgaa.glossary.json +478 -0
  76. package/src/data/standards/rgaa.json +3454 -0
  77. package/src/data/wcag-universe.json +530 -0
  78. package/src/data/wcag.json +1178 -0
  79. package/src/discover.ts +203 -0
  80. package/src/fix/codemods.ts +76 -0
  81. package/src/fix/diff.ts +64 -0
  82. package/src/fix/edits.ts +126 -0
  83. package/src/fix.ts +215 -0
  84. package/src/gh.ts +214 -0
  85. package/src/glob.ts +206 -0
  86. package/src/graph/build.ts +114 -0
  87. package/src/graph/engine-resolve.ts +0 -0
  88. package/src/graph/graph.ts +126 -0
  89. package/src/graph/imports.ts +359 -0
  90. package/src/graph/resolve.ts +73 -0
  91. package/src/graph/tsconfig.ts +113 -0
  92. package/src/grounding.ts +119 -0
  93. package/src/guidance/index.ts +45 -0
  94. package/src/guidance/types.ts +36 -0
  95. package/src/init.ts +113 -0
  96. package/src/mcp/handlers.ts +388 -0
  97. package/src/mcp/http.ts +260 -0
  98. package/src/mcp/prompts.ts +183 -0
  99. package/src/mcp/protocol.ts +187 -0
  100. package/src/mcp/resources.ts +136 -0
  101. package/src/mcp/server.ts +228 -0
  102. package/src/mcp/stdio.ts +119 -0
  103. package/src/mcp/tools.ts +347 -0
  104. package/src/messages.ts +1011 -0
  105. package/src/name.ts +174 -0
  106. package/src/orchestrate-templates.ts +231 -0
  107. package/src/orchestrate.ts +186 -0
  108. package/src/output.ts +108 -0
  109. package/src/pack.ts +187 -0
  110. package/src/parse/html.ts +258 -0
  111. package/src/parse/jsx-ast.ts +67 -0
  112. package/src/parse/jsx-bridge.ts +247 -0
  113. package/src/parse/jsx.ts +19 -0
  114. package/src/parse/source.ts +75 -0
  115. package/src/prd.ts +406 -0
  116. package/src/project-lock.ts +42 -0
  117. package/src/render.ts +307 -0
  118. package/src/report.ts +399 -0
  119. package/src/rules/colors.ts +125 -0
  120. package/src/rules/cross-registry.ts +264 -0
  121. package/src/rules/cross-rule.ts +83 -0
  122. package/src/rules/forms.ts +590 -0
  123. package/src/rules/frames.ts +33 -0
  124. package/src/rules/headings.ts +227 -0
  125. package/src/rules/images.ts +200 -0
  126. package/src/rules/links.ts +193 -0
  127. package/src/rules/mandatory.ts +173 -0
  128. package/src/rules/multimedia.ts +77 -0
  129. package/src/rules/navigation.ts +190 -0
  130. package/src/rules/presentation.ts +75 -0
  131. package/src/rules/registry.ts +57 -0
  132. package/src/rules/rule.ts +122 -0
  133. package/src/rules/scripts-aria.ts +636 -0
  134. package/src/rules/tables.ts +264 -0
  135. package/src/rules/timing.ts +55 -0
  136. package/src/sample.ts +176 -0
  137. package/src/scan-local.ts +866 -0
  138. package/src/scan.ts +538 -0
  139. package/src/standards/derive.ts +195 -0
  140. package/src/standards/index.ts +31 -0
  141. package/src/standards/pack-rules.ts +111 -0
  142. package/src/standards/pack.ts +81 -0
  143. package/src/standards/registry.ts +100 -0
  144. package/src/standards/types.ts +202 -0
  145. package/src/standards/validate.ts +544 -0
  146. package/src/standards/vocabulary.ts +75 -0
  147. package/src/types.ts +355 -0
  148. package/src/util.ts +32 -0
  149. package/src/vendor/codeindex-engine.d.mts +1009 -0
  150. package/src/vendor/codeindex-engine.mjs +13701 -0
  151. package/src/vendor/engine.meta.json +9 -0
  152. package/src/verify.ts +275 -0
  153. package/src/wcag.ts +128 -0
@@ -0,0 +1,227 @@
1
+ // Theme 9 — Information structure (headings).
2
+ import type { Doc, El, HNode } from "../parse/html.js";
3
+ import { attr, elementsByTag } from "../parse/html.js";
4
+ import { isIntrinsic } from "../parse/jsx-bridge.js";
5
+ import { accessibleName, mayInjectContent } from "../name.js";
6
+ import { type Rule, type RuleFinding, shellBodyInjected } from "./rule.js";
7
+ import { ancestors } from "../parse/html.js";
8
+
9
+ // JSX/SFC only: true when a required element/content may be injected at runtime in a
10
+ // way static source analysis cannot see — a child component (non-intrinsic tag, which
11
+ // can render anything), a <slot>, or a {…}/{@render} expression child. Rules that
12
+ // assert "the page is missing X" must bail on these so a residual risk is never turned
13
+ // into a false non-conformity (e.g. a Next.js layout supplies its <h1> via {children}).
14
+ function contentMaybeInjected(doc: Doc): boolean {
15
+ if (doc.kind === "html") return false; // plain HTML has no slots/components/expressions
16
+ for (const el of doc.elements) {
17
+ if (el.tag === "slot") return true;
18
+ if (el.tag !== "#fragment" && !isIntrinsic(el.tag)) return true;
19
+ }
20
+ const stack: HNode[] = [...doc.roots];
21
+ while (stack.length) {
22
+ const n = stack.pop()!;
23
+ if (n.type === "text") {
24
+ if (n.data.includes("{")) return true; // {children}/{expr} — injected markup
25
+ } else stack.push(...n.children);
26
+ }
27
+ return false;
28
+ }
29
+
30
+ function headingLevel(el: El): number | null {
31
+ const m = /^h([1-6])$/.exec(el.tag);
32
+ if (m) return Number(m[1]);
33
+ if ((attr(el, "role") ?? "") === "heading") {
34
+ const lvl = Number(attr(el, "aria-level"));
35
+ if (Number.isInteger(lvl) && lvl >= 1) return lvl;
36
+ }
37
+ return null;
38
+ }
39
+
40
+ function headings(doc: Doc): { el: El; level: number }[] {
41
+ const out: { el: El; level: number }[] = [];
42
+ for (const el of doc.elements) {
43
+ const lvl = headingLevel(el);
44
+ if (lvl !== null) out.push({ el, level: lvl });
45
+ }
46
+ return out;
47
+ }
48
+
49
+ const headingOrderSkip: Rule = {
50
+ id: "heading-order-skip",
51
+ criteria: ["1.3.1"],
52
+ severity: "majeur",
53
+ run(doc: Doc): RuleFinding[] {
54
+ const out: RuleFinding[] = [];
55
+ let prev = 0;
56
+ let prevArm: string | undefined;
57
+ let prevHeading: El | null = null;
58
+ let componentBetween = false;
59
+ // Walk elements in source order so interleaved child components are visible: a
60
+ // component rendered BETWEEN two headings may itself render an intermediate heading
61
+ // (invisible to source) — so a "skip" across it is not a definite order violation.
62
+ for (const el of doc.elements) {
63
+ const level = headingLevel(el);
64
+ if (level === null) {
65
+ if (el.tag !== "#fragment" && !isIntrinsic(el.tag) && (!prevHeading || !ancestors(el).includes(prevHeading))) componentBetween = true;
66
+ continue;
67
+ }
68
+ // Compare only headings in the SAME branch context (different JSX conditional arms
69
+ // are mutually exclusive) with no component injected between them.
70
+ if (prev !== 0 && el.branchArm === prevArm && !componentBetween && level - prev > 1) {
71
+ out.push({
72
+ criteriaId: "1.3.1",
73
+ el,
74
+ msgId: "heading-order-skip",
75
+ params: { level, prev, expected: prev + 1 },
76
+ });
77
+ }
78
+ prev = level;
79
+ prevArm = el.branchArm;
80
+ prevHeading = el;
81
+ componentBetween = false;
82
+ }
83
+ return out;
84
+ },
85
+ };
86
+
87
+ // h1-missing / h1-multiple are ADVISORY (non-normative): neither "start at h1" nor "a
88
+ // single h1 per page" is a requirement in HTML, accessibility, or SEO. A human RGAA
89
+ // auditor rejected them as non-conformities — no normative test backs them. Heading
90
+ // HIERARCHY stays covered by `heading-order-skip` (normative: RGAA 9.1.3 makes a level
91
+ // skip testable). These fire as « Recommandation (non normative) », never an NC.
92
+ const h1Missing: Rule = {
93
+ id: "h1-missing",
94
+ criteria: ["1.3.1"],
95
+ severity: "mineur",
96
+ scope: "page",
97
+ advisory: true,
98
+ run(doc: Doc): RuleFinding[] {
99
+ const hasH1 = headings(doc).some((h) => h.level === 1);
100
+ if (hasH1) return [];
101
+ // The h1 may be supplied by {children}/a child component (JSX/SFC) or injected into
102
+ // a framework shell template (SPA mount point / `%sveltekit.body%`) at build/runtime.
103
+ if (contentMaybeInjected(doc) || shellBodyInjected(doc)) return [];
104
+ const anchor = elementsByTag(doc, "body")[0] ?? elementsByTag(doc, "html")[0];
105
+ if (!anchor) return [];
106
+ return [
107
+ {
108
+ criteriaId: "1.3.1",
109
+ el: anchor,
110
+ msgId: "h1-missing",
111
+ },
112
+ ];
113
+ },
114
+ };
115
+
116
+ const h1Multiple: Rule = {
117
+ id: "h1-multiple",
118
+ criteria: ["1.3.1"],
119
+ severity: "mineur",
120
+ scope: "page",
121
+ advisory: true,
122
+ run(doc: Doc): RuleFinding[] {
123
+ const h1s = elementsByTag(doc, "h1");
124
+ if (h1s.length <= 1) return [];
125
+ return h1s.slice(1).map((el) => ({
126
+ criteriaId: "1.3.1",
127
+ el,
128
+ msgId: "h1-multiple",
129
+ params: { count: h1s.length },
130
+ }));
131
+ },
132
+ };
133
+
134
+ // <slot> is allowed: it projects slotted <li>s (web components / SFC templates).
135
+ const ALLOWED_IN_LIST = new Set(["li", "script", "template", "slot"]);
136
+
137
+ const listStructure: Rule = {
138
+ id: "list-structure",
139
+ criteria: ["1.3.1"],
140
+ severity: "majeur",
141
+ run(doc: Doc): RuleFinding[] {
142
+ const out: RuleFinding[] = [];
143
+ for (const el of doc.elements) {
144
+ if (el.tag === "ul" || el.tag === "ol") {
145
+ // Only intrinsic (HTML) children are a definite violation; a PascalCase
146
+ // component child may itself render an <li>, so it is left to the cross-file
147
+ // graph / human, never asserted here.
148
+ const bad = el.children.find((c) => c.type === "element" && isIntrinsic(c.tag) && !ALLOWED_IN_LIST.has(c.tag));
149
+ if (bad && bad.type === "element") {
150
+ out.push({
151
+ criteriaId: "1.3.1",
152
+ el: bad,
153
+ msgId: "list-structure.invalid-child",
154
+ params: { childTag: bad.tag, parentTag: el.tag },
155
+ });
156
+ }
157
+ } else if (el.tag === "li") {
158
+ const parent = el.parent;
159
+ if (!parent || ["ul", "ol", "menu"].includes(parent.tag)) continue;
160
+ // A <li> inside a <template> (named-slot definition) or a component subtree is
161
+ // projected into a parent component's <ul> at render — its real parent is unknown
162
+ // to static analysis, so this is not a definite violation.
163
+ if (parent.tag === "template" || ancestors(el).some((a) => a.tag === "template" || (a.tag !== "#fragment" && !isIntrinsic(a.tag)))) continue;
164
+ out.push({
165
+ criteriaId: "1.3.1",
166
+ el,
167
+ msgId: "list-structure.li-outside-list",
168
+ params: { parentTag: parent.tag },
169
+ });
170
+ }
171
+ }
172
+ return out;
173
+ },
174
+ };
175
+
176
+ const emptyHeading: Rule = {
177
+ id: "empty-heading",
178
+ criteria: ["1.3.1"],
179
+ severity: "majeur",
180
+ run(doc: Doc): RuleFinding[] {
181
+ const out: RuleFinding[] = [];
182
+ for (const { el, level } of headings(doc)) {
183
+ if (attr(el, "aria-hidden") === "true") continue;
184
+ if (accessibleName(el, doc)) continue;
185
+ if (mayInjectContent(el)) continue; // text supplied by a <slot>/child component
186
+ out.push({
187
+ criteriaId: "1.3.1",
188
+ el,
189
+ msgId: "empty-heading",
190
+ params: { tag: el.tag, level },
191
+ });
192
+ }
193
+ return out;
194
+ },
195
+ };
196
+
197
+ // A description list only conveys its term/description pairing when the markup is
198
+ // well-formed: <dl> takes <dt>/<dd> (optionally wrapped in <div>), and <dt>/<dd> mean
199
+ // nothing outside a <dl>. Broken either way, the relationship is purely visual.
200
+ const DL_ALLOWED = new Set(["dt", "dd", "div", "script", "template", "slot", "#fragment"]);
201
+
202
+ const dlStructure: Rule = {
203
+ id: "dl-structure",
204
+ criteria: ["1.3.1"],
205
+ severity: "majeur",
206
+ run(doc: Doc): RuleFinding[] {
207
+ const out: RuleFinding[] = [];
208
+ for (const el of doc.elements) {
209
+ if (el.tag === "dl") {
210
+ if (mayInjectContent(el)) continue; // children projected by a slot/component
211
+ for (const child of el.children) {
212
+ if (child.type !== "element" || !isIntrinsic(child.tag)) continue;
213
+ if (DL_ALLOWED.has(child.tag)) continue;
214
+ out.push({ criteriaId: "1.3.1", el: child, msgId: "dl-structure.foreign-child", params: { tag: child.tag } });
215
+ }
216
+ continue;
217
+ }
218
+ if (el.tag !== "dt" && el.tag !== "dd") continue;
219
+ const chain = ancestors(el);
220
+ if (chain.some((a) => a.tag === "dl" || !isIntrinsic(a.tag))) continue; // inside a dl, or a component may supply one
221
+ out.push({ criteriaId: "1.3.1", el, msgId: "dl-structure.orphan", params: { tag: el.tag } });
222
+ }
223
+ return out;
224
+ },
225
+ };
226
+
227
+ export const headingsRules: Rule[] = [headingOrderSkip, h1Missing, h1Multiple, listStructure, emptyHeading, dlStructure];
@@ -0,0 +1,200 @@
1
+ // Theme 1 — Images.
2
+ import type { Doc, El } from "../parse/html.js";
3
+ import { attr, hasAttr, hasBoundAttr, boundAttr, hasDynamicSpread, visibleText, ancestors, descendants } from "../parse/html.js";
4
+ import { accessibleName, isNameExempt } from "../name.js";
5
+ import type { Rule, RuleFinding } from "./rule.js";
6
+
7
+ // Not exposed to assistive tech (aria-hidden / [hidden] / an inline display:none or
8
+ // visibility:hidden, on the element or an ancestor), or explicitly presentational —
9
+ // either way there is no accessible name to demand. Shared with the other rule families
10
+ // via src/name.ts so the whole engine agrees on what "not exposed" means.
11
+ const isHidden = isNameExempt;
12
+ // A dynamically-bound name (`:aria-label`, `v-bind:aria-labelledby`) names the element
13
+ // even though we cannot resolve its value — treat it as present, not missing.
14
+ const named = (el: El): boolean => !!(boundAttr(el, "aria-label") ?? "").trim() || hasBoundAttr(el, "aria-labelledby");
15
+
16
+ const imgAltMissing: Rule = {
17
+ id: "img-alt-missing",
18
+ criteria: ["1.1.1"],
19
+ severity: "bloquant",
20
+ run(doc: Doc): RuleFinding[] {
21
+ const out: RuleFinding[] = [];
22
+ for (const el of doc.elements) {
23
+ const isImg = el.tag === "img" || el.tag === "area" || (attr(el, "role") ?? "") === "img";
24
+ if (!isImg) continue;
25
+ if (isHidden(el) && el.tag !== "area") continue;
26
+ // alt="" is the decorative opt-out (maps the image to presentation); a whitespace-only
27
+ // alt is NOT — it provides no accessible name yet keeps the image in the a11y tree.
28
+ const altLiteral = attr(el, "alt");
29
+ const whitespaceAlt = altLiteral !== undefined && altLiteral !== "" && altLiteral.trim() === "";
30
+ if ((hasBoundAttr(el, "alt") && !whitespaceAlt) || named(el)) continue; // alt="" / :alt="x" / aria-* → present
31
+ if (hasDynamicSpread(el)) continue; // {...props} / Svelte {alt} shorthand may carry alt
32
+ // role="img" on a non-<img> element (e.g. <svg role="img"><title>…) is named by its
33
+ // <title>/text content, not an alt attribute — accept that accessible name.
34
+ if (el.tag !== "img" && el.tag !== "area" && accessibleName(el, doc).trim() !== "") continue;
35
+ // `title` IS an accname fallback for an image, so 1.1.1 is technically satisfied and
36
+ // asserting a non-conformity would be inventing one. It is still the wrong tool —
37
+ // `title` is hover-only, absent on touch and unevenly announced — so this stays a
38
+ // recommendation to move the text into `alt`, never a normative failure.
39
+ if ((attr(el, "title") ?? "").trim() !== "") {
40
+ out.push({
41
+ criteriaId: "1.1.1",
42
+ el,
43
+ msgId: "img-alt-missing.title-only",
44
+ params: { tag: el.tag },
45
+ advisory: true,
46
+ severity: "mineur",
47
+ });
48
+ continue;
49
+ }
50
+ out.push({
51
+ criteriaId: "1.1.1",
52
+ el,
53
+ msgId: "img-alt-missing",
54
+ params: { tag: el.tag },
55
+ });
56
+ }
57
+ return out;
58
+ },
59
+ };
60
+
61
+ const decorativeAltMisuse: Rule = {
62
+ id: "decorative-alt-misuse",
63
+ criteria: ["1.1.1"],
64
+ severity: "majeur",
65
+ run(doc: Doc): RuleFinding[] {
66
+ const out: RuleFinding[] = [];
67
+ for (const el of doc.elements) {
68
+ if (el.tag !== "img") continue;
69
+ const alt = attr(el, "alt");
70
+ const role = (attr(el, "role") ?? "").trim();
71
+ const ariaLabel = (attr(el, "aria-label") ?? "").trim();
72
+ const title = (attr(el, "title") ?? "").trim();
73
+ if (alt === "" && (ariaLabel || title)) {
74
+ out.push({
75
+ criteriaId: "1.1.1",
76
+ el,
77
+ msgId: "decorative-alt-misuse.empty-but-named",
78
+ });
79
+ } else if (["presentation", "none"].includes(role) && alt?.trim()) {
80
+ out.push({
81
+ criteriaId: "1.1.1",
82
+ el,
83
+ msgId: "decorative-alt-misuse.role-but-alt",
84
+ params: { role },
85
+ });
86
+ }
87
+ }
88
+ return out;
89
+ },
90
+ };
91
+
92
+ const canvasFallbackMissing: Rule = {
93
+ id: "canvas-fallback-missing",
94
+ criteria: ["1.1.1"],
95
+ severity: "majeur",
96
+ run(doc: Doc): RuleFinding[] {
97
+ const out: RuleFinding[] = [];
98
+ for (const el of doc.elements) {
99
+ if (el.tag !== "canvas") continue;
100
+ if (attr(el, "aria-hidden") === "true") continue; // explicitly removed from the a11y tree (named on a role=img wrapper)
101
+ const hasFallback = el.children.some((c) => (c.type === "element" ? true : c.data.trim().length > 0));
102
+ if (hasFallback || named(el) || visibleText(el)) continue;
103
+ out.push({
104
+ criteriaId: "1.1.1",
105
+ el,
106
+ msgId: "canvas-fallback-missing",
107
+ });
108
+ }
109
+ return out;
110
+ },
111
+ };
112
+
113
+ const inputImageAltMissing: Rule = {
114
+ id: "input-image-alt-missing",
115
+ criteria: ["1.1.1"],
116
+ severity: "bloquant",
117
+ run(doc: Doc): RuleFinding[] {
118
+ const out: RuleFinding[] = [];
119
+ for (const el of doc.elements) {
120
+ if (el.tag !== "input" || (attr(el, "type") ?? "").toLowerCase() !== "image") continue;
121
+ if (isHidden(el)) continue;
122
+ const alt = (boundAttr(el, "alt") ?? "").trim();
123
+ if (alt || named(el) || (attr(el, "title") ?? "").trim()) continue;
124
+ out.push({
125
+ criteriaId: "1.1.1",
126
+ el,
127
+ msgId: "input-image-alt-missing",
128
+ });
129
+ }
130
+ return out;
131
+ },
132
+ };
133
+
134
+ // <object>/<embed> embed non-text content (PDF, Flash-era media, SVG, video) and need an
135
+ // accessible name or text fallback, like <img>. (Decorative ones should be aria-hidden.)
136
+ const objectEmbedNoName: Rule = {
137
+ id: "object-embed-no-name",
138
+ criteria: ["1.1.1"],
139
+ severity: "majeur",
140
+ run(doc: Doc): RuleFinding[] {
141
+ const out: RuleFinding[] = [];
142
+ for (const el of doc.elements) {
143
+ if (el.tag !== "object" && el.tag !== "embed") continue;
144
+ if (isHidden(el)) continue;
145
+ if (hasDynamicSpread(el)) continue; // a spread may inject aria-label/title
146
+ // named(el) catches a dynamically-bound aria-label / :aria-labelledby / aria-labelledby={x}
147
+ // (present name, value unknown) the same way chart-no-accessible-name does — without it,
148
+ // a bound aria-labelledby re-fired as a false "no name" finding on <object>/<embed>.
149
+ if (named(el) || accessibleName(el, doc).trim() !== "") continue; // aria-label/labelledby, title, or fallback content
150
+ out.push({
151
+ criteriaId: "1.1.1",
152
+ el,
153
+ msgId: "object-embed-no-name",
154
+ params: { tag: el.tag },
155
+ });
156
+ }
157
+ return out;
158
+ },
159
+ };
160
+
161
+ // A charting container (<svg>/<div> of a known charting library) with no accessible
162
+ // name — the data visualization carries information with no text alternative
163
+ // (WCAG 1.1.1). Distinct from canvas-fallback-missing (which owns <canvas>): charting
164
+ // libs (recharts/echarts/chart.js/…) render into <svg>/<div>. Only the OUTERMOST chart
165
+ // container is reported (inner chart nodes are skipped), and a class allowlist bounds FP.
166
+ // Right boundary (`([-_ ]|$)`) so a generic token matches a WHOLE class token only — else
167
+ // `graph` prefix-matches `graphics`/`typography-graphic` (decorative), a false positive.
168
+ const CHART_CLASS = /(^|[-_ ])(charts?|graphs?|recharts|highcharts|chart-?js|plotly|nivo|apexcharts|echarts|d3)([-_ ]|$)/i;
169
+
170
+ const chartNoAccessibleName: Rule = {
171
+ id: "chart-no-accessible-name",
172
+ criteria: ["1.1.1"],
173
+ severity: "majeur",
174
+ run(doc: Doc): RuleFinding[] {
175
+ const out: RuleFinding[] = [];
176
+ for (const el of doc.elements) {
177
+ if (el.tag !== "svg" && el.tag !== "div") continue;
178
+ const cls = attr(el, "class") ?? "";
179
+ if (!CHART_CLASS.test(cls)) continue;
180
+ if (isHidden(el)) continue;
181
+ if (hasDynamicSpread(el)) continue;
182
+ // report only the outermost chart container (skip nested chart nodes)
183
+ if (ancestors(el).some((a) => CHART_CLASS.test(attr(a, "class") ?? ""))) continue;
184
+ if (named(el) || accessibleName(el, doc).trim() !== "") continue; // aria-label/labelledby, <svg><title>, text
185
+ // named by a wrapping <figure><figcaption> or a role=img wrapper
186
+ const fig = ancestors(el).find((a) => a.tag === "figure");
187
+ if (fig && descendants(fig).some((d) => d.tag === "figcaption" && visibleText(d))) continue;
188
+ if (ancestors(el).some((a) => (attr(a, "role") ?? "") === "img" && (named(a) || (attr(a, "aria-label") ?? "").trim()))) continue;
189
+ out.push({
190
+ criteriaId: "1.1.1",
191
+ el,
192
+ msgId: "chart-no-accessible-name",
193
+ params: { tag: el.tag, cls: cls.trim().split(/\s+/)[0] ?? "" },
194
+ });
195
+ }
196
+ return out;
197
+ },
198
+ };
199
+
200
+ export const imagesRules: Rule[] = [imgAltMissing, decorativeAltMisuse, canvasFallbackMissing, inputImageAltMissing, objectEmbedNoName, chartNoAccessibleName];
@@ -0,0 +1,193 @@
1
+ // Theme 6 / 7 — Links & buttons: accessible-name presence (empty-name + icon-only).
2
+ import type { Doc, El } from "../parse/html.js";
3
+ import { attr, hasAttr, descendants, visibleText } from "../parse/html.js";
4
+ import { isIntrinsic } from "../parse/jsx-bridge.js";
5
+ import { accessibleName, mayInjectContent, isFormField, controlLabel, isNameExempt } from "../name.js";
6
+ import type { Rule, RuleFinding } from "./rule.js";
7
+
8
+ function hasIconChild(el: El): boolean {
9
+ return descendants(el).some((d) => {
10
+ if (d.tag === "img") {
11
+ const a = attr(d, "alt");
12
+ return a === undefined || a.trim() === "";
13
+ }
14
+ if (d.tag === "svg") {
15
+ const titled = descendants(d).some((x) => x.tag === "title" && visibleText(x));
16
+ return !titled && !(attr(d, "aria-label") ?? "").trim();
17
+ }
18
+ if (d.tag === "i") return /(^|\s|-)(icon|fa|glyphicon|material-icons)/.test((attr(d, "class") ?? "").toLowerCase());
19
+ return false;
20
+ });
21
+ }
22
+
23
+ const isButton = (el: El): boolean => {
24
+ if (el.tag === "button") return true;
25
+ if ((attr(el, "role") ?? "") === "button") return true;
26
+ if (el.tag === "input") return (attr(el, "type") ?? "").toLowerCase() === "button";
27
+ return false;
28
+ };
29
+
30
+ const linkEmptyName: Rule = {
31
+ id: "link-empty-name",
32
+ criteria: ["2.4.4"],
33
+ severity: "bloquant",
34
+ run(doc: Doc): RuleFinding[] {
35
+ const out: RuleFinding[] = [];
36
+ for (const el of doc.elements) {
37
+ if (el.tag !== "a" || !hasAttr(el, "href")) continue;
38
+ if (isNameExempt(el)) continue; // not exposed, or explicitly presentational
39
+ if (accessibleName(el, doc) !== "") continue;
40
+ if (mayInjectContent(el)) continue; // name supplied by a <slot>/child component
41
+ if (hasIconChild(el)) continue; // handled by icon-only-control-unnamed
42
+ out.push({
43
+ criteriaId: "2.4.4",
44
+ el,
45
+ msgId: "link-empty-name",
46
+ });
47
+ }
48
+ return out;
49
+ },
50
+ };
51
+
52
+ const buttonEmptyName: Rule = {
53
+ id: "button-empty-name",
54
+ criteria: ["4.1.2"],
55
+ severity: "bloquant",
56
+ run(doc: Doc): RuleFinding[] {
57
+ const out: RuleFinding[] = [];
58
+ for (const el of doc.elements) {
59
+ if (!isButton(el)) continue;
60
+ if (isNameExempt(el)) continue; // not exposed, or explicitly presentational
61
+ if (accessibleName(el, doc) !== "") continue;
62
+ if (mayInjectContent(el)) continue; // name supplied by a <slot>/child component
63
+ if (hasIconChild(el)) continue; // handled by icon-only-control-unnamed
64
+ out.push({
65
+ criteriaId: "4.1.2",
66
+ el,
67
+ msgId: "button-empty-name",
68
+ });
69
+ }
70
+ return out;
71
+ },
72
+ };
73
+
74
+ const iconOnlyControlUnnamed: Rule = {
75
+ id: "icon-only-control-unnamed",
76
+ criteria: ["2.4.4", "4.1.2"],
77
+ severity: "bloquant",
78
+ run(doc: Doc): RuleFinding[] {
79
+ const out: RuleFinding[] = [];
80
+ for (const el of doc.elements) {
81
+ const link = el.tag === "a" && hasAttr(el, "href");
82
+ const button = isButton(el);
83
+ if (!link && !button) continue;
84
+ if (isNameExempt(el)) continue; // not exposed, or explicitly presentational
85
+ if (accessibleName(el, doc) !== "") continue;
86
+ if (mayInjectContent(el)) continue; // name supplied by a <slot>/child component
87
+ if (!hasIconChild(el)) continue;
88
+ out.push({
89
+ criteriaId: link ? "2.4.4" : "4.1.2",
90
+ el,
91
+ msgId: "icon-only-control-unnamed",
92
+ params: { kind: link ? "link" : "button" },
93
+ });
94
+ }
95
+ return out;
96
+ },
97
+ };
98
+
99
+ // A link/button whose ONLY accessible name comes from the title attribute. `title` is
100
+ // restituted unreliably (hover only — not on touch, often not by keyboard/AT), so a control
101
+ // named solely by it is effectively unlabeled in practice. (link-empty-name/button-empty-name
102
+ // skip these because title technically yields a name, so this rule covers the gap.)
103
+ const controlNameTitleOnly: Rule = {
104
+ id: "control-name-title-only",
105
+ criteria: ["4.1.2"],
106
+ severity: "mineur",
107
+ run(doc: Doc): RuleFinding[] {
108
+ const out: RuleFinding[] = [];
109
+ for (const el of doc.elements) {
110
+ const link = el.tag === "a" && hasAttr(el, "href");
111
+ const field = !link && !isButton(el) && isFormField(el);
112
+ if (!link && !isButton(el) && !field) continue;
113
+ if (isNameExempt(el)) continue; // not exposed, or explicitly presentational
114
+ const title = (attr(el, "title") ?? "").trim();
115
+ if (!title || title.includes("{")) continue; // no title, or dynamic value
116
+ if (hasAttr(el, "aria-label") || hasAttr(el, "aria-labelledby")) continue; // named by ARIA, title is supplementary
117
+ if (mayInjectContent(el)) continue; // name may come from a <slot>/child component
118
+ if (el.tag === "input" && (attr(el, "value") ?? "").trim()) continue; // value names the button
119
+ // A form field is title-only precisely when its resolved label comes from title (a
120
+ // for=/wrapping/aria label wins over title in controlLabel, so via!=="title" means it
121
+ // is otherwise named or unlabeled — control-label-missing's job, not this rule's).
122
+ if (field && controlLabel(el, doc).via !== "title") continue;
123
+ const hasContentName =
124
+ visibleText(el).trim() !== "" ||
125
+ descendants(el).some(
126
+ (d) =>
127
+ (d.tag === "img" && (attr(d, "alt") ?? "").trim() !== "") ||
128
+ (d.tag === "svg" && descendants(d).some((x) => x.tag === "title" && visibleText(x).trim() !== "")),
129
+ );
130
+ if (!field && hasContentName) continue; // visible text / named image already provides the name (fields have no content name)
131
+ out.push({
132
+ criteriaId: "4.1.2",
133
+ el,
134
+ msgId: "control-name-title-only",
135
+ params: { kind: link ? "link" : field ? "field" : "button" },
136
+ });
137
+ }
138
+ return out;
139
+ },
140
+ };
141
+
142
+ /** Normalise for the label-in-name comparison: case, punctuation and whitespace are not
143
+ * what voice control matches on. */
144
+ const normalizeName = (s: string): string =>
145
+ s
146
+ .toLowerCase()
147
+ .replace(/[\u2018\u2019\u201c\u201d]/g, "'")
148
+ .replace(/[^\p{L}\p{N}]+/gu, " ")
149
+ .trim();
150
+
151
+ // WCAG 2.5.3 Label in Name: when a control shows a visible text label, that text must be
152
+ // CONTAINED in its accessible name. A voice-control user says what they see ("click Send");
153
+ // if the accessible name is "Submit", the command silently does nothing. Only reported when
154
+ // both halves are literal text — a dynamic name proves nothing either way.
155
+ const labelInNameMismatch: Rule = {
156
+ id: "label-in-name-mismatch",
157
+ criteria: ["2.5.3"],
158
+ severity: "majeur",
159
+ run(doc: Doc): RuleFinding[] {
160
+ const out: RuleFinding[] = [];
161
+ for (const el of doc.elements) {
162
+ if (!isIntrinsic(el.tag)) continue;
163
+ const link = el.tag === "a" && hasAttr(el, "href");
164
+ const role = (attr(el, "role") ?? "").trim().toLowerCase();
165
+ if (!link && !isButton(el) && !["button", "link", "menuitem", "tab", "checkbox", "radio", "switch"].includes(role)) continue;
166
+ if (isNameExempt(el)) continue;
167
+ // The override must be a LITERAL aria-label: aria-labelledby points at text elsewhere
168
+ // in the page, which the visible label may legitimately extend rather than contradict.
169
+ const ariaLabel = (attr(el, "aria-label") ?? "").trim();
170
+ if (!ariaLabel || ariaLabel.includes("{")) continue;
171
+ if (hasAttr(el, "aria-labelledby")) continue; // labelledby wins over aria-label; different question
172
+ const visible = visibleText(el).trim();
173
+ if (!visible || visible.includes("{")) continue;
174
+ // A one- or two-character run ("X", "OK", "→") is a glyph, not a label a voice-control
175
+ // user would speak — 2.5.3 is about VISIBLE LABEL TEXT, and comparing glyphs to names
176
+ // manufactures noise.
177
+ if (visible.length < 3) continue;
178
+ if (mayInjectContent(el)) continue; // the visible text may come from a slot/component
179
+ const name = normalizeName(ariaLabel);
180
+ const label = normalizeName(visible);
181
+ if (!label || !name || name.includes(label)) continue;
182
+ out.push({
183
+ criteriaId: "2.5.3",
184
+ el,
185
+ msgId: "label-in-name-mismatch",
186
+ params: { visible, name: ariaLabel },
187
+ });
188
+ }
189
+ return out;
190
+ },
191
+ };
192
+
193
+ export const linksRules: Rule[] = [linkEmptyName, buttonEmptyName, iconOnlyControlUnnamed, controlNameTitleOnly, labelInNameMismatch];