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.
- package/LICENSE +21 -0
- package/README.md +223 -0
- package/docker/Dockerfile +7 -0
- package/docker/package.json +9 -0
- package/docker/runner.mjs +23 -0
- package/package.json +73 -0
- package/scripts/bench.mjs +151 -0
- package/scripts/build-act-corpus.mjs +143 -0
- package/scripts/build-adjudication.mjs +503 -0
- package/scripts/build-pack-rgaa.mjs +397 -0
- package/scripts/build-standards.mjs +548 -0
- package/scripts/copy-bundle.mjs +39 -0
- package/scripts/import-pack.mjs +107 -0
- package/scripts/sync-engine.mjs +84 -0
- package/scripts/sync-version.mjs +43 -0
- package/scripts/ultra11y.mjs +52739 -0
- package/scripts/vendor/act-testcases.json +12297 -0
- package/scripts/vendor/rgaa/SOURCE.json +8 -0
- package/scripts/vendor/rgaa/criteres.json +4144 -0
- package/scripts/vendor/rgaa/glossaire.json +480 -0
- package/scripts/vendor/wcag-2.2-fr.json +82 -0
- package/scripts/vendor/wcag-2.2-sc-universe.json +950 -0
- package/scripts/vendor/wcag-2.2-sc.json +574 -0
- package/scripts/verify-rgaa-source.mjs +126 -0
- package/scripts/verify-skill-bundle.mjs +91 -0
- package/skills/review-a11y/SKILL.md +123 -0
- package/skills/review-a11y/scripts/ultra11y.mjs +52739 -0
- package/skills/ultra11y/SKILL.md +292 -0
- package/skills/ultra11y/references/act.md +163 -0
- package/skills/ultra11y/references/adjudication.md +599 -0
- package/skills/ultra11y/references/audit.md +100 -0
- package/skills/ultra11y/references/authoring.md +37 -0
- package/skills/ultra11y/references/automation.md +113 -0
- package/skills/ultra11y/references/correction.md +41 -0
- package/skills/ultra11y/references/criteria.md +129 -0
- package/skills/ultra11y/references/cross-file.md +82 -0
- package/skills/ultra11y/references/dynamic.md +152 -0
- package/skills/ultra11y/references/false-positives.md +81 -0
- package/skills/ultra11y/references/fix.md +46 -0
- package/skills/ultra11y/references/focus-and-logic.md +64 -0
- package/skills/ultra11y/references/forbidden-patterns.md +61 -0
- package/skills/ultra11y/references/forms-and-errors.md +147 -0
- package/skills/ultra11y/references/guidance.md +55 -0
- package/skills/ultra11y/references/judgment.md +90 -0
- package/skills/ultra11y/references/media-and-motion.md +116 -0
- package/skills/ultra11y/references/methodology.md +72 -0
- package/skills/ultra11y/references/naming.md +117 -0
- package/skills/ultra11y/references/orchestration.md +44 -0
- package/skills/ultra11y/references/packs.md +193 -0
- package/skills/ultra11y/references/prd.md +104 -0
- package/skills/ultra11y/references/rendered.md +110 -0
- package/skills/ultra11y/references/scale.md +49 -0
- package/skills/ultra11y/references/standards.md +97 -0
- package/skills/ultra11y/references/structure.md +138 -0
- package/skills/ultra11y/references/verify.md +88 -0
- package/skills/ultra11y/references/widgets.md +106 -0
- package/skills/ultra11y/scripts/ultra11y.mjs +52739 -0
- package/src/act.ts +441 -0
- package/src/adjudicate.ts +630 -0
- package/src/aria.ts +230 -0
- package/src/audit.ts +396 -0
- package/src/auditor.ts +399 -0
- package/src/autofill.ts +102 -0
- package/src/axe-map.ts +294 -0
- package/src/baseline.ts +75 -0
- package/src/capture.ts +182 -0
- package/src/check.ts +243 -0
- package/src/cli.ts +1927 -0
- package/src/color.ts +102 -0
- package/src/config.ts +182 -0
- package/src/crawl.ts +75 -0
- package/src/criteria.ts +183 -0
- package/src/data/adjudication.json +770 -0
- package/src/data/guidance/rgaa.json +2395 -0
- package/src/data/standards/rgaa.glossary.json +478 -0
- package/src/data/standards/rgaa.json +3454 -0
- package/src/data/wcag-universe.json +530 -0
- package/src/data/wcag.json +1178 -0
- package/src/discover.ts +203 -0
- package/src/fix/codemods.ts +76 -0
- package/src/fix/diff.ts +64 -0
- package/src/fix/edits.ts +126 -0
- package/src/fix.ts +215 -0
- package/src/gh.ts +214 -0
- package/src/glob.ts +206 -0
- package/src/graph/build.ts +114 -0
- package/src/graph/engine-resolve.ts +0 -0
- package/src/graph/graph.ts +126 -0
- package/src/graph/imports.ts +359 -0
- package/src/graph/resolve.ts +73 -0
- package/src/graph/tsconfig.ts +113 -0
- package/src/grounding.ts +119 -0
- package/src/guidance/index.ts +45 -0
- package/src/guidance/types.ts +36 -0
- package/src/init.ts +113 -0
- package/src/mcp/handlers.ts +388 -0
- package/src/mcp/http.ts +260 -0
- package/src/mcp/prompts.ts +183 -0
- package/src/mcp/protocol.ts +187 -0
- package/src/mcp/resources.ts +136 -0
- package/src/mcp/server.ts +228 -0
- package/src/mcp/stdio.ts +119 -0
- package/src/mcp/tools.ts +347 -0
- package/src/messages.ts +1011 -0
- package/src/name.ts +174 -0
- package/src/orchestrate-templates.ts +231 -0
- package/src/orchestrate.ts +186 -0
- package/src/output.ts +108 -0
- package/src/pack.ts +187 -0
- package/src/parse/html.ts +258 -0
- package/src/parse/jsx-ast.ts +67 -0
- package/src/parse/jsx-bridge.ts +247 -0
- package/src/parse/jsx.ts +19 -0
- package/src/parse/source.ts +75 -0
- package/src/prd.ts +406 -0
- package/src/project-lock.ts +42 -0
- package/src/render.ts +307 -0
- package/src/report.ts +399 -0
- package/src/rules/colors.ts +125 -0
- package/src/rules/cross-registry.ts +264 -0
- package/src/rules/cross-rule.ts +83 -0
- package/src/rules/forms.ts +590 -0
- package/src/rules/frames.ts +33 -0
- package/src/rules/headings.ts +227 -0
- package/src/rules/images.ts +200 -0
- package/src/rules/links.ts +193 -0
- package/src/rules/mandatory.ts +173 -0
- package/src/rules/multimedia.ts +77 -0
- package/src/rules/navigation.ts +190 -0
- package/src/rules/presentation.ts +75 -0
- package/src/rules/registry.ts +57 -0
- package/src/rules/rule.ts +122 -0
- package/src/rules/scripts-aria.ts +636 -0
- package/src/rules/tables.ts +264 -0
- package/src/rules/timing.ts +55 -0
- package/src/sample.ts +176 -0
- package/src/scan-local.ts +866 -0
- package/src/scan.ts +538 -0
- package/src/standards/derive.ts +195 -0
- package/src/standards/index.ts +31 -0
- package/src/standards/pack-rules.ts +111 -0
- package/src/standards/pack.ts +81 -0
- package/src/standards/registry.ts +100 -0
- package/src/standards/types.ts +202 -0
- package/src/standards/validate.ts +544 -0
- package/src/standards/vocabulary.ts +75 -0
- package/src/types.ts +355 -0
- package/src/util.ts +32 -0
- package/src/vendor/codeindex-engine.d.mts +1009 -0
- package/src/vendor/codeindex-engine.mjs +13701 -0
- package/src/vendor/engine.meta.json +9 -0
- package/src/verify.ts +275 -0
- 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];
|