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,264 @@
1
+ // Theme 5 — Data tables (+ layout-table heuristic to avoid false positives).
2
+ import type { Doc, El } from "../parse/html.js";
3
+ import { attr, hasAttr, hasBoundAttr, descendants, ancestors, visibleText } from "../parse/html.js";
4
+ import { mayInjectContent } from "../name.js";
5
+ import type { Rule, RuleFinding } from "./rule.js";
6
+
7
+ const declaredLayout = (t: El): boolean => ["presentation", "none"].includes((attr(t, "role") ?? "").trim());
8
+ const named = (t: El): boolean => !!(attr(t, "aria-label") ?? "").trim() || hasAttr(t, "aria-labelledby");
9
+
10
+ // Heuristic: treat a table as LAYOUT (skip data-table rules) when it declares
11
+ // role=presentation/none, OR nests another table (data tables don't nest), OR is
12
+ // a trivial single-row table with no <th>/<caption>. Cuts the classic
13
+ // table-based-layout false positive without missing real data tables.
14
+ function isLayoutTable(t: El): boolean {
15
+ if (declaredLayout(t)) return true;
16
+ const desc = descendants(t);
17
+ if (desc.some((d) => d.tag === "table")) return true;
18
+ const hasTh = desc.some((d) => d.tag === "th");
19
+ const hasCaption = t.children.some((c) => c.type === "element" && c.tag === "caption");
20
+ const rows = desc.filter((d) => d.tag === "tr").length;
21
+ return !hasTh && !hasCaption && rows <= 1;
22
+ }
23
+
24
+ const dataTableNoHeaders: Rule = {
25
+ id: "data-table-no-headers",
26
+ criteria: ["1.3.1"],
27
+ severity: "majeur",
28
+ run(doc: Doc): RuleFinding[] {
29
+ const out: RuleFinding[] = [];
30
+ for (const t of doc.elements) {
31
+ if (t.tag !== "table" || isLayoutTable(t)) continue;
32
+ const desc = descendants(t);
33
+ // No statically-visible rows/cells means the body is injected dynamically
34
+ // ({children}/slot — e.g. a generic <table> wrapper component) or simply absent;
35
+ // we cannot tell whether headers exist, so we do not assert a definite NC.
36
+ if (!desc.some((d) => d.tag === "tr" || d.tag === "td" || d.tag === "th")) continue;
37
+ const ths = desc.filter((d) => d.tag === "th");
38
+ const hasTh = ths.length > 0;
39
+ // hasBoundAttr also matches a dynamic :scope / v-bind:scope binding (Vue/Svelte).
40
+ const hasAssoc = desc.some((d) => (d.tag === "td" || d.tag === "th") && (hasBoundAttr(d, "scope") || hasBoundAttr(d, "headers")));
41
+ // Implicit scope per HTML's header-association algorithm (WCAG technique H51): a simple
42
+ // table needs no explicit scope= when its headers sit in <thead>, OR form one complete
43
+ // header ROW (column headers), OR are the first cell of every row (row headers).
44
+ // (Order-independent: descendants() is not in document order.)
45
+ const rows = desc.filter((d) => d.tag === "tr");
46
+ const cellsOf = (tr: El): El[] => tr.children.filter((c): c is El => c.type === "element" && (c.tag === "th" || c.tag === "td"));
47
+ const allThInThead = hasTh && ths.every((th) => ancestors(th).some((a) => a.tag === "thead"));
48
+ const headerRow = rows.find((r) => {
49
+ const cells = cellsOf(r);
50
+ return cells.length > 0 && cells.every((c) => c.tag === "th");
51
+ });
52
+ const allThInHeaderRow = hasTh && headerRow !== undefined && ths.every((th) => cellsOf(headerRow).includes(th));
53
+ const allThFirstCol =
54
+ hasTh &&
55
+ ths.every((th) => {
56
+ const tr = ancestors(th).find((a) => a.tag === "tr");
57
+ return tr !== undefined && cellsOf(tr)[0] === th;
58
+ });
59
+ if (hasTh && (hasAssoc || allThInThead || allThInHeaderRow || allThFirstCol)) continue;
60
+ if (!hasTh) {
61
+ out.push({
62
+ criteriaId: "1.3.1",
63
+ el: t,
64
+ msgId: "data-table-no-headers.no-th",
65
+ });
66
+ } else if (!hasAssoc) {
67
+ out.push({
68
+ criteriaId: "1.3.1",
69
+ el: t,
70
+ msgId: "data-table-no-headers.no-assoc",
71
+ });
72
+ }
73
+ }
74
+ return out;
75
+ },
76
+ };
77
+
78
+ const tableCaptionMissing: Rule = {
79
+ id: "table-caption-missing",
80
+ criteria: ["1.3.1"],
81
+ severity: "mineur",
82
+ run(doc: Doc): RuleFinding[] {
83
+ const out: RuleFinding[] = [];
84
+ for (const t of doc.elements) {
85
+ if (t.tag !== "table" || isLayoutTable(t)) continue;
86
+ const hasCaption = t.children.some((c) => c.type === "element" && c.tag === "caption");
87
+ if (hasCaption || named(t)) continue;
88
+ out.push({
89
+ criteriaId: "1.3.1",
90
+ el: t,
91
+ msgId: "table-caption-missing",
92
+ });
93
+ }
94
+ return out;
95
+ },
96
+ };
97
+
98
+ const layoutTableDataMarkup: Rule = {
99
+ id: "layout-table-data-markup",
100
+ criteria: ["1.3.1"],
101
+ severity: "mineur",
102
+ run(doc: Doc): RuleFinding[] {
103
+ const out: RuleFinding[] = [];
104
+ for (const t of doc.elements) {
105
+ if (t.tag !== "table" || !declaredLayout(t)) continue;
106
+ const desc = descendants(t);
107
+ const dataMarkup =
108
+ desc.some((d) => d.tag === "th") ||
109
+ t.children.some((c) => c.type === "element" && c.tag === "caption") ||
110
+ desc.some((d) => hasAttr(d, "scope") || hasAttr(d, "headers"));
111
+ if (!dataMarkup) continue;
112
+ out.push({
113
+ criteriaId: "1.3.1",
114
+ el: t,
115
+ msgId: "layout-table-data-markup",
116
+ params: { role: attr(t, "role") ?? "" },
117
+ });
118
+ }
119
+ return out;
120
+ },
121
+ };
122
+
123
+ // A sortable column header that doesn't expose its sort state. When a <th>
124
+ // (or role=columnheader) contains a real sort control (button/link) AND signals
125
+ // sorting (a sort/tri class or label) but carries no aria-sort, AT cannot restitute
126
+ // the current sort order (WCAG 1.3.1). Layout tables are skipped.
127
+ const SORT_SIGNAL = /sort|trier|tri\b/i;
128
+ const isSortControl = (e: El): boolean => e.tag === "button" || (e.tag === "a" && hasAttr(e, "href")) || (attr(e, "role") ?? "").trim() === "button";
129
+ const signalsSort = (e: El): boolean => SORT_SIGNAL.test(`${attr(e, "class") ?? ""} ${attr(e, "aria-label") ?? ""}`) || hasAttr(e, "data-sort");
130
+
131
+ const sortableHeaderNoAriaSort: Rule = {
132
+ id: "sortable-header-no-aria-sort",
133
+ criteria: ["1.3.1"],
134
+ severity: "mineur",
135
+ run(doc: Doc): RuleFinding[] {
136
+ const out: RuleFinding[] = [];
137
+ for (const el of doc.elements) {
138
+ const isHeader = el.tag === "th" || (attr(el, "role") ?? "").trim() === "columnheader";
139
+ if (!isHeader) continue;
140
+ if (hasAttr(el, "aria-sort")) continue; // already declares sort state (any value)
141
+ const table = ancestors(el).find((a) => a.tag === "table");
142
+ if (table && isLayoutTable(table)) continue;
143
+ const desc = descendants(el);
144
+ if (!isSortControl(el) && !desc.some(isSortControl)) continue; // no actual sort control
145
+ if (!signalsSort(el) && !desc.some(signalsSort)) continue; // nothing marks it as sortable
146
+ out.push({
147
+ criteriaId: "1.3.1",
148
+ el,
149
+ msgId: "sortable-header-no-aria-sort",
150
+ });
151
+ }
152
+ return out;
153
+ },
154
+ };
155
+
156
+ // ADVISORY (auditor recommendation, never an NC): an empty data cell — or one reduced to
157
+ // a lone "-" — is announced as "blank" by screen readers. The recommendation is to add
158
+ // sr-only text stating the absence of a value. Conservative: only bare <td>s in a data
159
+ // table with no name, no element children, and no injected content.
160
+ const tableEmptyDataCell: Rule = {
161
+ id: "table-empty-data-cell",
162
+ criteria: ["1.3.1"],
163
+ severity: "mineur",
164
+ advisory: true,
165
+ run(doc: Doc): RuleFinding[] {
166
+ const out: RuleFinding[] = [];
167
+ for (const t of doc.elements) {
168
+ if (t.tag !== "table" || isLayoutTable(t)) continue;
169
+ for (const cell of descendants(t)) {
170
+ if (cell.tag !== "td") continue;
171
+ if (mayInjectContent(cell)) continue; // value supplied via slot/component/{expr}
172
+ if (cell.children.some((c) => c.type === "element")) continue; // holds an icon/img/control — not blank
173
+ if ((attr(cell, "aria-label") ?? "").trim() || hasAttr(cell, "aria-labelledby")) continue; // named another way
174
+ const txt = visibleText(cell);
175
+ if (txt !== "" && txt !== "-") continue;
176
+ out.push({ criteriaId: "1.3.1", el: cell, msgId: "table-empty-data-cell", advisory: true });
177
+ }
178
+ }
179
+ return out;
180
+ },
181
+ };
182
+
183
+ /** The <table> a cell belongs to (nearest table ancestor), or undefined. */
184
+ function ownerTable(el: El, doc: Doc): El | undefined {
185
+ return ancestors(el).find((a) => a.tag === "table");
186
+ }
187
+
188
+ // A `headers` attribute pointing at ids that are not header cells OF THE SAME TABLE builds
189
+ // no association at all: the screen reader announces the cell with no header context, which
190
+ // is exactly the situation `headers` was added to prevent.
191
+ const headersAttrDangling: Rule = {
192
+ id: "headers-attr-dangling",
193
+ criteria: ["1.3.1"],
194
+ severity: "majeur",
195
+ run(doc: Doc): RuleFinding[] {
196
+ const out: RuleFinding[] = [];
197
+ for (const el of doc.elements) {
198
+ if (el.tag !== "td" && el.tag !== "th") continue;
199
+ const raw = (attr(el, "headers") ?? "").trim();
200
+ if (!raw || raw.includes("{")) continue;
201
+ const table = ownerTable(el, doc);
202
+ if (!table) continue;
203
+ const inTable = new Map(
204
+ descendants(table)
205
+ .filter((d) => d.tag === "th" || d.tag === "td")
206
+ .map((d) => [attr(d, "id") ?? "", d]),
207
+ );
208
+ const bad = raw
209
+ .split(/\s+/)
210
+ .filter(Boolean)
211
+ .filter((id) => {
212
+ const target = inTable.get(id);
213
+ return !target || target === el; // absent from this table, or the cell itself
214
+ });
215
+ if (!bad.length) continue;
216
+ out.push({ criteriaId: "1.3.1", el, msgId: "headers-attr-dangling", params: { ids: bad.join(" ") } });
217
+ }
218
+ return out;
219
+ },
220
+ };
221
+
222
+ // A header cell that heads NOTHING — no data cell resolves to it, by scope or by `headers`.
223
+ // It is announced as a header for an empty set, and the columns it looks like it covers are
224
+ // in fact unheaded.
225
+ const thNoDataCells: Rule = {
226
+ id: "th-no-data-cells",
227
+ criteria: ["1.3.1"],
228
+ severity: "mineur",
229
+ run(doc: Doc): RuleFinding[] {
230
+ const out: RuleFinding[] = [];
231
+ for (const table of doc.elements) {
232
+ if (table.tag !== "table" || isLayoutTable(table)) continue;
233
+ const cells = descendants(table).filter((d) => d.tag === "td" || d.tag === "th");
234
+ const headers = cells.filter((c) => c.tag === "th");
235
+ if (!headers.length) continue;
236
+ const dataCells = cells.filter((c) => c.tag === "td");
237
+ // A table with no data cells at all is a heading-only skeleton — nothing to assign,
238
+ // and data-table-no-headers already owns that shape.
239
+ if (!dataCells.length) continue;
240
+ // Only a FULLY headers-wired table can be judged here: as soon as one data cell
241
+ // relies on implicit row/column position, an unreferenced header may still head it,
242
+ // and deciding that needs a real table model (colspan, rowspan, header rows).
243
+ if (!dataCells.every((c) => (attr(c, "headers") ?? "").trim() !== "")) continue;
244
+ const referenced = new Set(dataCells.flatMap((c) => (attr(c, "headers") ?? "").split(/\s+/).filter(Boolean)));
245
+ for (const th of headers) {
246
+ if (mayInjectContent(th) || !visibleText(th).trim()) continue; // empty/injected: other rules own it
247
+ const id = attr(th, "id") ?? "";
248
+ if (id && referenced.has(id)) continue; // explicitly referenced by a data cell
249
+ out.push({ criteriaId: "1.3.1", el: th, msgId: "th-no-data-cells" });
250
+ }
251
+ }
252
+ return out;
253
+ },
254
+ };
255
+
256
+ export const tablesRules: Rule[] = [
257
+ dataTableNoHeaders,
258
+ tableCaptionMissing,
259
+ layoutTableDataMarkup,
260
+ sortableHeaderNoAriaSort,
261
+ tableEmptyDataCell,
262
+ headersAttrDangling,
263
+ thNoDataCells,
264
+ ];
@@ -0,0 +1,55 @@
1
+ // Theme 13 — Timing & moving content (the statically-checkable slice): timed meta
2
+ // refresh/redirect (WCAG 2.2.1) and the legacy <blink>/<marquee> auto-motion (2.2.2).
3
+ import type { Doc } from "../parse/html.js";
4
+ import { attr } from "../parse/html.js";
5
+ import type { Rule, RuleFinding } from "./rule.js";
6
+
7
+ const metaRefreshRedirect: Rule = {
8
+ id: "meta-refresh-redirect",
9
+ criteria: ["2.2.1"],
10
+ severity: "majeur",
11
+ run(doc: Doc): RuleFinding[] {
12
+ const out: RuleFinding[] = [];
13
+ // Only the FIRST `http-equiv="refresh"` in document order takes effect; any later one
14
+ // never runs, so reporting it would be a defect that cannot happen.
15
+ const first = doc.elements.find((e) => e.tag === "meta" && (attr(e, "http-equiv") ?? "").toLowerCase() === "refresh");
16
+ for (const el of first ? [first] : []) {
17
+ const content = (attr(el, "content") ?? "").trim();
18
+ // The delay must be a bare non-negative integer. `+5`, `-5` and `5.5` are invalid
19
+ // per the HTML parsing rules, so the browser ignores the directive entirely —
20
+ // Number.parseInt would happily read "+5" as 5 and invent a time limit.
21
+ if (!/^\d+\s*(?:[;,]|$)/.test(content)) continue;
22
+ const seconds = Number.parseInt(content, 10);
23
+ if (!Number.isFinite(seconds) || seconds <= 0) continue; // non-numeric / instant (0s) handled elsewhere
24
+ if (seconds > 72000) continue; // WCAG 2.2.1 exception: a time limit longer than 20 hours is exempt
25
+ out.push({
26
+ criteriaId: "2.2.1",
27
+ el,
28
+ msgId: "meta-refresh-redirect",
29
+ params: { content, seconds },
30
+ });
31
+ }
32
+ return out;
33
+ },
34
+ };
35
+
36
+ const blinkMarquee: Rule = {
37
+ id: "blink-marquee",
38
+ criteria: ["2.2.2"],
39
+ severity: "majeur",
40
+ run(doc: Doc): RuleFinding[] {
41
+ const out: RuleFinding[] = [];
42
+ for (const el of doc.elements) {
43
+ if (el.tag !== "blink" && el.tag !== "marquee") continue;
44
+ out.push({
45
+ criteriaId: "2.2.2",
46
+ el,
47
+ msgId: "blink-marquee",
48
+ params: { tag: el.tag },
49
+ });
50
+ }
51
+ return out;
52
+ },
53
+ };
54
+
55
+ export const timingRules: Rule[] = [metaRefreshRedirect, blinkMarquee];
package/src/sample.ts ADDED
@@ -0,0 +1,176 @@
1
+ // Core, STANDARD-AGNOSTIC page-sample (échantillon) mechanics. A real accessibility audit
2
+ // runs over a representative set of served pages (+ transverse elements), not the whole
3
+ // file tree — this validates the `sample` block of an Ultra11yConfig (hard error on
4
+ // malformed entries, mirroring the pack-loading validation style), projects it to the
5
+ // storageState-free shape recorded in an AuditResult, and lints a configured sample against
6
+ // a pack's NORMATIVE methodology (which required page kinds it covers).
7
+ //
8
+ // SECURITY: `storageState` values are FILE PATHS (Playwright session files). Their CONTENT
9
+ // is NEVER read here nor embedded in any output/finding/report — only the path is validated
10
+ // as a non-empty string (plus an existsSync advisory that cites the path, never the content).
11
+ // The required-kinds LIST is standard-specific and lives in the pack
12
+ // (src/standards/types.ts SampleMethodology), never in this core module.
13
+ import { existsSync } from "node:fs";
14
+ import type { SampleConfig, SamplePage, SampleScope } from "./types.js";
15
+ import type { SampleMethodology, SampleRequiredKind } from "./standards/types.js";
16
+
17
+ export interface SampleIssue {
18
+ path: string; // dotted path into the sample, e.g. "sample.pages[2].url"
19
+ message: string;
20
+ }
21
+
22
+ export interface SampleValidation {
23
+ ok: boolean;
24
+ issues: SampleIssue[];
25
+ // Advisory only (never block): e.g. a storageState PATH that does not exist on disk.
26
+ // The message cites the path string only — the file's CONTENT is never read.
27
+ warnings: SampleIssue[];
28
+ sample?: SampleConfig; // the normalized sample, present only when ok
29
+ }
30
+
31
+ /** A served URL (http(s)://) or a local HTML file path — the two things `scan` can target. */
32
+ function looksLikeTarget(u: string): boolean {
33
+ return /^https?:\/\//i.test(u) || /^(\.\.?[/\\]|[/\\])/.test(u) || /\.x?html?$/i.test(u);
34
+ }
35
+
36
+ /** Validate an untrusted `sample` config block. Any malformed entry is a hard error; a
37
+ * storageState path that does not exist on disk is a WARNING (advisory — the scan would
38
+ * fail to load the session, but the config itself is well-formed). */
39
+ export function validateSample(raw: unknown): SampleValidation {
40
+ const issues: SampleIssue[] = [];
41
+ const warnings: SampleIssue[] = [];
42
+ const err = (path: string, message: string) => issues.push({ path, message });
43
+ const warn = (path: string, message: string) => warnings.push({ path, message });
44
+
45
+ if (typeof raw !== "object" || raw === null || Array.isArray(raw)) {
46
+ err("sample", "sample must be an object { pages: [...], transverse?: [...] }");
47
+ return { ok: false, issues, warnings };
48
+ }
49
+ const s = raw as Record<string, unknown>;
50
+ const pages = Array.isArray(s.pages) ? (s.pages as unknown[]) : null;
51
+ if (!pages || pages.length === 0) err("sample.pages", "sample.pages must be a non-empty array");
52
+ const ids = new Set<string>();
53
+ pages?.forEach((pg, i) => {
54
+ if (typeof pg !== "object" || pg === null || Array.isArray(pg)) {
55
+ err(`sample.pages[${i}]`, "each page must be an object { id, name, url, auth?, storageState?, notes? }");
56
+ return;
57
+ }
58
+ const p = pg as Record<string, unknown>;
59
+ const id = p.id;
60
+ if (typeof id !== "string" || id.trim() === "") {
61
+ err(`sample.pages[${i}].id`, "page id must be a non-empty string");
62
+ } else {
63
+ if (ids.has(id)) err(`sample.pages[${i}].id`, `duplicate page id "${id}"`);
64
+ ids.add(id);
65
+ }
66
+ if (typeof p.name !== "string" || p.name.trim() === "") err(`sample.pages[${i}].name`, "page name must be a non-empty string");
67
+ if (typeof p.url !== "string" || p.url.trim() === "") err(`sample.pages[${i}].url`, "page url must be a non-empty string");
68
+ else if (!looksLikeTarget(p.url)) err(`sample.pages[${i}].url`, `malformed url "${p.url}" (expected an http(s):// URL or an HTML file path)`);
69
+ if (p.auth !== undefined && typeof p.auth !== "boolean") err(`sample.pages[${i}].auth`, "auth must be a boolean");
70
+ if (p.storageState !== undefined && (typeof p.storageState !== "string" || p.storageState.trim() === "")) {
71
+ err(`sample.pages[${i}].storageState`, "storageState must be a non-empty file path string");
72
+ } else if (typeof p.storageState === "string" && p.storageState.trim() !== "" && !existsSync(p.storageState)) {
73
+ // Path string only in the message — the file's content is never read here or anywhere.
74
+ warn(
75
+ `sample.pages[${i}].storageState`,
76
+ `storageState path not found: "${p.storageState}" (resolved from the current directory) — the scan will not be able to load this session`,
77
+ );
78
+ }
79
+ if (p.notes !== undefined && typeof p.notes !== "string") err(`sample.pages[${i}].notes`, "notes must be a string");
80
+ });
81
+
82
+ if (s.transverse !== undefined) {
83
+ if (!Array.isArray(s.transverse)) {
84
+ err("sample.transverse", "transverse must be an array of strings");
85
+ } else {
86
+ (s.transverse as unknown[]).forEach((t, i) => {
87
+ if (typeof t !== "string" || t.trim() === "") err(`sample.transverse[${i}]`, "transverse entries must be non-empty strings");
88
+ });
89
+ }
90
+ }
91
+
92
+ const ok = issues.length === 0;
93
+ return { ok, issues, warnings, sample: ok ? normalizeSample(s) : undefined };
94
+ }
95
+
96
+ function normalizeSample(s: Record<string, unknown>): SampleConfig {
97
+ const pages = (s.pages as Record<string, unknown>[]).map((p) => ({
98
+ id: String(p.id),
99
+ name: String(p.name),
100
+ url: String(p.url),
101
+ ...(typeof p.auth === "boolean" ? { auth: p.auth } : {}),
102
+ ...(typeof p.storageState === "string" ? { storageState: p.storageState } : {}),
103
+ ...(typeof p.notes === "string" ? { notes: p.notes } : {}),
104
+ }));
105
+ const transverse = Array.isArray(s.transverse) ? (s.transverse as string[]).map(String) : undefined;
106
+ return { pages, ...(transverse?.length ? { transverse } : {}) };
107
+ }
108
+
109
+ /** Project a validated sample to the storageState-free shape recorded on an AuditResult /
110
+ * DynamicResult. The Playwright session PATH is deliberately dropped — never persisted. */
111
+ export function sampleScope(sample: SampleConfig): SampleScope {
112
+ return {
113
+ pages: sample.pages.map((p) => ({
114
+ id: p.id,
115
+ name: p.name,
116
+ url: p.url,
117
+ ...(p.auth !== undefined ? { auth: p.auth } : {}),
118
+ ...(p.notes ? { notes: p.notes } : {}),
119
+ })),
120
+ ...(sample.transverse?.length ? { transverse: sample.transverse } : {}),
121
+ };
122
+ }
123
+
124
+ export interface SampleLint {
125
+ missing: SampleRequiredKind[]; // required kinds not covered by any configured page
126
+ }
127
+
128
+ // Accent-insensitive, case-folded normalization for fuzzy matching (RGAA labels carry
129
+ // diacritics and apostrophes; a user's page names rarely match verbatim).
130
+ const norm = (s: string): string =>
131
+ s
132
+ .normalize("NFD")
133
+ .replace(/\p{Diacritic}/gu, "")
134
+ .replace(/['’]/g, " ")
135
+ .toLowerCase();
136
+
137
+ // SHORT keywords (≤ 5 chars: aide, plan, faq, help, menu, home, index…) must match as a
138
+ // WHOLE WORD — bare substring matching over-credited ("plaide" → aide, "planning" → plan).
139
+ // Longer keywords keep substring matching so a stem still hits ("contact" → "contactez-nous").
140
+ const SHORT_KEYWORD = 5;
141
+ const escapeRe = (s: string): string => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
142
+ function keywordMatches(haystack: string, keyword: string): boolean {
143
+ if (keyword.length > SHORT_KEYWORD) return haystack.includes(keyword);
144
+ return new RegExp(`(^|[^a-z0-9])${escapeRe(keyword)}($|[^a-z0-9])`).test(haystack);
145
+ }
146
+
147
+ /** Lint a configured sample against a standard's normative methodology: which required page
148
+ * KINDS does no configured page cover? Fuzzy — matches a kind's keywords against each
149
+ * page's name/notes/url/id (accent-insensitive; whole-word for short keywords). Two
150
+ * documented heuristic credits: the "transverse" kind is covered when the sample declares
151
+ * any transverse element, and the "representative pages" kind is covered when the sample
152
+ * holds ≥ 2 pages (any multi-page sample de facto carries representative pages beyond a
153
+ * single entry point — this stops a constant false nag). Advisory output, never a gate. */
154
+ export function lintSample(sample: SampleConfig, methodology: SampleMethodology): SampleLint {
155
+ const haystacks = sample.pages.map((p) => norm([p.name, p.notes ?? "", p.url, p.id].join(" ")));
156
+ const transverseHay = norm((sample.transverse ?? []).join(" "));
157
+ const hasTransverse = (sample.transverse ?? []).length > 0;
158
+ const missing: SampleRequiredKind[] = [];
159
+ for (const kind of methodology.requiredKinds) {
160
+ const kws = kind.keywords.map(norm).filter(Boolean);
161
+ const covered =
162
+ haystacks.some((h) => kws.some((k) => keywordMatches(h, k))) ||
163
+ (transverseHay !== "" && kws.some((k) => keywordMatches(transverseHay, k))) ||
164
+ (hasTransverse && /transverse/.test(kind.id)) ||
165
+ (/representative|representatif/.test(kind.id) && sample.pages.length >= 2);
166
+ if (!covered) missing.push(kind);
167
+ }
168
+ return { missing };
169
+ }
170
+
171
+ /** Localized label for a required kind (default-locale-first fallback). */
172
+ export function kindLabel(kind: SampleRequiredKind, locale = "fr"): string {
173
+ return kind.label[locale] ?? Object.values(kind.label)[0] ?? kind.id;
174
+ }
175
+
176
+ export type { SamplePage };