@surea11y/core 1.2.0 → 1.3.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 (157) hide show
  1. package/CHANGELOG.md +37 -7
  2. package/LICENSE +373 -21
  3. package/README.md +67 -1
  4. package/bin/core.js +144 -19
  5. package/docs/API_STABILITY.md +1 -1
  6. package/docs/BINDING_AUTHORS_GUIDE.md +9 -9
  7. package/docs/CI_INTEGRATIONS.md +103 -0
  8. package/docs/CLI.md +54 -1
  9. package/docs/ENGINE_OPTIONS.md +2 -0
  10. package/docs/INTEGRATION.md +18 -0
  11. package/docs/OUTPUT_SCHEMA.md +1 -1
  12. package/docs/RULE_CATALOG.md +1 -1
  13. package/docs/SARIF.md +59 -0
  14. package/package.json +15 -4
  15. package/src/baseline.js +0 -0
  16. package/src/catalogs/composites.wcag.js +414 -450
  17. package/src/checks/automatic/area-alt-present.js +59 -25
  18. package/src/checks/automatic/aria-allowed-attr.js +193 -33
  19. package/src/checks/automatic/aria-allowed-role.js +21 -7
  20. package/src/checks/automatic/aria-braille-equivalent.js +32 -10
  21. package/src/checks/automatic/aria-conditional-attr.js +24 -7
  22. package/src/checks/automatic/aria-deprecated-role.js +22 -8
  23. package/src/checks/automatic/aria-hidden-body.js +42 -19
  24. package/src/checks/automatic/aria-hidden-focus.js +408 -53
  25. package/src/checks/automatic/aria-prohibited-attr.js +296 -22
  26. package/src/checks/automatic/aria-prohibited-children.js +55 -16
  27. package/src/checks/automatic/aria-required-attr.js +23 -8
  28. package/src/checks/automatic/aria-required-children.js +37 -14
  29. package/src/checks/automatic/aria-required-parent.js +48 -14
  30. package/src/checks/automatic/aria-role-name-present.js +47 -21
  31. package/src/checks/automatic/aria-roles-valid.js +22 -12
  32. package/src/checks/automatic/aria-valid-attr-value.js +28 -7
  33. package/src/checks/automatic/aria-valid-attr.js +17 -5
  34. package/src/checks/automatic/autocomplete-valid.js +74 -16
  35. package/src/checks/automatic/avoid-inline-spacing.js +20 -6
  36. package/src/checks/automatic/binary-control-name-present.js +60 -50
  37. package/src/checks/automatic/button-name-present.js +48 -18
  38. package/src/checks/automatic/bypass-blocks-present.js +42 -25
  39. package/src/checks/automatic/canvas-text-alternative-present.js +57 -26
  40. package/src/checks/automatic/combobox-name-present.js +38 -45
  41. package/src/checks/automatic/contrast-computable.js +361 -341
  42. package/src/checks/automatic/contrast-enhanced.js +487 -466
  43. package/src/checks/automatic/contrast-minimum.js +486 -465
  44. package/src/checks/automatic/css-orientation-lock.js +30 -8
  45. package/src/checks/automatic/definition-list-children-valid.js +40 -19
  46. package/src/checks/automatic/deprecated-elements-not-used.js +21 -7
  47. package/src/checks/automatic/dialog-name-present.js +37 -75
  48. package/src/checks/automatic/dlitem-parent-valid.js +23 -8
  49. package/src/checks/automatic/duplicate-id-aria.js +24 -6
  50. package/src/checks/automatic/embed-text-alternative-present.js +86 -35
  51. package/src/checks/automatic/form-control-programmatic-label-present.js +79 -196
  52. package/src/checks/automatic/form-control-single-label.js +47 -10
  53. package/src/checks/automatic/html-xml-lang-mismatch.js +34 -18
  54. package/src/checks/automatic/iframe-focusable-content.js +26 -11
  55. package/src/checks/automatic/iframe-name-present.js +31 -9
  56. package/src/checks/automatic/iframe-title-unique.js +29 -8
  57. package/src/checks/automatic/img-alt-present.js +47 -43
  58. package/src/checks/automatic/input-image-alt-present.js +143 -112
  59. package/src/checks/automatic/label-in-name.js +50 -22
  60. package/src/checks/automatic/language-page-present.js +109 -109
  61. package/src/checks/automatic/link-in-text-block.js +59 -19
  62. package/src/checks/automatic/link-name-present.js +45 -14
  63. package/src/checks/automatic/list-children-valid.js +26 -9
  64. package/src/checks/automatic/listbox-name-present.js +39 -19
  65. package/src/checks/automatic/listitem-parent-valid.js +18 -6
  66. package/src/checks/automatic/menuitem-name-present.js +39 -61
  67. package/src/checks/automatic/meta-refresh-no-exceptions.js +28 -7
  68. package/src/checks/automatic/meta-refresh-timing-absent.js +20 -6
  69. package/src/checks/automatic/meta-viewport-zoom-enabled.js +24 -7
  70. package/src/checks/automatic/meter-name-present.js +36 -33
  71. package/src/checks/automatic/nested-interactive-controls-absent.js +31 -10
  72. package/src/checks/automatic/object-text-alternative-present.js +91 -39
  73. package/src/checks/automatic/option-name-present.js +38 -21
  74. package/src/checks/automatic/page-title-present.js +17 -6
  75. package/src/checks/automatic/progressbar-name-present.js +41 -34
  76. package/src/checks/automatic/role-img-alt-present.js +209 -157
  77. package/src/checks/automatic/searchbox-name-present.js +39 -19
  78. package/src/checks/automatic/server-side-image-map-absent.js +23 -8
  79. package/src/checks/automatic/slider-name-present.js +40 -47
  80. package/src/checks/automatic/spinbutton-name-present.js +39 -19
  81. package/src/checks/automatic/summary-name-present.js +37 -17
  82. package/src/checks/automatic/svg-image-text-alternative-present.js +114 -47
  83. package/src/checks/automatic/svg-text-alternative-present.js +246 -226
  84. package/src/checks/automatic/tab-name-present.js +37 -60
  85. package/src/checks/automatic/table-headers-attr-valid.js +24 -8
  86. package/src/checks/automatic/table-th-has-data-cells.js +22 -8
  87. package/src/checks/automatic/target-size-minimum.js +118 -48
  88. package/src/checks/automatic/td-has-header.js +29 -11
  89. package/src/checks/automatic/textbox-name-present.js +39 -19
  90. package/src/checks/automatic/tooltip-name-present.js +37 -18
  91. package/src/checks/automatic/treeitem-name-present.js +38 -21
  92. package/src/checks/automatic/valid-lang.js +20 -6
  93. package/src/checks/automatic/video-poster-text-alternative-present.js +79 -36
  94. package/src/checks/manual/accesskeys-manual.js +14 -5
  95. package/src/checks/manual/area-alt-decorative-manual.js +192 -193
  96. package/src/checks/manual/area-alt-quality-manual.js +182 -141
  97. package/src/checks/manual/aria-checked-state-mismatch-manual.js +34 -11
  98. package/src/checks/manual/aria-text-manual.js +14 -6
  99. package/src/checks/manual/canvas-text-alternative-quality-manual.js +149 -114
  100. package/src/checks/manual/css-hidden-focus.js +196 -165
  101. package/src/checks/manual/embed-text-alternative-quality-manual.js +171 -160
  102. package/src/checks/manual/empty-heading-manual.js +24 -7
  103. package/src/checks/manual/empty-table-header-manual.js +17 -6
  104. package/src/checks/manual/focus-order-semantics-manual.js +45 -10
  105. package/src/checks/manual/form-control-programmatic-label-quality-manual.js +207 -246
  106. package/src/checks/manual/heading-order-manual.js +22 -7
  107. package/src/checks/manual/identical-links-same-purpose-manual.js +34 -12
  108. package/src/checks/manual/image-redundant-alt-manual.js +17 -7
  109. package/src/checks/manual/img-alt-decorative-manual.js +131 -96
  110. package/src/checks/manual/img-alt-quality-manual.js +176 -127
  111. package/src/checks/manual/input-image-alt-decorative-manual.js +125 -92
  112. package/src/checks/manual/input-image-alt-quality-manual.js +125 -92
  113. package/src/checks/manual/label-title-only-manual.js +14 -5
  114. package/src/checks/manual/landmark-banner-is-top-level-manual.js +66 -16
  115. package/src/checks/manual/landmark-contentinfo-is-top-level-manual.js +53 -16
  116. package/src/checks/manual/landmark-main-is-top-level-manual.js +35 -10
  117. package/src/checks/manual/landmark-no-duplicate-banner-manual.js +29 -10
  118. package/src/checks/manual/landmark-no-duplicate-contentinfo-manual.js +29 -10
  119. package/src/checks/manual/landmark-no-duplicate-main-manual.js +17 -8
  120. package/src/checks/manual/landmark-one-main-manual.js +26 -20
  121. package/src/checks/manual/landmark-unique-manual.js +41 -15
  122. package/src/checks/manual/link-name-quality-manual.js +43 -12
  123. package/src/checks/manual/media-transcript-present-manual.js +35 -22
  124. package/src/checks/manual/meta-viewport-large-manual.js +16 -5
  125. package/src/checks/manual/mouse-only-event-handlers-manual.js +38 -11
  126. package/src/checks/manual/no-autoplay-audio-manual.js +20 -6
  127. package/src/checks/manual/object-text-alternative-quality-manual.js +175 -154
  128. package/src/checks/manual/p-as-heading-manual.js +22 -7
  129. package/src/checks/manual/page-has-heading-one-manual.js +31 -22
  130. package/src/checks/manual/page-title-patterns-manual.js +78 -50
  131. package/src/checks/manual/presentation-role-conflict-manual.js +59 -19
  132. package/src/checks/manual/region-manual.js +241 -48
  133. package/src/checks/manual/scope-attr-valid-manual.js +10 -3
  134. package/src/checks/manual/scrollable-region-focusable-manual.js +37 -11
  135. package/src/checks/manual/skip-link-manual.js +35 -12
  136. package/src/checks/manual/svg-text-alternative-quality-manual.js +206 -165
  137. package/src/checks/manual/tabindex-manual.js +10 -3
  138. package/src/checks/manual/table-duplicate-name-manual.js +17 -7
  139. package/src/checks/manual/table-fake-caption-manual.js +24 -7
  140. package/src/checks/manual/video-caption-manual.js +15 -4
  141. package/src/checks/manual-review.js +56 -12
  142. package/src/core/aria-helpers.js +1127 -886
  143. package/src/core/contrast-helpers.js +1217 -1062
  144. package/src/core/dom-helpers.js +4175 -3917
  145. package/src/core/dom-runner.js +720 -604
  146. package/src/core/frame-messaging.js +189 -138
  147. package/src/core/frame-scan.js +94 -82
  148. package/src/core/rollup-composites.js +94 -102
  149. package/src/core/rule-meta.js +58 -41
  150. package/src/core.js +36552 -29111
  151. package/src/i18n/en.js +1194 -889
  152. package/src/i18n/fr.js +1136 -795
  153. package/src/policy/contracts.js +13 -13
  154. package/src/policy/resolvePolicy.js +48 -44
  155. package/src/report.js +63 -43
  156. package/src/sarif.js +175 -0
  157. package/surea11y.browser.js +36042 -0
@@ -6,8 +6,10 @@
6
6
  * @summary Page content should be contained within a landmark region
7
7
  * @standard Best Practices (a widely-used reference engine's classification; no formal WCAG Success Criterion — see ROADMAP.md Tier 1b)
8
8
  * @applicability
9
- * Applies to direct children of <body> that carry visible text content
10
- * and are not themselves a landmark.
9
+ * Applies to any element under <body> that directly carries visible text
10
+ * (or other own content — see @implementation-notes) and is not itself a
11
+ * landmark, live region, dialog, button, <svg>, <iframe>/<frame>, or a
12
+ * resolvable skip-link.
11
13
  * @expectation
12
14
  * Every top-level piece of page content lives inside a landmark region
13
15
  * (main, navigation, banner, contentinfo, complementary, region, form,
@@ -18,19 +20,62 @@
18
20
  * `type: 'manual'` rule; see landmark-banner-is-top-level's
19
21
  * header comment for the shared rationale/precedent and the landmark-
20
22
  * detection model.
21
- * - Deliberately scoped to DIRECT children of <body> only (not a full
22
- * recursive scan of every text node on the page) — a widely-used
23
- * reference engine's own region rule is known to be noisy in practice when applied
24
- * unconditionally; checking only the top level keeps false positives
25
- * low while still catching the common case (a page section authored
26
- * entirely outside any landmark).
23
+ * - Recursive tree walk, not a direct-<body>-children-only scan (that was
24
+ * this check's original scope, replaced 2026-08-01). The direct-children
25
+ * scope was originally chosen to keep this check quiet, but it turned out
26
+ * to be nearly inert on the single most common real-world page shape: a
27
+ * modern framework's single root mount div
28
+ * (`<body><div id="root">...everything...</div></body>`, confirmed
29
+ * present as the ONLY direct <body> child on 37 of ~90 pages in a
30
+ * real-world corpus). On that shape, the old scan had at most one
31
+ * candidate for the entire page and either missed every real gap inside
32
+ * it or collapsed the whole page into one undifferentiated report.
33
+ * - Algorithm, using this engine's own (already more spec-correct in
34
+ * places — see hasLandmarkScopingAncestor's header comment) helpers:
35
+ * 1. Depth-first walk from <body>'s children.
36
+ * 2. At each node: if it's ineligible for the accessibility tree
37
+ * (helpers.isAccTreeEligible), OR is itself a "stopper" (landmark,
38
+ * live region, dialog, button, <svg>, <iframe>/<frame>, or a
39
+ * resolvable skip-link), mark it and every ancestor up to <body> as
40
+ * "has a stopper" and don't recurse further into it (an <iframe>/
41
+ * <frame> is additionally reported as its own occurrence — its
42
+ * content is opaque to this engine, so from the outer page's
43
+ * perspective it IS unplaced content).
44
+ * 3. Otherwise, if the node has OWN content (a direct child text node,
45
+ * being an inherently visual element, or an aria-label) — checked
46
+ * non-recursively, so a plain wrapper <div> with only nested
47
+ * children never short-circuits the walk into its descendants —
48
+ * collect it as a candidate and stop recursing into it.
49
+ * 4. Otherwise recurse into its element children.
50
+ * Each collected candidate is then walked back UP through parents while
51
+ * the parent has no "stopper" marker and isn't <body> itself, collapsing
52
+ * contiguous unplaced content into one occurrence per real gap instead
53
+ * of reporting every individual text-bearing leaf — this is what keeps
54
+ * the walk from being noisy on ordinary pages that mix landmarked and
55
+ * stray content, not the old direct-children-only restriction.
56
+ * - "Stopper" exemptions (button, dialog, <svg>, resolvable skip-links)
57
+ * are a deliberate scope choice, not an oversight: these
58
+ * are extremely common real-world patterns (floating action buttons,
59
+ * modal dialogs, decorative/icon SVGs, "skip to content" links) that
60
+ * aren't the kind of "content organization" gap this rule exists to
61
+ * catch, and flagging them would reintroduce the false-positive noise
62
+ * the original narrow scope was trying to avoid.
63
+ * - The "own content" check for aria-label deliberately does NOT resolve
64
+ * aria-labelledby — a known, narrow scope gap (an element named only via aria-labelledby, with no
65
+ * own text/aria-label, and no other content anywhere in its subtree,
66
+ * could be silently skipped) accepted to avoid a full accessible-name
67
+ * computation (recursive itself) inside an already-recursive structural
68
+ * walk.
69
+ * - Bounded by a node-visit budget (see MAX_VISITED_NODES) as a defensive
70
+ * guard against pathological pages, matching the existing pattern used
71
+ * by getContentNameInfo's maxContentNodes in src/core/dom-helpers.js.
27
72
  */
28
73
 
29
74
  const id = 'region';
30
75
 
31
76
  const meta = {
32
77
  title: 'Page content should be inside a landmark region',
33
- description: 'Checks that direct children of <body> with visible text content are contained within a landmark region.',
78
+ description: 'Checks that content under <body> is contained within a landmark region.',
34
79
  i18n: {
35
80
  titleKey: 'region_title',
36
81
  descriptionKey: 'region_description'
@@ -63,7 +108,13 @@ function runInPage(ctx) {
63
108
  }
64
109
 
65
110
  function normalizeWs(s) {
66
- return String(s || '').replace(/\s+/g, ' ').trim();
111
+ return String(s || '')
112
+ .replace(/\s+/g, ' ')
113
+ .trim();
114
+ }
115
+
116
+ function lower(s) {
117
+ return String(s || '').toLowerCase();
67
118
  }
68
119
 
69
120
  // Delegates to the shared helpers.getLandmarkNameInfo (aria-label -> aria-labelledby, via the
@@ -84,7 +135,7 @@ function runInPage(ctx) {
84
135
  function getExplicitRoleToken(el) {
85
136
  const raw = normalizeWs(el.getAttribute && el.getAttribute('role'));
86
137
  if (!raw) return '';
87
- return raw.split(/\s+/)[0].toLowerCase();
138
+ return lower(raw.split(/\s+/)[0]);
88
139
  }
89
140
 
90
141
  // Delegates to the shared helpers.hasLandmarkScopingAncestor for the
@@ -104,7 +155,7 @@ function runInPage(ctx) {
104
155
  }
105
156
 
106
157
  function getImplicitLandmarkRole(el) {
107
- const tag = el.tagName ? el.tagName.toLowerCase() : '';
158
+ const tag = el.tagName ? lower(el.tagName) : '';
108
159
  if (tag === 'header') return hasSectioningAncestor(el, true) ? '' : 'banner';
109
160
  if (tag === 'footer') return hasSectioningAncestor(el, true) ? '' : 'contentinfo';
110
161
  if (tag === 'main') return 'main';
@@ -123,7 +174,16 @@ function runInPage(ctx) {
123
174
  return '';
124
175
  }
125
176
 
126
- const LANDMARK_ROLES = new Set(['banner', 'contentinfo', 'main', 'navigation', 'complementary', 'region', 'form', 'search']);
177
+ const LANDMARK_ROLES = new Set([
178
+ 'banner',
179
+ 'contentinfo',
180
+ 'main',
181
+ 'navigation',
182
+ 'complementary',
183
+ 'region',
184
+ 'form',
185
+ 'search'
186
+ ]);
127
187
 
128
188
  function isLandmark(el) {
129
189
  if (!el || !el.getAttribute) return false;
@@ -134,48 +194,179 @@ function runInPage(ctx) {
134
194
 
135
195
  const SKIP_TAGS = new Set(['script', 'style', 'template', 'noscript', 'link', 'meta', 'title']);
136
196
 
137
- function isIneligible(el) {
138
- if (el.getAttribute && el.getAttribute('aria-hidden') === 'true') return true;
139
- if (el.hasAttribute && el.hasAttribute('hidden')) return true;
197
+ // Roles/attributes that make an element its own self-contained
198
+ // announced area — not literally a WAI-ARIA landmark, but not "content
199
+ // that needs a landmark" either. Same live-region role list a widely-used
200
+ // reference engine's own region rule stops recursion at.
201
+ const LIVE_REGION_ROLES = new Set(['alert', 'status', 'log', 'marquee', 'timer']);
202
+
203
+ function isAriaLive(el) {
204
+ const v = lower(normalizeWs(el.getAttribute && el.getAttribute('aria-live')));
205
+ return v === 'polite' || v === 'assertive';
206
+ }
207
+
208
+ function isDialogLike(el) {
209
+ const tag = el.tagName ? lower(el.tagName) : '';
210
+ if (tag === 'dialog') return true;
211
+ const role = getExplicitRoleToken(el);
212
+ return role === 'dialog' || role === 'alertdialog';
213
+ }
214
+
215
+ function isButtonLike(el) {
216
+ const role = getExplicitRoleToken(el);
217
+ if (role) return role === 'button';
218
+ const tag = el.tagName ? lower(el.tagName) : '';
219
+ if (tag === 'button' || tag === 'summary') return true;
220
+ if (tag === 'input') {
221
+ const type = lower(normalizeWs(el.getAttribute && el.getAttribute('type')));
222
+ return type === 'button' || type === 'submit' || type === 'reset' || type === 'image';
223
+ }
140
224
  return false;
141
225
  }
142
226
 
143
- function getConservativeSubtreeText(container) {
144
- const SHOW_TEXT = 4;
227
+ // A "skip to content" link is deliberately placed outside the main
228
+ // content flow at the very top of the page — exempting it (when its
229
+ // fragment actually resolves to a real target, not a dead "#"
230
+ // placeholder) avoids flagging a helpful, common accessibility pattern
231
+ // as the very thing this rule is meant to catch.
232
+ function isResolvableSkipLink(el) {
233
+ const tag = el.tagName ? lower(el.tagName) : '';
234
+ if (tag !== 'a') return false;
235
+ const href = el.getAttribute && el.getAttribute('href');
236
+ if (!href || href.charAt(0) !== '#' || href.length < 2) return false;
145
237
  try {
146
- const walker = document.createTreeWalker(container, SHOW_TEXT, null);
147
- const parts = [];
148
- let n = walker.nextNode();
149
- while (n) {
150
- const raw = normalizeWs(n.nodeValue || '');
151
- if (raw) parts.push(raw);
152
- n = walker.nextNode();
153
- }
154
- return normalizeWs(parts.join(' '));
238
+ return !!(document.getElementById && document.getElementById(href.slice(1)));
155
239
  } catch {
156
- return normalizeWs(container.textContent);
240
+ return false;
157
241
  }
158
242
  }
159
243
 
160
- const occurrences = [];
161
- let applicableCount = 0;
244
+ function isStopper(el) {
245
+ if (isLandmark(el)) return true;
246
+ if (isAriaLive(el)) return true;
247
+ const role = getExplicitRoleToken(el);
248
+ if (role && LIVE_REGION_ROLES.has(role)) return true;
249
+ if (isDialogLike(el)) return true;
250
+ if (isButtonLike(el)) return true;
251
+ const tag = el.tagName ? lower(el.tagName) : '';
252
+ if (tag === 'svg' || tag === 'iframe' || tag === 'frame') return true;
253
+ if (isResolvableSkipLink(el)) return true;
254
+ return false;
255
+ }
256
+
257
+ const VISUAL_CONTENT_TAGS = new Set(['img', 'video', 'audio', 'canvas', 'object', 'embed']);
162
258
 
163
- for (const el of body.children || []) {
164
- if (!el || !el.tagName) continue;
165
- const tag = el.tagName.toLowerCase();
166
- if (SKIP_TAGS.has(tag)) continue;
167
- if (isIneligible(el)) continue;
168
- if (isLandmark(el)) continue;
259
+ // Non-recursive "does THIS element, on its own, carry content" check —
260
+ // deliberately mirrors only the direct-content half of getContentNameInfo/
261
+ // a widely-used reference engine's own has-content check, not a full
262
+ // name-from-content recursion: the whole point is to keep recursing
263
+ // through plain wrapper elements (a framework's root mount <div> included)
264
+ // until reaching the actual content-bearing node, rather than a coarse
265
+ // ancestor swallowing everything beneath it into one report.
266
+ function hasOwnContent(el) {
267
+ const kids = el.childNodes || [];
268
+ for (let i = 0; i < kids.length; i++) {
269
+ const k = kids[i];
270
+ if (k.nodeType === 3 && normalizeWs(k.nodeValue)) return true;
271
+ }
272
+ const tag = el.tagName ? lower(el.tagName) : '';
273
+ if (VISUAL_CONTENT_TAGS.has(tag)) return true;
274
+ if (
275
+ tag === 'input' &&
276
+ lower(normalizeWs(el.getAttribute && el.getAttribute('type'))) !== 'hidden'
277
+ )
278
+ return true;
279
+ if (normalizeWs(el.getAttribute && el.getAttribute('aria-label'))) return true;
280
+ return false;
281
+ }
169
282
 
170
- const text = getConservativeSubtreeText(el);
171
- if (!text) continue;
283
+ // Defensive guard against pathological pages (mirrors the maxContentNodes
284
+ // pattern in src/core/dom-helpers.js's getContentNameInfo) -- a full-body
285
+ // structural walk visits more nodes than a single element's name
286
+ // computation, hence the larger budget.
287
+ const MAX_VISITED_NODES = 20000;
288
+ let visited = 0;
289
+ let truncated = false;
172
290
 
173
- applicableCount += 1;
291
+ const leaves = [];
292
+ const stopperFlagged = new WeakSet();
174
293
 
294
+ function markFlaggedUpToBody(el) {
295
+ let cur = el;
296
+ while (cur) {
297
+ if (stopperFlagged.has(cur)) break; // everything above is already marked
298
+ stopperFlagged.add(cur);
299
+ if (cur === body) break;
300
+ cur = cur.parentElement;
301
+ }
302
+ }
303
+
304
+ function walk(el) {
305
+ if (truncated || !el || el.nodeType !== 1) return;
306
+ visited += 1;
307
+ if (visited > MAX_VISITED_NODES) {
308
+ truncated = true;
309
+ return;
310
+ }
311
+
312
+ const tag = el.tagName ? lower(el.tagName) : '';
313
+ if (SKIP_TAGS.has(tag)) return;
314
+
315
+ const eligRes = helpers.isAccTreeEligible ? helpers.isAccTreeEligible(el) : { eligible: true };
316
+ if (!(eligRes && eligRes.eligible)) {
317
+ markFlaggedUpToBody(el);
318
+ return;
319
+ }
320
+
321
+ if (isStopper(el)) {
322
+ markFlaggedUpToBody(el);
323
+ if (tag === 'iframe' || tag === 'frame') leaves.push(el);
324
+ return;
325
+ }
326
+
327
+ if (hasOwnContent(el)) {
328
+ leaves.push(el);
329
+ return;
330
+ }
331
+
332
+ const kids = el.children || [];
333
+ for (let i = 0; i < kids.length; i++) {
334
+ walk(kids[i]);
335
+ if (truncated) return;
336
+ }
337
+ }
338
+
339
+ for (const child of body.children || []) walk(child);
340
+
341
+ // Collapse each candidate leaf upward through parents that have no OTHER
342
+ // stopper anywhere in their subtree, so contiguous unplaced content
343
+ // merges into ONE occurrence per real gap instead of one per text node --
344
+ // this collapsing, not the old direct-children-only scope, is what keeps
345
+ // ordinary pages (landmarked content mixed with a little stray content)
346
+ // from producing noisy, one-per-leaf reports.
347
+ const collapsed = [];
348
+ const seen = new Set();
349
+ for (const leaf of leaves) {
350
+ let cur = leaf;
351
+ while (
352
+ cur.parentElement &&
353
+ cur.parentElement !== body &&
354
+ !stopperFlagged.has(cur.parentElement)
355
+ ) {
356
+ cur = cur.parentElement;
357
+ }
358
+ if (!seen.has(cur)) {
359
+ seen.add(cur);
360
+ collapsed.push(cur);
361
+ }
362
+ }
363
+
364
+ const occurrences = collapsed.map((el) => {
365
+ const tag = el.tagName ? lower(el.tagName) : '';
175
366
  const stableSelector = helpers.buildSelector ? helpers.buildSelector(el) : 'html';
176
- const html = helpers.getOuterHtmlSnippet ? helpers.getOuterHtmlSnippet(el) : (el.outerHTML || '');
367
+ const html = helpers.getOuterHtmlSnippet ? helpers.getOuterHtmlSnippet(el) : el.outerHTML || '';
177
368
 
178
- occurrences.push({
369
+ return {
179
370
  selector: stableSelector,
180
371
  html,
181
372
  summary: 'This content is not contained within a landmark region.',
@@ -188,16 +379,18 @@ function runInPage(ctx) {
188
379
  data: {
189
380
  details: { reasonCode: 'CONTENT_OUTSIDE_LANDMARK', element: tag }
190
381
  }
191
- });
192
- }
382
+ };
383
+ });
193
384
 
194
- if (applicableCount === 0) {
385
+ if (occurrences.length === 0) {
195
386
  return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
196
387
  }
197
- if (occurrences.length) {
198
- return { ruleId: rule.ruleId, outcome: 'cantTell', severity: rule.defaultSeverity || 'minor', occurrences };
199
- }
200
- return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
388
+ return {
389
+ ruleId: rule.ruleId,
390
+ outcome: 'cantTell',
391
+ severity: rule.defaultSeverity || 'minor',
392
+ occurrences
393
+ };
201
394
  }
202
395
 
203
396
  module.exports = { id, meta, runInPage, applicability };
@@ -43,7 +43,9 @@ function runInPage(ctx) {
43
43
 
44
44
  const VALID_SCOPES = new Set(['row', 'col', 'rowgroup', 'colgroup']);
45
45
 
46
- const nodes = helpers.queryAllSmart ? helpers.queryAllSmart('[scope]') : helpers.queryAll('[scope]');
46
+ const nodes = helpers.queryAllSmart
47
+ ? helpers.queryAllSmart('[scope]')
48
+ : helpers.queryAll('[scope]');
47
49
 
48
50
  const occurrences = [];
49
51
  let applicableCount = 0;
@@ -58,7 +60,7 @@ function runInPage(ctx) {
58
60
  if (VALID_SCOPES.has(raw.toLowerCase())) continue;
59
61
 
60
62
  const stableSelector = helpers.buildSelector ? helpers.buildSelector(el) : 'html';
61
- const html = helpers.getOuterHtmlSnippet ? helpers.getOuterHtmlSnippet(el) : (el.outerHTML || '');
63
+ const html = helpers.getOuterHtmlSnippet ? helpers.getOuterHtmlSnippet(el) : el.outerHTML || '';
62
64
 
63
65
  occurrences.push({
64
66
  selector: stableSelector,
@@ -80,7 +82,12 @@ function runInPage(ctx) {
80
82
  return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
81
83
  }
82
84
  if (occurrences.length) {
83
- return { ruleId: rule.ruleId, outcome: 'cantTell', severity: rule.defaultSeverity || 'minor', occurrences };
85
+ return {
86
+ ruleId: rule.ruleId,
87
+ outcome: 'cantTell',
88
+ severity: rule.defaultSeverity || 'minor',
89
+ occurrences
90
+ };
84
91
  }
85
92
  return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
86
93
  }
@@ -48,8 +48,20 @@ const meta = {
48
48
  tags: ['wcag2a', 'wcag2aaa', 'wcag211', 'wcag213', 'structure', 'atomic', 'manual'],
49
49
  wcagSc: ['2.1.1', '2.1.3'],
50
50
  normativeMappings: [
51
- { standard: 'WCAG', version: '2.2', requirement: '2.1.1', title: 'Keyboard', conformanceLevel: 'A' },
52
- { standard: 'WCAG', version: '2.2', requirement: '2.1.3', title: 'Keyboard (No Exception)', conformanceLevel: 'AAA' }
51
+ {
52
+ standard: 'WCAG',
53
+ version: '2.2',
54
+ requirement: '2.1.1',
55
+ title: 'Keyboard',
56
+ conformanceLevel: 'A'
57
+ },
58
+ {
59
+ standard: 'WCAG',
60
+ version: '2.2',
61
+ requirement: '2.1.3',
62
+ title: 'Keyboard (No Exception)',
63
+ conformanceLevel: 'AAA'
64
+ }
53
65
  ],
54
66
  defaultSeverity: 'moderate',
55
67
  category: 'operable',
@@ -64,7 +76,7 @@ const meta = {
64
76
  };
65
77
 
66
78
  function runInPage(ctx) {
67
- const { document, helpers, rule } = ctx;
79
+ const { helpers, rule } = ctx;
68
80
 
69
81
  function safeComputedStyle(el) {
70
82
  try {
@@ -73,7 +85,8 @@ function runInPage(ctx) {
73
85
  const cs = helpers.computedStyle(el);
74
86
  if (cs) return cs;
75
87
  }
76
- const view = (el.ownerDocument && el.ownerDocument.defaultView) ? el.ownerDocument.defaultView : null;
88
+ const view =
89
+ el.ownerDocument && el.ownerDocument.defaultView ? el.ownerDocument.defaultView : null;
77
90
  if (view && typeof view.getComputedStyle === 'function') return view.getComputedStyle(el);
78
91
  } catch {}
79
92
  return null;
@@ -81,8 +94,12 @@ function runInPage(ctx) {
81
94
 
82
95
  function isScrollableOverflow(cs) {
83
96
  if (!cs) return false;
84
- const x = String(cs.overflowX || '').trim().toLowerCase();
85
- const y = String(cs.overflowY || '').trim().toLowerCase();
97
+ const x = String(cs.overflowX || '')
98
+ .trim()
99
+ .toLowerCase();
100
+ const y = String(cs.overflowY || '')
101
+ .trim()
102
+ .toLowerCase();
86
103
  return x === 'auto' || x === 'scroll' || y === 'auto' || y === 'scroll';
87
104
  }
88
105
 
@@ -108,8 +125,11 @@ function runInPage(ctx) {
108
125
  }
109
126
  }
110
127
 
111
- const CANDIDATE_SELECTOR = 'div, section, article, aside, main, nav, pre, table, blockquote, ul, ol, textarea';
112
- const nodes = helpers.queryAllSmart ? helpers.queryAllSmart(CANDIDATE_SELECTOR) : helpers.queryAll(CANDIDATE_SELECTOR);
128
+ const CANDIDATE_SELECTOR =
129
+ 'div, section, article, aside, main, nav, pre, table, blockquote, ul, ol, textarea';
130
+ const nodes = helpers.queryAllSmart
131
+ ? helpers.queryAllSmart(CANDIDATE_SELECTOR)
132
+ : helpers.queryAll(CANDIDATE_SELECTOR);
113
133
 
114
134
  const occurrences = [];
115
135
  let applicableCount = 0;
@@ -127,12 +147,13 @@ function runInPage(ctx) {
127
147
 
128
148
  const tag = (el.tagName || '').toLowerCase();
129
149
  const stableSelector = helpers.buildSelector ? helpers.buildSelector(el) : 'html';
130
- const html = helpers.getOuterHtmlSnippet ? helpers.getOuterHtmlSnippet(el) : (el.outerHTML || '');
150
+ const html = helpers.getOuterHtmlSnippet ? helpers.getOuterHtmlSnippet(el) : el.outerHTML || '';
131
151
 
132
152
  const baseOccurrence = {
133
153
  selector: stableSelector,
134
154
  html,
135
- summary: 'This element declares overflow:auto/scroll, has no focusable descendant, and is not itself keyboard-focusable.',
155
+ summary:
156
+ 'This element declares overflow:auto/scroll, has no focusable descendant, and is not itself keyboard-focusable.',
136
157
  hint: 'If this region’s content actually overflows, add tabindex="0" (and a suitable label) so keyboard users can focus it and scroll with the arrow keys.',
137
158
  i18n: {
138
159
  summaryKey: 'scrollableRegionFocusable_summary_cantTell',
@@ -156,7 +177,12 @@ function runInPage(ctx) {
156
177
  }
157
178
 
158
179
  if (occurrences.length) {
159
- return { ruleId: rule.ruleId, outcome: 'cantTell', severity: rule.defaultSeverity || 'moderate', occurrences };
180
+ return {
181
+ ruleId: rule.ruleId,
182
+ outcome: 'cantTell',
183
+ severity: rule.defaultSeverity || 'moderate',
184
+ occurrences
185
+ };
160
186
  }
161
187
 
162
188
  // Manual rules may only emit cantTell/notApplicable (never pass/fail):
@@ -37,7 +37,8 @@ const id = 'skip-link';
37
37
 
38
38
  const meta = {
39
39
  title: 'Skip link must have a resolvable, usable target',
40
- description: 'Checks that a "skip to ..." link\'s href fragment resolves to a real, currently usable element in the document.',
40
+ description:
41
+ 'Checks that a "skip to ..." link\'s href fragment resolves to a real, currently usable element in the document.',
41
42
  i18n: {
42
43
  titleKey: 'skipLink_title',
43
44
  descriptionKey: 'skipLink_description'
@@ -57,7 +58,9 @@ function runInPage(ctx) {
57
58
  const { document, helpers, rule } = ctx;
58
59
 
59
60
  function normalizeWs(s) {
60
- return String(s || '').replace(/\s+/g, ' ').trim();
61
+ return String(s || '')
62
+ .replace(/\s+/g, ' ')
63
+ .trim();
61
64
  }
62
65
 
63
66
  function getAccessibleNameText(el) {
@@ -105,7 +108,9 @@ function runInPage(ctx) {
105
108
 
106
109
  const geometrySupported = hasReliableGeometrySupport();
107
110
 
108
- const nodes = helpers.queryAllSmart ? helpers.queryAllSmart('a[href]') : helpers.queryAll('a[href]');
111
+ const nodes = helpers.queryAllSmart
112
+ ? helpers.queryAllSmart('a[href]')
113
+ : helpers.queryAll('a[href]');
109
114
 
110
115
  const occurrences = [];
111
116
  let applicableCount = 0;
@@ -147,18 +152,29 @@ function runInPage(ctx) {
147
152
  const accEligibility = toEligibility(
148
153
  helpers.getEligibilityInfo
149
154
  ? helpers.getEligibilityInfo(target, ctx, { targetSet: 'acc' })
150
- : (helpers.isAccTreeEligible ? helpers.isAccTreeEligible(target, ctx) : { eligible: true, reasons: [] })
155
+ : helpers.isAccTreeEligible
156
+ ? helpers.isAccTreeEligible(target, ctx)
157
+ : { eligible: true, reasons: [] }
151
158
  );
152
159
 
153
160
  let geometryEligibility = null;
154
161
  let geometryReasonCode = null;
155
162
  if (geometrySupported && helpers.isDomVisibleEligible) {
156
163
  geometryEligibility = toEligibility(
157
- helpers.isDomVisibleEligible(target, ctx, { visibilityMode: 'styleAndGeometry', ignoreOpacity: true })
164
+ helpers.isDomVisibleEligible(target, ctx, {
165
+ visibilityMode: 'styleAndGeometry',
166
+ ignoreOpacity: true
167
+ })
158
168
  );
159
- if (!geometryEligibility.eligible && geometryEligibility.reasons.includes('noClientRects')) {
169
+ if (
170
+ !geometryEligibility.eligible &&
171
+ geometryEligibility.reasons.includes('noClientRects')
172
+ ) {
160
173
  geometryReasonCode = 'NO_CLIENT_RECTS';
161
- } else if (!geometryEligibility.eligible && geometryEligibility.reasons.includes('zeroArea')) {
174
+ } else if (
175
+ !geometryEligibility.eligible &&
176
+ geometryEligibility.reasons.includes('zeroArea')
177
+ ) {
162
178
  geometryReasonCode = 'ZERO_AREA_TARGET';
163
179
  }
164
180
  }
@@ -168,7 +184,9 @@ function runInPage(ctx) {
168
184
  if (!unusableByAcc && !unusableByGeometry) continue;
169
185
 
170
186
  const stableSelector = helpers.buildSelector ? helpers.buildSelector(el) : 'html';
171
- const html = helpers.getOuterHtmlSnippet ? helpers.getOuterHtmlSnippet(el) : (el.outerHTML || '');
187
+ const html = helpers.getOuterHtmlSnippet
188
+ ? helpers.getOuterHtmlSnippet(el)
189
+ : el.outerHTML || '';
172
190
 
173
191
  occurrences.push({
174
192
  selector: stableSelector,
@@ -202,13 +220,13 @@ function runInPage(ctx) {
202
220
  }
203
221
 
204
222
  const stableSelector = helpers.buildSelector ? helpers.buildSelector(el) : 'html';
205
- const html = helpers.getOuterHtmlSnippet ? helpers.getOuterHtmlSnippet(el) : (el.outerHTML || '');
223
+ const html = helpers.getOuterHtmlSnippet ? helpers.getOuterHtmlSnippet(el) : el.outerHTML || '';
206
224
 
207
225
  occurrences.push({
208
226
  selector: stableSelector,
209
227
  html,
210
- summary: 'This skip link\'s target does not exist.',
211
- hint: 'Point the skip link\'s href at an id that exists in the document, or add the missing target element.',
228
+ summary: "This skip link's target does not exist.",
229
+ hint: "Point the skip link's href at an id that exists in the document, or add the missing target element.",
212
230
  i18n: {
213
231
  summaryKey: 'skipLink_summary_cantTell',
214
232
  hintKey: 'skipLink_hint_cantTell',
@@ -224,7 +242,12 @@ function runInPage(ctx) {
224
242
  return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
225
243
  }
226
244
  if (occurrences.length) {
227
- return { ruleId: rule.ruleId, outcome: 'cantTell', severity: rule.defaultSeverity || 'minor', occurrences };
245
+ return {
246
+ ruleId: rule.ruleId,
247
+ outcome: 'cantTell',
248
+ severity: rule.defaultSeverity || 'minor',
249
+ occurrences
250
+ };
228
251
  }
229
252
  return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
230
253
  }