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,173 @@
|
|
|
1
|
+
// Theme 8 — Mandatory elements (the statically-checkable slice).
|
|
2
|
+
import type { Doc, El, HNode } from "../parse/html.js";
|
|
3
|
+
import { attr, hasAttr, visibleText, allIds, elementsByTag } from "../parse/html.js";
|
|
4
|
+
import { isDisplayHidden } from "../name.js";
|
|
5
|
+
import { type Rule, type RuleFinding, shellHeadInjected } from "./rule.js";
|
|
6
|
+
|
|
7
|
+
// Next.js App Router sets the document <title> via `export const metadata = { title }`
|
|
8
|
+
// or `generateMetadata`, not a literal <title> in JSX — invisible to source analysis.
|
|
9
|
+
// When that API is present in a JSX/TSX file, the title is managed by the framework, so
|
|
10
|
+
// title-missing-empty must not assert a (false) non-conformity.
|
|
11
|
+
const NEXT_METADATA = /export\s+(const\s+metadata\b|(async\s+)?function\s+generateMetadata\b)/;
|
|
12
|
+
function titleSetByFramework(doc: Doc): boolean {
|
|
13
|
+
if (doc.kind === "html") return false;
|
|
14
|
+
return NEXT_METADATA.test(doc.source) && /\btitle\s*:/.test(doc.source);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const htmlLangMissing: Rule = {
|
|
18
|
+
id: "html-lang-missing",
|
|
19
|
+
criteria: ["3.1.1"],
|
|
20
|
+
severity: "bloquant",
|
|
21
|
+
scope: "page",
|
|
22
|
+
run(doc: Doc): RuleFinding[] {
|
|
23
|
+
const html = elementsByTag(doc, "html")[0];
|
|
24
|
+
if (!html) return [];
|
|
25
|
+
const lang = (attr(html, "lang") ?? attr(html, "xml:lang") ?? "").trim();
|
|
26
|
+
if (lang) return [];
|
|
27
|
+
return [
|
|
28
|
+
{
|
|
29
|
+
criteriaId: "3.1.1",
|
|
30
|
+
el: html,
|
|
31
|
+
msgId: "html-lang-missing",
|
|
32
|
+
},
|
|
33
|
+
];
|
|
34
|
+
},
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
const titleMissingEmpty: Rule = {
|
|
38
|
+
id: "title-missing-empty",
|
|
39
|
+
criteria: ["2.4.2"],
|
|
40
|
+
severity: "bloquant",
|
|
41
|
+
scope: "page",
|
|
42
|
+
run(doc: Doc): RuleFinding[] {
|
|
43
|
+
const titles = elementsByTag(doc, "title");
|
|
44
|
+
const hasNonEmpty = titles.some((t) => visibleText(t).length > 0);
|
|
45
|
+
if (hasNonEmpty) return [];
|
|
46
|
+
// <title> injected by the Next.js metadata API, or by a framework shell placeholder
|
|
47
|
+
// in <head> (e.g. SvelteKit `%sveltekit.head%`, `<%= title %>`).
|
|
48
|
+
if (titleSetByFramework(doc) || shellHeadInjected(doc)) return [];
|
|
49
|
+
const anchor: El | undefined = elementsByTag(doc, "head")[0] ?? elementsByTag(doc, "html")[0] ?? doc.elements[0];
|
|
50
|
+
if (!anchor) return [];
|
|
51
|
+
return [
|
|
52
|
+
{
|
|
53
|
+
criteriaId: "2.4.2",
|
|
54
|
+
el: anchor,
|
|
55
|
+
msgId: "title-missing-empty",
|
|
56
|
+
params: { titleState: titles.length ? "empty" : "absent" },
|
|
57
|
+
},
|
|
58
|
+
];
|
|
59
|
+
},
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
// Two elements are mutually exclusive at runtime when their JSX conditional-arm paths
|
|
63
|
+
// (tagged by the parser: "/c0T" vs "/c0F" for a ternary's two arms, "/c1R" for the
|
|
64
|
+
// right operand of an && / ||) first diverge INSIDE THE SAME conditional — only one of
|
|
65
|
+
// the two arms is ever rendered. Independent conditionals ("/c0R" vs "/c1R") and any
|
|
66
|
+
// unconditional element (no arm) can co-render, so they are NOT exclusive. HTML/SFC docs
|
|
67
|
+
// never set branchArm, so this always returns false there (behaviour unchanged).
|
|
68
|
+
function armsExclusive(a: string | undefined, b: string | undefined): boolean {
|
|
69
|
+
if (!a || !b) return false;
|
|
70
|
+
const sa = a.split("/").filter(Boolean);
|
|
71
|
+
const sb = b.split("/").filter(Boolean);
|
|
72
|
+
const n = Math.min(sa.length, sb.length);
|
|
73
|
+
for (let i = 0; i < n; i++) {
|
|
74
|
+
if (sa[i] === sb[i]) continue;
|
|
75
|
+
const ca = /^c(\d+)/.exec(sa[i]!)?.[1];
|
|
76
|
+
const cb = /^c(\d+)/.exec(sb[i]!)?.[1];
|
|
77
|
+
return ca !== undefined && ca === cb; // same conditional, different arm → exclusive
|
|
78
|
+
}
|
|
79
|
+
return false; // one path is a prefix of the other → they can co-render
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const duplicateId: Rule = {
|
|
83
|
+
id: "duplicate-id",
|
|
84
|
+
criteria: ["4.1.2"],
|
|
85
|
+
severity: "majeur",
|
|
86
|
+
run(doc: Doc): RuleFinding[] {
|
|
87
|
+
const out: RuleFinding[] = [];
|
|
88
|
+
const byId = new Map<string, El[]>();
|
|
89
|
+
for (const { id, el } of allIds(doc)) {
|
|
90
|
+
if (id.includes("{")) continue; // dynamic id (id={`x-${i}`}/id="x-{id}") — unique per instance at runtime
|
|
91
|
+
const prior = byId.get(id);
|
|
92
|
+
if (!prior) {
|
|
93
|
+
byId.set(id, [el]);
|
|
94
|
+
continue;
|
|
95
|
+
}
|
|
96
|
+
// A real collision only when this element can co-render with an earlier one of the
|
|
97
|
+
// same id. Ids reused across mutually-exclusive JSX conditional arms never coexist.
|
|
98
|
+
if (prior.some((p) => !armsExclusive(p.branchArm, el.branchArm))) {
|
|
99
|
+
out.push({ criteriaId: "4.1.2", el, msgId: "duplicate-id", params: { id } });
|
|
100
|
+
}
|
|
101
|
+
prior.push(el);
|
|
102
|
+
}
|
|
103
|
+
return out;
|
|
104
|
+
},
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
const inlineLangChangeMissing: Rule = {
|
|
108
|
+
id: "inline-lang-change-missing",
|
|
109
|
+
criteria: ["3.1.2"],
|
|
110
|
+
severity: "mineur",
|
|
111
|
+
run(doc: Doc): RuleFinding[] {
|
|
112
|
+
const out: RuleFinding[] = [];
|
|
113
|
+
for (const el of doc.elements) {
|
|
114
|
+
if (el.tag === "html") continue;
|
|
115
|
+
if (!hasAttr(el, "lang")) continue;
|
|
116
|
+
if ((attr(el, "lang") ?? "").trim() === "") {
|
|
117
|
+
out.push({
|
|
118
|
+
criteriaId: "3.1.2",
|
|
119
|
+
el,
|
|
120
|
+
msgId: "inline-lang-change-missing",
|
|
121
|
+
params: { tag: el.tag },
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
return out;
|
|
126
|
+
},
|
|
127
|
+
};
|
|
128
|
+
|
|
129
|
+
// BCP47 primary subtag + optional subtags (syntactic validity only). The primary
|
|
130
|
+
// subtag is a 2-3 alpha language code, OR the `x`/`i` singleton that starts a
|
|
131
|
+
// private-use (`x-klingon`) or grandfathered (`i-navajo`) tag — those legitimate
|
|
132
|
+
// singletons must not be flagged invalid (the old /^[A-Za-z]{2,3}…/ rejected them).
|
|
133
|
+
const BCP47 = /^([A-Za-z]{2,3}|[xXiI])(-[A-Za-z0-9]{1,8})*$/;
|
|
134
|
+
|
|
135
|
+
/** Does this element govern any rendered text of its own — text that is neither hidden nor
|
|
136
|
+
* re-declared by a descendant's own `lang`? */
|
|
137
|
+
function governsText(el: El): boolean {
|
|
138
|
+
const walk = (n: HNode): boolean => {
|
|
139
|
+
if (n.type === "text") return n.data.trim() !== "";
|
|
140
|
+
if (hasAttr(n, "lang")) return false; // the descendant declares its own language
|
|
141
|
+
if (isDisplayHidden(n)) return false;
|
|
142
|
+
return n.children.some(walk);
|
|
143
|
+
};
|
|
144
|
+
return el.children.some(walk);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
const langInvalid: Rule = {
|
|
148
|
+
id: "lang-invalid",
|
|
149
|
+
criteria: ["3.1.1", "3.1.2"],
|
|
150
|
+
severity: "mineur",
|
|
151
|
+
run(doc: Doc): RuleFinding[] {
|
|
152
|
+
const out: RuleFinding[] = [];
|
|
153
|
+
for (const el of doc.elements) {
|
|
154
|
+
const lang = (attr(el, "lang") ?? "").trim();
|
|
155
|
+
if (!lang) continue; // empty handled by inline-lang-change-missing / html-lang-missing
|
|
156
|
+
if (BCP47.test(lang)) continue;
|
|
157
|
+
// A malformed `lang` only misdeclares something if it actually GOVERNS text. On
|
|
158
|
+
// <html> that is the whole document; elsewhere, text sitting under a descendant with
|
|
159
|
+
// its own `lang`, or inside a hidden subtree, is not governed by this attribute — and
|
|
160
|
+
// an element governing no text at all declares a language for nobody.
|
|
161
|
+
if (el.tag !== "html" && !governsText(el)) continue;
|
|
162
|
+
out.push({
|
|
163
|
+
criteriaId: el.tag === "html" ? "3.1.1" : "3.1.2",
|
|
164
|
+
el,
|
|
165
|
+
msgId: "lang-invalid",
|
|
166
|
+
params: { lang, tag: el.tag },
|
|
167
|
+
});
|
|
168
|
+
}
|
|
169
|
+
return out;
|
|
170
|
+
},
|
|
171
|
+
};
|
|
172
|
+
|
|
173
|
+
export const mandatoryRules: Rule[] = [htmlLangMissing, titleMissingEmpty, duplicateId, inlineLangChangeMissing, langInvalid];
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
// Theme 4 / 13 — Auto-starting media + missing captions track (statically-checkable slice).
|
|
2
|
+
import type { Doc, El } from "../parse/html.js";
|
|
3
|
+
import { attr, hasAttr, descendants } from "../parse/html.js";
|
|
4
|
+
import type { Rule, RuleFinding } from "./rule.js";
|
|
5
|
+
|
|
6
|
+
// `muted` is statically TRUE only as a bare boolean attr, a literal "muted"/"true", or the
|
|
7
|
+
// JSX literal `{true}`. A JSX expression like `muted={isMuted}` or `muted={false}` is NOT
|
|
8
|
+
// statically muted — an audit must not assume silence from it (avoids a captions false
|
|
9
|
+
// negative on a sound-bearing video).
|
|
10
|
+
function mutedStatically(el: El): boolean {
|
|
11
|
+
if (!hasAttr(el, "muted")) return false;
|
|
12
|
+
const v = (attr(el, "muted") ?? "").trim().toLowerCase();
|
|
13
|
+
return v === "" || v === "muted" || v === "true" || v === "{true}";
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const autoplayMedia: Rule = {
|
|
17
|
+
id: "autoplay-media",
|
|
18
|
+
criteria: ["1.4.2", "2.2.2"],
|
|
19
|
+
severity: "majeur",
|
|
20
|
+
run(doc: Doc): RuleFinding[] {
|
|
21
|
+
const out: RuleFinding[] = [];
|
|
22
|
+
for (const el of doc.elements) {
|
|
23
|
+
if (el.tag !== "audio" && el.tag !== "video") continue;
|
|
24
|
+
if (!hasAttr(el, "autoplay")) continue;
|
|
25
|
+
// `controls` IS the mechanism 1.4.2 asks for (pause/stop/volume), so autoplay with
|
|
26
|
+
// native controls conforms — reporting it would be inventing a non-conformity.
|
|
27
|
+
if (hasAttr(el, "controls")) continue;
|
|
28
|
+
// muted video has no sound → it falls under moving content (13.8), audio always 4.10.
|
|
29
|
+
// Whether the movement lasts the 5 seconds 2.2.2 requires is not knowable from the
|
|
30
|
+
// source, so this is a recommendation to check, never an asserted failure.
|
|
31
|
+
if (el.tag === "video" && mutedStatically(el)) {
|
|
32
|
+
out.push({
|
|
33
|
+
criteriaId: "2.2.2",
|
|
34
|
+
el,
|
|
35
|
+
msgId: "autoplay-media.muted-video",
|
|
36
|
+
advisory: true,
|
|
37
|
+
});
|
|
38
|
+
continue;
|
|
39
|
+
}
|
|
40
|
+
out.push({
|
|
41
|
+
criteriaId: el.tag === "audio" ? "1.4.2" : "2.2.2",
|
|
42
|
+
el,
|
|
43
|
+
msgId: "autoplay-media.audible",
|
|
44
|
+
params: { tag: el.tag },
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
return out;
|
|
48
|
+
},
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
// A <video> that carries audio (not muted) but ships no <track> element. The absence is
|
|
52
|
+
// worth surfacing, but it is NOT proof of a 1.2.2 failure: captions can be embedded in the
|
|
53
|
+
// media container itself, and the audio track may be silent. Neither is knowable from
|
|
54
|
+
// source, so this is a NON-NORMATIVE recommendation — 1.2.2 stays a judgment criterion the
|
|
55
|
+
// agent adjudicates against the real media, rather than being flipped to NC on a guess.
|
|
56
|
+
const mediaNoTrack: Rule = {
|
|
57
|
+
id: "media-no-track",
|
|
58
|
+
criteria: ["1.2.2"],
|
|
59
|
+
severity: "majeur",
|
|
60
|
+
advisory: true,
|
|
61
|
+
run(doc: Doc): RuleFinding[] {
|
|
62
|
+
const out: RuleFinding[] = [];
|
|
63
|
+
for (const el of doc.elements) {
|
|
64
|
+
if (el.tag !== "video") continue;
|
|
65
|
+
if (mutedStatically(el)) continue; // statically-muted video carries no audio to caption
|
|
66
|
+
if (descendants(el).some((d) => d.tag === "track")) continue;
|
|
67
|
+
out.push({
|
|
68
|
+
criteriaId: "1.2.2",
|
|
69
|
+
el,
|
|
70
|
+
msgId: "media-no-track",
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
return out;
|
|
74
|
+
},
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
export const multimediaRules: Rule[] = [autoplayMedia, mediaNoTrack];
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
// Theme 12 — Navigation + page landmarks (statically-checkable slice).
|
|
2
|
+
import type { Doc, El } from "../parse/html.js";
|
|
3
|
+
import { attr, descendants, elementsByTag, hasAttr, hasBoundAttr, hasDynamicSpread } from "../parse/html.js";
|
|
4
|
+
import { isIntrinsic } from "../parse/jsx-bridge.js";
|
|
5
|
+
import { type Rule, type RuleFinding, shellBodyInjected } from "./rule.js";
|
|
6
|
+
|
|
7
|
+
// JSX/SFC: a child component or a `{children}`/`{expr}` child may inject the <main> at
|
|
8
|
+
// runtime (a Next.js layout supplies <main> via {children}). Never assert "no main" then.
|
|
9
|
+
function contentMaybeInjected(doc: Doc): boolean {
|
|
10
|
+
if (doc.kind === "html") return false;
|
|
11
|
+
return doc.elements.some((e) => e.tag === "slot" || (e.tag !== "#fragment" && !isIntrinsic(e.tag)));
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** Visible <main>/role="main" landmarks (aria-hidden ones don't count). */
|
|
15
|
+
function mainLandmarks(doc: Doc): El[] {
|
|
16
|
+
return doc.elements.filter((e) => {
|
|
17
|
+
if (attr(e, "aria-hidden") === "true") return false;
|
|
18
|
+
if (e.tag === "main") return true;
|
|
19
|
+
return (attr(e, "role") ?? "").trim().toLowerCase() === "main";
|
|
20
|
+
});
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Visible <nav>/role="navigation" landmarks (aria-hidden ones don't count). */
|
|
24
|
+
function navLandmarks(doc: Doc): El[] {
|
|
25
|
+
return doc.elements.filter((e) => {
|
|
26
|
+
if (attr(e, "aria-hidden") === "true") return false;
|
|
27
|
+
if (e.tag === "nav") return true;
|
|
28
|
+
return (attr(e, "role") ?? "").trim().toLowerCase() === "navigation";
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** A <nav> carries a distinguishing accessible name (literal or dynamically bound). */
|
|
33
|
+
function navIsNamed(el: El): boolean {
|
|
34
|
+
if ((attr(el, "aria-label") ?? "").trim() || hasAttr(el, "aria-labelledby")) return true;
|
|
35
|
+
if (hasBoundAttr(el, "aria-label") || hasBoundAttr(el, "aria-labelledby")) return true; // value injected at runtime
|
|
36
|
+
return hasDynamicSpread(el); // a spread may inject aria-label — can't prove it unnamed
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const skipLinkTargetMissing: Rule = {
|
|
40
|
+
id: "skip-link-target-missing",
|
|
41
|
+
criteria: ["2.4.1"],
|
|
42
|
+
severity: "majeur",
|
|
43
|
+
scope: "page",
|
|
44
|
+
run(doc: Doc): RuleFinding[] {
|
|
45
|
+
const out: RuleFinding[] = [];
|
|
46
|
+
const hasName = (id: string): boolean => doc.byId.has(id) || doc.elements.some((e) => attr(e, "name") === id);
|
|
47
|
+
for (const el of doc.elements) {
|
|
48
|
+
if (el.tag !== "a") continue;
|
|
49
|
+
const href = attr(el, "href") ?? "";
|
|
50
|
+
if (!href.startsWith("#") || href === "#") continue;
|
|
51
|
+
let id = href.slice(1);
|
|
52
|
+
try {
|
|
53
|
+
id = decodeURIComponent(id);
|
|
54
|
+
} catch {
|
|
55
|
+
/* keep raw */
|
|
56
|
+
}
|
|
57
|
+
if (hasName(id)) continue;
|
|
58
|
+
out.push({
|
|
59
|
+
criteriaId: "2.4.1",
|
|
60
|
+
el,
|
|
61
|
+
msgId: "skip-link-target-missing",
|
|
62
|
+
params: { href, id },
|
|
63
|
+
// In a JSX/SFC component the target id often lives in a sibling/parent component
|
|
64
|
+
// resolved at composition time (or via --graph). Single-file source can't prove it
|
|
65
|
+
// missing, so flag it provisional rather than a hard NC. Full HTML pages stay firm.
|
|
66
|
+
preliminary: doc.kind !== "html",
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
return out;
|
|
70
|
+
},
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
const positiveTabindex: Rule = {
|
|
74
|
+
id: "positive-tabindex",
|
|
75
|
+
criteria: ["2.4.3"],
|
|
76
|
+
severity: "majeur",
|
|
77
|
+
run(doc: Doc): RuleFinding[] {
|
|
78
|
+
const out: RuleFinding[] = [];
|
|
79
|
+
for (const el of doc.elements) {
|
|
80
|
+
if (!hasAttr(el, "tabindex")) continue;
|
|
81
|
+
// HTML parses tabindex with integer rules (parseInt), so "1.5" is a positive 1.
|
|
82
|
+
const ti = Number.parseInt((attr(el, "tabindex") ?? "").trim(), 10);
|
|
83
|
+
if (ti > 0) {
|
|
84
|
+
out.push({
|
|
85
|
+
criteriaId: "2.4.3",
|
|
86
|
+
el,
|
|
87
|
+
msgId: "positive-tabindex",
|
|
88
|
+
params: { value: ti },
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
return out;
|
|
93
|
+
},
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
// A full page must expose its primary content in a <main> landmark so AT users can jump
|
|
97
|
+
// to it (and skip links have a target). Page-scoped: never runs on fragments/components.
|
|
98
|
+
const missingMainLandmark: Rule = {
|
|
99
|
+
id: "missing-main-landmark",
|
|
100
|
+
criteria: ["1.3.1"],
|
|
101
|
+
severity: "majeur",
|
|
102
|
+
scope: "page",
|
|
103
|
+
run(doc: Doc): RuleFinding[] {
|
|
104
|
+
if (mainLandmarks(doc).length > 0) return [];
|
|
105
|
+
if (contentMaybeInjected(doc) || shellBodyInjected(doc)) return []; // <main> may be injected at runtime
|
|
106
|
+
const anchor = elementsByTag(doc, "body")[0] ?? elementsByTag(doc, "html")[0];
|
|
107
|
+
if (!anchor) return [];
|
|
108
|
+
return [
|
|
109
|
+
{
|
|
110
|
+
criteriaId: "1.3.1",
|
|
111
|
+
el: anchor,
|
|
112
|
+
msgId: "missing-main-landmark",
|
|
113
|
+
},
|
|
114
|
+
];
|
|
115
|
+
},
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
const multipleMainLandmark: Rule = {
|
|
119
|
+
id: "multiple-main-landmark",
|
|
120
|
+
criteria: ["1.3.1"],
|
|
121
|
+
severity: "majeur",
|
|
122
|
+
scope: "page",
|
|
123
|
+
run(doc: Doc): RuleFinding[] {
|
|
124
|
+
const mains = mainLandmarks(doc);
|
|
125
|
+
if (mains.length <= 1) return [];
|
|
126
|
+
return mains.slice(1).map((el) => ({
|
|
127
|
+
criteriaId: "1.3.1",
|
|
128
|
+
el,
|
|
129
|
+
msgId: "multiple-main-landmark",
|
|
130
|
+
params: { count: mains.length },
|
|
131
|
+
}));
|
|
132
|
+
},
|
|
133
|
+
};
|
|
134
|
+
|
|
135
|
+
// A full content page whose navigation links sit in a header/aside cluster with NO
|
|
136
|
+
// <nav>/role="navigation" landmark anywhere — AT users cannot reach a "navigation" region
|
|
137
|
+
// (RGAA 9.2/12.6). Conservative to stay precise: requires a real content page (<main>), a
|
|
138
|
+
// DENSE link cluster (≥4 links) inside a single <header>/<aside>, and ZERO nav landmarks.
|
|
139
|
+
const NAV_CLUSTER_MIN = 4;
|
|
140
|
+
|
|
141
|
+
const navLandmarkMissing: Rule = {
|
|
142
|
+
id: "nav-landmark-missing",
|
|
143
|
+
criteria: ["1.3.1"],
|
|
144
|
+
severity: "majeur",
|
|
145
|
+
scope: "page",
|
|
146
|
+
run(doc: Doc): RuleFinding[] {
|
|
147
|
+
if (navLandmarks(doc).length > 0) return []; // a navigation region already exists
|
|
148
|
+
if (mainLandmarks(doc).length === 0) return []; // not a real content page — don't assert
|
|
149
|
+
if (contentMaybeInjected(doc) || shellBodyInjected(doc)) return []; // <nav> may be injected at runtime
|
|
150
|
+
for (const el of doc.elements) {
|
|
151
|
+
if (el.tag !== "header" && el.tag !== "aside") continue;
|
|
152
|
+
const links = descendants(el).filter((d) => d.tag === "a" && hasAttr(d, "href"));
|
|
153
|
+
if (links.length >= NAV_CLUSTER_MIN) {
|
|
154
|
+
return [
|
|
155
|
+
{
|
|
156
|
+
criteriaId: "1.3.1",
|
|
157
|
+
el,
|
|
158
|
+
msgId: "nav-landmark-missing",
|
|
159
|
+
params: { count: links.length, region: el.tag },
|
|
160
|
+
},
|
|
161
|
+
];
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
return [];
|
|
165
|
+
},
|
|
166
|
+
};
|
|
167
|
+
|
|
168
|
+
// ≥2 navigation regions where at least one lacks a distinguishing accessible name — AT
|
|
169
|
+
// users cannot tell them apart (RGAA 12.6). Fires only on the unnamed landmark(s); a
|
|
170
|
+
// single unnamed <nav> is fine (nothing to disambiguate) and never fires.
|
|
171
|
+
const navLandmarkUnnamed: Rule = {
|
|
172
|
+
id: "nav-landmark-unnamed",
|
|
173
|
+
criteria: ["1.3.1"],
|
|
174
|
+
severity: "mineur",
|
|
175
|
+
scope: "page",
|
|
176
|
+
run(doc: Doc): RuleFinding[] {
|
|
177
|
+
const navs = navLandmarks(doc);
|
|
178
|
+
if (navs.length < 2) return [];
|
|
179
|
+
return navs.filter((n) => !navIsNamed(n)).map((el) => ({ criteriaId: "1.3.1", el, msgId: "nav-landmark-unnamed" }));
|
|
180
|
+
},
|
|
181
|
+
};
|
|
182
|
+
|
|
183
|
+
export const navigationRules: Rule[] = [
|
|
184
|
+
skipLinkTargetMissing,
|
|
185
|
+
positiveTabindex,
|
|
186
|
+
missingMainLandmark,
|
|
187
|
+
multipleMainLandmark,
|
|
188
|
+
navLandmarkMissing,
|
|
189
|
+
navLandmarkUnnamed,
|
|
190
|
+
];
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
// Theme 10 — Presentation: the statically-checkable slice of zoom support.
|
|
2
|
+
// 10.4 (text legible at 200%) generally needs rendering, but a viewport meta that
|
|
3
|
+
// blocks zooming is a DEFINITE failure detectable from the markup alone.
|
|
4
|
+
import type { Doc } from "../parse/html.js";
|
|
5
|
+
import { attr, textContent } from "../parse/html.js";
|
|
6
|
+
import type { Rule, RuleFinding } from "./rule.js";
|
|
7
|
+
|
|
8
|
+
const metaViewportZoomBlock: Rule = {
|
|
9
|
+
id: "meta-viewport-zoom-block",
|
|
10
|
+
criteria: ["1.4.4"],
|
|
11
|
+
severity: "majeur",
|
|
12
|
+
run(doc: Doc): RuleFinding[] {
|
|
13
|
+
const out: RuleFinding[] = [];
|
|
14
|
+
for (const el of doc.elements) {
|
|
15
|
+
if (el.tag !== "meta" || (attr(el, "name") ?? "").toLowerCase() !== "viewport") continue;
|
|
16
|
+
const content = (attr(el, "content") ?? "").toLowerCase();
|
|
17
|
+
const pairs = new Map<string, string>();
|
|
18
|
+
for (const part of content.split(/[,;]/)) {
|
|
19
|
+
const [k, v] = part.split("=").map((s) => s.trim());
|
|
20
|
+
if (k) pairs.set(k, v ?? "");
|
|
21
|
+
}
|
|
22
|
+
const userScalable = pairs.get("user-scalable");
|
|
23
|
+
const maxScale = pairs.get("maximum-scale");
|
|
24
|
+
// Only a real, finite maximum-scale < 2 blocks zoom. An empty (`maximum-scale=`)
|
|
25
|
+
// or malformed value must NOT be treated as 0 (Number("") === 0) and falsely flagged.
|
|
26
|
+
const maxScaleNum = maxScale !== undefined && maxScale.trim() !== "" ? Number(maxScale) : Number.NaN;
|
|
27
|
+
// A maximum-scale of 0 or less is invalid: browsers discard it, so zoom is NOT
|
|
28
|
+
// blocked (`maximum-scale=-1` is a W3C ACT "passed" example).
|
|
29
|
+
const blocked = userScalable === "no" || userScalable === "0" || (Number.isFinite(maxScaleNum) && maxScaleNum > 0 && maxScaleNum < 2);
|
|
30
|
+
if (!blocked) continue;
|
|
31
|
+
out.push({
|
|
32
|
+
criteriaId: "1.4.4",
|
|
33
|
+
el,
|
|
34
|
+
msgId: "meta-viewport-zoom-block",
|
|
35
|
+
params: { blockedBy: userScalable === "no" || userScalable === "0" ? "user-scalable" : "maximum-scale", maxScale: maxScale ?? "" },
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
return out;
|
|
39
|
+
},
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
// ADVISORY (10.2-adjacent): CSS generated content that carries informative WORDS
|
|
43
|
+
// (`content: "…"`) is invisible to assistive technologies. Recommendation: move the text
|
|
44
|
+
// into the DOM. Conservative — only inline <style> text, only quoted values with a run of
|
|
45
|
+
// ≥3 letters (skips icon-font glyph escapes, punctuation, counters, quotes, attr()).
|
|
46
|
+
const CSS_CONTENT = /content\s*:\s*(["'])((?:\\.|(?!\1).)*)\1/gi;
|
|
47
|
+
const INFORMATIVE_WORD = /\p{L}{3,}/u;
|
|
48
|
+
|
|
49
|
+
const cssGeneratedContentInformative: Rule = {
|
|
50
|
+
id: "css-generated-content-informative",
|
|
51
|
+
criteria: ["1.3.1"],
|
|
52
|
+
severity: "mineur",
|
|
53
|
+
advisory: true,
|
|
54
|
+
run(doc: Doc): RuleFinding[] {
|
|
55
|
+
const out: RuleFinding[] = [];
|
|
56
|
+
for (const el of doc.elements) {
|
|
57
|
+
if (el.tag !== "style") continue;
|
|
58
|
+
const css = textContent(el);
|
|
59
|
+
const seen = new Set<string>();
|
|
60
|
+
CSS_CONTENT.lastIndex = 0;
|
|
61
|
+
let m: RegExpExecArray | null;
|
|
62
|
+
while ((m = CSS_CONTENT.exec(css))) {
|
|
63
|
+
const value = m[2] ?? "";
|
|
64
|
+
if (value.startsWith("\\")) continue; // a glyph escape like "\f001" (icon font) — not text
|
|
65
|
+
if (!INFORMATIVE_WORD.test(value)) continue; // punctuation / symbol / separator only
|
|
66
|
+
if (seen.has(value)) continue;
|
|
67
|
+
seen.add(value);
|
|
68
|
+
out.push({ criteriaId: "1.3.1", el, msgId: "css-generated-content-informative", params: { text: value.slice(0, 40) }, advisory: true });
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
return out;
|
|
72
|
+
},
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
export const presentationRules: Rule[] = [metaViewportZoomBlock, cssGeneratedContentInformative];
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
// The rule registry: the complete static surface of the engine. runRules walks
|
|
2
|
+
// every applicable rule over a parsed Doc and returns normalised Findings,
|
|
3
|
+
// sorted by source position. Page-scoped rules skip fragments/components.
|
|
4
|
+
import type { Doc } from "../parse/html.js";
|
|
5
|
+
import type { Finding } from "../types.js";
|
|
6
|
+
import { type Rule, toFinding, isFullDocument } from "./rule.js";
|
|
7
|
+
import { imagesRules } from "./images.js";
|
|
8
|
+
import { framesRules } from "./frames.js";
|
|
9
|
+
import { scriptsAriaRules } from "./scripts-aria.js";
|
|
10
|
+
import { mandatoryRules } from "./mandatory.js";
|
|
11
|
+
import { headingsRules } from "./headings.js";
|
|
12
|
+
import { tablesRules } from "./tables.js";
|
|
13
|
+
import { linksRules } from "./links.js";
|
|
14
|
+
import { formsRules } from "./forms.js";
|
|
15
|
+
import { navigationRules } from "./navigation.js";
|
|
16
|
+
import { multimediaRules } from "./multimedia.js";
|
|
17
|
+
import { presentationRules } from "./presentation.js";
|
|
18
|
+
import { colorsRules } from "./colors.js";
|
|
19
|
+
import { timingRules } from "./timing.js";
|
|
20
|
+
|
|
21
|
+
export const ALL_RULES: Rule[] = [
|
|
22
|
+
...colorsRules,
|
|
23
|
+
...imagesRules,
|
|
24
|
+
...framesRules,
|
|
25
|
+
...scriptsAriaRules,
|
|
26
|
+
...mandatoryRules,
|
|
27
|
+
...headingsRules,
|
|
28
|
+
...tablesRules,
|
|
29
|
+
...linksRules,
|
|
30
|
+
...formsRules,
|
|
31
|
+
...navigationRules,
|
|
32
|
+
...multimediaRules,
|
|
33
|
+
...presentationRules,
|
|
34
|
+
...timingRules,
|
|
35
|
+
];
|
|
36
|
+
|
|
37
|
+
const SEVERITY_ORDER: Record<string, number> = { bloquant: 0, majeur: 1, mineur: 2 };
|
|
38
|
+
|
|
39
|
+
export function ruleById(id: string): Rule | undefined {
|
|
40
|
+
return ALL_RULES.find((r) => r.id === id);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function ruleIds(): string[] {
|
|
44
|
+
return ALL_RULES.map((r) => r.id);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function runRules(doc: Doc, only?: Set<string>): Finding[] {
|
|
48
|
+
const out: Finding[] = [];
|
|
49
|
+
const fullDoc = isFullDocument(doc);
|
|
50
|
+
for (const rule of ALL_RULES) {
|
|
51
|
+
if (only && !only.has(rule.id)) continue;
|
|
52
|
+
if (rule.scope === "page" && !fullDoc) continue;
|
|
53
|
+
for (const rf of rule.run(doc)) out.push(toFinding(doc, rule.id, rule.severity, rf, rule.advisory));
|
|
54
|
+
}
|
|
55
|
+
out.sort((a, b) => a.line - b.line || a.col - b.col || SEVERITY_ORDER[a.severity]! - SEVERITY_ORDER[b.severity]!);
|
|
56
|
+
return out;
|
|
57
|
+
}
|