@surea11y/core 1.2.0 → 1.4.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 (168) hide show
  1. package/CHANGELOG.md +81 -7
  2. package/LICENSE +373 -21
  3. package/README.md +175 -35
  4. package/bin/surea11y-core.js +20 -0
  5. package/docs/API_STABILITY.md +27 -1
  6. package/docs/BINDING_AUTHORS_GUIDE.md +9 -9
  7. package/docs/CI_INTEGRATIONS.md +103 -0
  8. package/docs/ENGINE_OPTIONS.md +2 -0
  9. package/docs/I18N.md +12 -9
  10. package/docs/INTEGRATION.md +19 -1
  11. package/docs/LIMITATIONS.md +1 -1
  12. package/docs/OUTPUT_SCHEMA.md +1 -1
  13. package/docs/REPORT.md +1 -1
  14. package/docs/RULE_CATALOG.md +1 -1
  15. package/docs/SARIF.md +59 -0
  16. package/package.json +63 -18
  17. package/src/baseline.js +0 -0
  18. package/src/checks/automatic/area-alt-present.js +63 -31
  19. package/src/checks/automatic/aria-allowed-attr.js +204 -80
  20. package/src/checks/automatic/aria-allowed-role.js +23 -7
  21. package/src/checks/automatic/aria-braille-equivalent.js +34 -10
  22. package/src/checks/automatic/aria-conditional-attr.js +32 -14
  23. package/src/checks/automatic/aria-deprecated-role.js +26 -11
  24. package/src/checks/automatic/aria-hidden-body.js +48 -23
  25. package/src/checks/automatic/aria-hidden-focus.js +420 -66
  26. package/src/checks/automatic/aria-prohibited-attr.js +327 -60
  27. package/src/checks/automatic/aria-prohibited-children.js +111 -103
  28. package/src/checks/automatic/aria-required-attr.js +29 -15
  29. package/src/checks/automatic/aria-required-children.js +44 -24
  30. package/src/checks/automatic/aria-required-parent.js +64 -35
  31. package/src/checks/automatic/aria-role-name-present.js +49 -21
  32. package/src/checks/automatic/aria-roles-valid.js +24 -12
  33. package/src/checks/automatic/aria-valid-attr-value.js +46 -22
  34. package/src/checks/automatic/aria-valid-attr.js +19 -5
  35. package/src/checks/automatic/autocomplete-valid.js +76 -16
  36. package/src/checks/automatic/avoid-inline-spacing.js +23 -8
  37. package/src/checks/automatic/binary-control-name-present.js +62 -50
  38. package/src/checks/automatic/button-name-present.js +54 -24
  39. package/src/checks/automatic/bypass-blocks-present.js +51 -32
  40. package/src/checks/automatic/canvas-text-alternative-present.js +59 -26
  41. package/src/checks/automatic/combobox-name-present.js +40 -45
  42. package/src/checks/automatic/contrast-computable.js +363 -341
  43. package/src/checks/automatic/contrast-enhanced.js +489 -466
  44. package/src/checks/automatic/contrast-minimum.js +488 -465
  45. package/src/checks/automatic/css-orientation-lock.js +51 -35
  46. package/src/checks/automatic/definition-list-children-valid.js +46 -25
  47. package/src/checks/automatic/deprecated-elements-not-used.js +25 -9
  48. package/src/checks/automatic/dialog-name-present.js +47 -85
  49. package/src/checks/automatic/dlitem-parent-valid.js +25 -8
  50. package/src/checks/automatic/duplicate-id-aria.js +28 -9
  51. package/src/checks/automatic/embed-text-alternative-present.js +88 -35
  52. package/src/checks/automatic/form-control-programmatic-label-present.js +81 -196
  53. package/src/checks/automatic/form-control-single-label.js +50 -14
  54. package/src/checks/automatic/html-xml-lang-mismatch.js +36 -18
  55. package/src/checks/automatic/iframe-focusable-content.js +265 -22
  56. package/src/checks/automatic/iframe-name-present.js +33 -9
  57. package/src/checks/automatic/iframe-title-unique.js +32 -9
  58. package/src/checks/automatic/img-alt-present.js +54 -52
  59. package/src/checks/automatic/input-image-alt-present.js +141 -112
  60. package/src/checks/automatic/label-in-name.js +65 -41
  61. package/src/checks/automatic/language-page-present.js +111 -109
  62. package/src/checks/automatic/link-in-text-block.js +61 -19
  63. package/src/checks/automatic/link-name-present.js +47 -14
  64. package/src/checks/automatic/list-children-valid.js +40 -33
  65. package/src/checks/automatic/listbox-name-present.js +41 -19
  66. package/src/checks/automatic/listitem-parent-valid.js +48 -13
  67. package/src/checks/automatic/menuitem-name-present.js +41 -61
  68. package/src/checks/automatic/meta-refresh-no-exceptions.js +32 -11
  69. package/src/checks/automatic/meta-refresh-timing-absent.js +22 -6
  70. package/src/checks/automatic/meta-viewport-zoom-enabled.js +26 -7
  71. package/src/checks/automatic/meter-name-present.js +40 -36
  72. package/src/checks/automatic/nested-interactive-controls-absent.js +58 -15
  73. package/src/checks/automatic/object-text-alternative-present.js +93 -39
  74. package/src/checks/automatic/option-name-present.js +40 -21
  75. package/src/checks/automatic/page-title-present.js +19 -6
  76. package/src/checks/automatic/progressbar-name-present.js +49 -44
  77. package/src/checks/automatic/role-img-alt-present.js +211 -159
  78. package/src/checks/automatic/searchbox-name-present.js +41 -19
  79. package/src/checks/automatic/server-side-image-map-absent.js +27 -11
  80. package/src/checks/automatic/slider-name-present.js +42 -47
  81. package/src/checks/automatic/spinbutton-name-present.js +41 -19
  82. package/src/checks/automatic/summary-name-present.js +39 -17
  83. package/src/checks/automatic/svg-image-text-alternative-present.js +116 -47
  84. package/src/checks/automatic/svg-text-alternative-present.js +262 -230
  85. package/src/checks/automatic/tab-name-present.js +39 -60
  86. package/src/checks/automatic/table-headers-attr-valid.js +27 -10
  87. package/src/checks/automatic/table-th-has-data-cells.js +24 -8
  88. package/src/checks/automatic/target-size-minimum.js +123 -48
  89. package/src/checks/automatic/td-has-header.js +53 -12
  90. package/src/checks/automatic/textbox-name-present.js +41 -19
  91. package/src/checks/automatic/tooltip-name-present.js +39 -18
  92. package/src/checks/automatic/treeitem-name-present.js +40 -21
  93. package/src/checks/automatic/valid-lang.js +22 -6
  94. package/src/checks/automatic/video-poster-text-alternative-present.js +81 -36
  95. package/src/checks/manual/accesskeys-manual.js +17 -6
  96. package/src/checks/manual/area-alt-decorative-manual.js +194 -193
  97. package/src/checks/manual/area-alt-quality-manual.js +184 -141
  98. package/src/checks/manual/aria-checked-state-mismatch-manual.js +48 -34
  99. package/src/checks/manual/aria-text-manual.js +20 -11
  100. package/src/checks/manual/canvas-text-alternative-quality-manual.js +151 -114
  101. package/src/checks/manual/css-hidden-focus.js +375 -169
  102. package/src/checks/manual/embed-text-alternative-quality-manual.js +178 -162
  103. package/src/checks/manual/empty-heading-manual.js +41 -24
  104. package/src/checks/manual/empty-table-header-manual.js +69 -31
  105. package/src/checks/manual/focus-order-semantics-manual.js +60 -13
  106. package/src/checks/manual/form-control-programmatic-label-quality-manual.js +209 -246
  107. package/src/checks/manual/heading-order-manual.js +50 -8
  108. package/src/checks/manual/identical-links-same-purpose-manual.js +36 -12
  109. package/src/checks/manual/image-redundant-alt-manual.js +38 -8
  110. package/src/checks/manual/img-alt-decorative-manual.js +133 -96
  111. package/src/checks/manual/img-alt-quality-manual.js +178 -127
  112. package/src/checks/manual/input-image-alt-decorative-manual.js +127 -92
  113. package/src/checks/manual/input-image-alt-quality-manual.js +127 -92
  114. package/src/checks/manual/label-title-only-manual.js +44 -28
  115. package/src/checks/manual/landmark-banner-is-top-level-manual.js +95 -38
  116. package/src/checks/manual/landmark-contentinfo-is-top-level-manual.js +85 -32
  117. package/src/checks/manual/landmark-main-is-top-level-manual.js +69 -27
  118. package/src/checks/manual/landmark-no-duplicate-banner-manual.js +45 -33
  119. package/src/checks/manual/landmark-no-duplicate-contentinfo-manual.js +43 -31
  120. package/src/checks/manual/landmark-no-duplicate-main-manual.js +27 -21
  121. package/src/checks/manual/landmark-one-main-manual.js +38 -43
  122. package/src/checks/manual/landmark-unique-manual.js +78 -67
  123. package/src/checks/manual/link-name-quality-manual.js +45 -12
  124. package/src/checks/manual/media-transcript-present-manual.js +37 -22
  125. package/src/checks/manual/meta-viewport-large-manual.js +19 -6
  126. package/src/checks/manual/mouse-only-event-handlers-manual.js +40 -11
  127. package/src/checks/manual/no-autoplay-audio-manual.js +22 -6
  128. package/src/checks/manual/object-text-alternative-quality-manual.js +177 -154
  129. package/src/checks/manual/p-as-heading-manual.js +24 -7
  130. package/src/checks/manual/page-has-heading-one-manual.js +42 -32
  131. package/src/checks/manual/page-title-patterns-manual.js +80 -50
  132. package/src/checks/manual/presentation-role-conflict-manual.js +101 -47
  133. package/src/checks/manual/region-manual.js +244 -60
  134. package/src/checks/manual/scope-attr-valid-manual.js +13 -4
  135. package/src/checks/manual/scrollable-region-focusable-manual.js +39 -11
  136. package/src/checks/manual/skip-link-manual.js +42 -18
  137. package/src/checks/manual/svg-text-alternative-quality-manual.js +208 -165
  138. package/src/checks/manual/tabindex-manual.js +13 -4
  139. package/src/checks/manual/table-duplicate-name-manual.js +22 -11
  140. package/src/checks/manual/table-fake-caption-manual.js +48 -10
  141. package/src/checks/manual/video-caption-manual.js +17 -4
  142. package/src/checks/manual-review.js +58 -12
  143. package/src/core.js +41705 -29650
  144. package/src/index.js +2 -0
  145. package/src/report.js +109 -47
  146. package/src/sarif.js +190 -0
  147. package/surea11y.browser.js +37774 -0
  148. package/bin/core.js +0 -348
  149. package/docs/CLI.md +0 -75
  150. package/src/catalogs/composites.wcag.js +0 -490
  151. package/src/checks/rules-and-tags.full.csv +0 -19
  152. package/src/checks/rules-and-tags.full.json +0 -259
  153. package/src/core/aria-helpers.js +0 -970
  154. package/src/core/contrast-helpers.js +0 -1147
  155. package/src/core/dom-helpers.js +0 -4235
  156. package/src/core/dom-runner.js +0 -671
  157. package/src/core/frame-messaging.js +0 -210
  158. package/src/core/frame-scan.js +0 -178
  159. package/src/core/rollup-composites.js +0 -135
  160. package/src/core/rule-meta.js +0 -159
  161. package/src/coverage/wcag-facets.js +0 -1079
  162. package/src/coverage/wcag-version-map.js +0 -84
  163. package/src/i18n/en.js +0 -923
  164. package/src/i18n/fr.js +0 -844
  165. package/src/policy/contracts.js +0 -18
  166. package/src/policy/resolvePolicy.js +0 -55
  167. package/src/policy/schemas/engine-options.schema.json +0 -103
  168. package/src/policy/schemas/policy-contract.schema.json +0 -40
@@ -1,3 +1,5 @@
1
+ /* SPDX-License-Identifier: MPL-2.0 */
2
+
1
3
  'use strict';
2
4
 
3
5
  /**
@@ -42,7 +44,13 @@ const meta = {
42
44
  tags: ['wcag2aaa', 'wcag249', 'navigation', 'atomic', 'manual'],
43
45
  wcagSc: ['2.4.9'],
44
46
  normativeMappings: [
45
- { standard: 'WCAG', version: '2.2', requirement: '2.4.9', title: 'Link Purpose (Link Only)', conformanceLevel: 'AAA' }
47
+ {
48
+ standard: 'WCAG',
49
+ version: '2.2',
50
+ requirement: '2.4.9',
51
+ title: 'Link Purpose (Link Only)',
52
+ conformanceLevel: 'AAA'
53
+ }
46
54
  ],
47
55
  defaultSeverity: 'minor',
48
56
  category: 'operable',
@@ -52,13 +60,15 @@ const meta = {
52
60
  };
53
61
 
54
62
  function runInPage(ctx) {
55
- const { document, helpers, rule } = ctx;
63
+ const { helpers, rule } = ctx;
56
64
 
57
65
  function normName(s) {
58
66
  return (s == null ? '' : String(s)).replace(/\s+/g, ' ').trim().toLowerCase();
59
67
  }
60
68
 
61
- const nodes = helpers.queryAllSmart ? helpers.queryAllSmart('a[href]') : helpers.queryAll('a[href]');
69
+ const nodes = helpers.queryAllSmart
70
+ ? helpers.queryAllSmart('a[href]')
71
+ : helpers.queryAll('a[href]');
62
72
 
63
73
  const groups = new Map(); // normName -> [{ el, href }]
64
74
  let applicableCount = 0;
@@ -67,18 +77,24 @@ function runInPage(ctx) {
67
77
  if (!el || !el.getAttribute) continue;
68
78
 
69
79
  const eligResult = helpers.isAccTreeEligible ? helpers.isAccTreeEligible(el, ctx) : true;
70
- const eligible = typeof eligResult === 'boolean' ? eligResult : !!(eligResult && eligResult.eligible);
80
+ const eligible =
81
+ typeof eligResult === 'boolean' ? eligResult : !!(eligResult && eligResult.eligible);
71
82
  if (!eligible) continue;
72
83
 
73
84
  const nameInfo = helpers.getAccessibleNameInfo ? helpers.getAccessibleNameInfo(el, ctx) : null;
74
- const rawName = (nameInfo && typeof nameInfo.value === 'string' && nameInfo.value.trim())
75
- ? nameInfo.value
76
- : (el.textContent || '');
85
+ const rawName =
86
+ nameInfo && typeof nameInfo.value === 'string' && nameInfo.value.trim()
87
+ ? nameInfo.value
88
+ : el.textContent || '';
77
89
  const name = normName(rawName);
78
90
  if (!name) continue;
79
91
 
80
- let href = '';
81
- try { href = String(el.href || ''); } catch { href = ''; }
92
+ let href;
93
+ try {
94
+ href = String(el.href || '');
95
+ } catch {
96
+ href = '';
97
+ }
82
98
  if (!href) continue;
83
99
 
84
100
  applicableCount += 1;
@@ -95,12 +111,15 @@ function runInPage(ctx) {
95
111
 
96
112
  for (const { el, href } of entries) {
97
113
  const stableSelector = helpers.buildSelector ? helpers.buildSelector(el) : 'html';
98
- const html = helpers.getOuterHtmlSnippet ? helpers.getOuterHtmlSnippet(el) : (el.outerHTML || '');
114
+ const html = helpers.getOuterHtmlSnippet
115
+ ? helpers.getOuterHtmlSnippet(el)
116
+ : el.outerHTML || '';
99
117
 
100
118
  const baseOccurrence = {
101
119
  selector: stableSelector,
102
120
  html,
103
- summary: 'This link shares an accessible name with other links on the page that lead to a different destination.',
121
+ summary:
122
+ 'This link shares an accessible name with other links on the page that lead to a different destination.',
104
123
  hint: 'Ensure links with the same text serve the same purpose, or make the link text distinct enough to describe each destination.',
105
124
  i18n: {
106
125
  summaryKey: 'identicalLinksSamePurpose_summary_cantTell',
@@ -130,7 +149,12 @@ function runInPage(ctx) {
130
149
  }
131
150
 
132
151
  if (occurrences.length) {
133
- return { ruleId: rule.ruleId, outcome: 'cantTell', severity: rule.defaultSeverity || 'minor', occurrences };
152
+ return {
153
+ ruleId: rule.ruleId,
154
+ outcome: 'cantTell',
155
+ severity: rule.defaultSeverity || 'minor',
156
+ occurrences
157
+ };
134
158
  }
135
159
 
136
160
  // Manual rules may only emit cantTell/notApplicable (never pass/fail):
@@ -1,10 +1,12 @@
1
+ /* SPDX-License-Identifier: MPL-2.0 */
2
+
1
3
  'use strict';
2
4
 
3
5
  /**
4
6
  * @check image-redundant-alt
5
7
  * @atomic true
6
8
  * @summary An image's alt text must not duplicate adjacent visible text
7
- * @standard Best Practices (a widely-used reference engine's classification; no formal WCAG Success Criterion — see ROADMAP.md Tier 1b)
9
+ * @standard Best Practices (no formal WCAG Success Criterion — see ROADMAP.md Tier 1b)
8
10
  * @applicability
9
11
  * Applies to <img> elements with non-empty alt text whose immediate
10
12
  * parent element also has other visible text content (i.e. text nodes
@@ -30,7 +32,8 @@ const id = 'image-redundant-alt';
30
32
 
31
33
  const meta = {
32
34
  title: 'Image alt text must not duplicate adjacent visible text',
33
- description: 'Checks that an <img> alt text is not identical to other visible text already present in its immediate parent element.',
35
+ description:
36
+ 'Checks that an <img> alt text is not identical to other visible text already present in its immediate parent element.',
34
37
  i18n: {
35
38
  titleKey: 'imageRedundantAlt_title',
36
39
  descriptionKey: 'imageRedundantAlt_description'
@@ -47,26 +50,48 @@ const meta = {
47
50
  };
48
51
 
49
52
  function runInPage(ctx) {
50
- const { document, helpers, rule } = ctx;
53
+ const { helpers, rule } = ctx;
51
54
 
52
55
  function normalizeWs(s) {
53
- return String(s || '').replace(/\s+/g, ' ').trim();
56
+ return String(s || '')
57
+ .replace(/\s+/g, ' ')
58
+ .trim();
54
59
  }
55
60
 
56
61
  function getOwnTextExcludingImg(parent, imgEl) {
62
+ const isAccTreeEligible =
63
+ helpers && typeof helpers.isAccTreeEligible === 'function' ? helpers.isAccTreeEligible : null;
64
+
57
65
  let text = '';
58
66
  for (const child of parent.childNodes || []) {
59
67
  if (child === imgEl) continue;
60
68
  if (child.nodeType === 3) {
61
69
  text += ' ' + (child.nodeValue || '');
62
70
  } else if (child.nodeType === 1 && child !== imgEl) {
71
+ // An aria-hidden sibling is never actually announced to assistive
72
+ // technology, so its text can't cause the "same words twice"
73
+ // double-announcement this rule exists to catch -- counting it
74
+ // anyway flags a redundancy that doesn't exist in what AT users
75
+ // actually hear.
76
+ if (isAccTreeEligible) {
77
+ const elig = (() => {
78
+ try {
79
+ return isAccTreeEligible(child, ctx);
80
+ } catch {
81
+ return { eligible: true, reasons: [] };
82
+ }
83
+ })();
84
+ if (elig && elig.eligible === false) continue;
85
+ }
63
86
  text += ' ' + (child.textContent || '');
64
87
  }
65
88
  }
66
89
  return normalizeWs(text);
67
90
  }
68
91
 
69
- const nodes = helpers.queryAllSmart ? helpers.queryAllSmart('img[alt]') : helpers.queryAll('img[alt]');
92
+ const nodes = helpers.queryAllSmart
93
+ ? helpers.queryAllSmart('img[alt]')
94
+ : helpers.queryAll('img[alt]');
70
95
 
71
96
  const occurrences = [];
72
97
  let applicableCount = 0;
@@ -87,12 +112,12 @@ function runInPage(ctx) {
87
112
  if (otherText.toLowerCase() !== alt.toLowerCase()) continue;
88
113
 
89
114
  const stableSelector = helpers.buildSelector ? helpers.buildSelector(el) : 'html';
90
- const html = helpers.getOuterHtmlSnippet ? helpers.getOuterHtmlSnippet(el) : (el.outerHTML || '');
115
+ const html = helpers.getOuterHtmlSnippet ? helpers.getOuterHtmlSnippet(el) : el.outerHTML || '';
91
116
 
92
117
  occurrences.push({
93
118
  selector: stableSelector,
94
119
  html,
95
- summary: 'This image\'s alt text duplicates other visible text right next to it.',
120
+ summary: "This image's alt text duplicates other visible text right next to it.",
96
121
  hint: 'Make the alt text empty (alt="") if the image is purely decorative alongside the text, or remove the redundant duplication.',
97
122
  i18n: {
98
123
  summaryKey: 'imageRedundantAlt_summary_cantTell',
@@ -109,7 +134,12 @@ function runInPage(ctx) {
109
134
  return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
110
135
  }
111
136
  if (occurrences.length) {
112
- return { ruleId: rule.ruleId, outcome: 'cantTell', severity: rule.defaultSeverity || 'minor', occurrences };
137
+ return {
138
+ ruleId: rule.ruleId,
139
+ outcome: 'cantTell',
140
+ severity: rule.defaultSeverity || 'minor',
141
+ occurrences
142
+ };
113
143
  }
114
144
  return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
115
145
  }
@@ -1,3 +1,5 @@
1
+ /* SPDX-License-Identifier: MPL-2.0 */
2
+
1
3
  'use strict';
2
4
 
3
5
  /**
@@ -11,20 +13,27 @@
11
13
  * Human review is required to confirm that the provided text alternative is accurate and appropriate.
12
14
  */
13
15
 
14
- const id = "img-alt-decorative";
16
+ const id = 'img-alt-decorative';
15
17
 
16
18
  const meta = {
17
- title: "<img> with alt=\"\" must be decorative (manual review)",
18
- description: "Flags <img> elements with empty alt for human review that they are purely decorative.",
19
+ title: '<img> with alt="" must be decorative (manual review)',
20
+ description:
21
+ 'Flags <img> elements with empty alt for human review that they are purely decorative.',
19
22
  i18n: {
20
- titleKey: "img_altDecorative_title",
21
- descriptionKey: "img_altDecorative_description"
23
+ titleKey: 'img_altDecorative_title',
24
+ descriptionKey: 'img_altDecorative_description'
22
25
  },
23
26
  helpUrl: null,
24
- tags: ["wcag2a", "wcag111", "nontext", "images", "manual", "atomic"],
27
+ tags: ['wcag2a', 'wcag111', 'nontext', 'images', 'manual', 'atomic'],
25
28
  wcagSc: ['1.1.1'],
26
29
  normativeMappings: [
27
- { standard: 'WCAG', version: '2.2', requirement: '1.1.1', title: 'Non-text Content', conformanceLevel: 'A' }
30
+ {
31
+ standard: 'WCAG',
32
+ version: '2.2',
33
+ requirement: '1.1.1',
34
+ title: 'Non-text Content',
35
+ conformanceLevel: 'A'
36
+ }
28
37
  ],
29
38
  defaultSeverity: 'minor',
30
39
  category: 'perceivable',
@@ -38,111 +47,139 @@ const meta = {
38
47
  };
39
48
 
40
49
  function runInPage(ctx) {
41
-
42
- const { document, root, helpers, rule } = ctx;
43
- const safeRoot = root || document;
44
-
45
- const queryAllSmart = helpers && typeof helpers.queryAllSmart === 'function' ? helpers.queryAllSmart : null;
46
- const queryAll = helpers && typeof helpers.queryAll === 'function'
47
- ? helpers.queryAll
48
- : (sel) => {
49
- try { return safeRoot && safeRoot.querySelectorAll ? Array.from(safeRoot.querySelectorAll(sel)) : []; }
50
- catch { return []; }
50
+ const { document, root, helpers, rule } = ctx;
51
+ const safeRoot = root || document;
52
+
53
+ const queryAllSmart =
54
+ helpers && typeof helpers.queryAllSmart === 'function' ? helpers.queryAllSmart : null;
55
+ const queryAll =
56
+ helpers && typeof helpers.queryAll === 'function'
57
+ ? helpers.queryAll
58
+ : (sel) => {
59
+ try {
60
+ return safeRoot && safeRoot.querySelectorAll
61
+ ? Array.from(safeRoot.querySelectorAll(sel))
62
+ : [];
63
+ } catch {
64
+ return [];
65
+ }
51
66
  };
52
67
 
53
- const isAccTreeEligible = helpers && typeof helpers.isAccTreeEligible === 'function'
54
- ? helpers.isAccTreeEligible
55
- : null;
56
-
57
- const getFocusableInfo = helpers && typeof helpers.getFocusableInfo === 'function'
58
- ? helpers.getFocusableInfo
59
- : null;
60
-
61
- function isRolePresentationExcluded(el) {
62
- const role = (() => {
63
- try { return String(el.getAttribute('role') || '').trim().toLowerCase(); }
64
- catch { return ''; }
65
- })();
66
- if (role !== 'presentation' && role !== 'none') return false;
67
-
68
- // Exclude only when NOT focusable (mirrors img-alt-present policy)
69
- let focusable = false;
70
- if (getFocusableInfo) {
71
- const fi = (() => { try { return getFocusableInfo(el, ctx); } catch { return null; } })();
72
- focusable = !!(fi && fi.focusable);
73
- } else {
74
- const tabindex = el.getAttribute('tabindex');
75
- focusable = tabindex != null && String(tabindex).trim() !== '' && !Number.isNaN(Number(String(tabindex).trim()));
68
+ const isAccTreeEligible =
69
+ helpers && typeof helpers.isAccTreeEligible === 'function' ? helpers.isAccTreeEligible : null;
70
+
71
+ const getFocusableInfo =
72
+ helpers && typeof helpers.getFocusableInfo === 'function' ? helpers.getFocusableInfo : null;
73
+
74
+ function isRolePresentationExcluded(el) {
75
+ const role = (() => {
76
+ try {
77
+ return String(el.getAttribute('role') || '')
78
+ .trim()
79
+ .toLowerCase();
80
+ } catch {
81
+ return '';
82
+ }
83
+ })();
84
+ if (role !== 'presentation' && role !== 'none') return false;
85
+
86
+ // Exclude only when NOT focusable (mirrors img-alt-present policy)
87
+ let focusable;
88
+ if (getFocusableInfo) {
89
+ const fi = (() => {
90
+ try {
91
+ return getFocusableInfo(el, ctx);
92
+ } catch {
93
+ return null;
76
94
  }
77
- return !focusable;
95
+ })();
96
+ focusable = !!(fi && fi.focusable);
97
+ } else {
98
+ const tabindex = el.getAttribute('tabindex');
99
+ focusable =
100
+ tabindex != null &&
101
+ String(tabindex).trim() !== '' &&
102
+ !Number.isNaN(Number(String(tabindex).trim()));
78
103
  }
104
+ return !focusable;
105
+ }
79
106
 
80
- const els = (() => {
81
- // Only likely candidates:
82
- // - alt="" (exact)
83
- // - alt that starts/ends with space (to catch whitespace-only like " ")
84
- const sel = 'img[alt=""], img[alt^=" "], img[alt$=" "]';
85
- try { return Array.from((queryAllSmart ? queryAllSmart(sel) : queryAll(sel)) || []); }
86
- catch { return queryAll(sel); }
87
- })();
88
- const uniqueEls = [];
89
- const seen = new Set();
90
- for (const el of els) {
91
- if (!seen.has(el)) { seen.add(el); uniqueEls.push(el); }
107
+ const els = (() => {
108
+ // Only likely candidates:
109
+ // - alt="" (exact)
110
+ // - alt that starts/ends with space (to catch whitespace-only like " ")
111
+ const sel = 'img[alt=""], img[alt^=" "], img[alt$=" "]';
112
+ try {
113
+ return Array.from((queryAllSmart ? queryAllSmart(sel) : queryAll(sel)) || []);
114
+ } catch {
115
+ return queryAll(sel);
92
116
  }
93
-
94
- if (!els.length) {
95
- return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
117
+ })();
118
+ const uniqueEls = [];
119
+ const seen = new Set();
120
+ for (const el of els) {
121
+ if (!seen.has(el)) {
122
+ seen.add(el);
123
+ uniqueEls.push(el);
96
124
  }
125
+ }
97
126
 
98
- const occurrences = [];
99
- let applicableCount = 0;
100
-
101
- for (const el of uniqueEls) {
102
- if (!el || !el.getAttribute) continue;
127
+ if (!els.length) {
128
+ return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
129
+ }
103
130
 
104
- if (isAccTreeEligible) {
105
- const elig = (() => {
106
- try { return isAccTreeEligible(el, ctx); } catch { return { eligible: true, reasons: [] }; }
107
- })();
108
- if (elig && elig.eligible === false) continue;
109
- }
131
+ const occurrences = [];
132
+ let applicableCount = 0;
110
133
 
111
- if (isRolePresentationExcluded(el)) continue;
112
-
113
- // Rule-specific applicability (only elements that already have a text alternative mechanism)
114
- const rawAlt = el.getAttribute('alt');
115
- if (rawAlt == null) continue;
116
- if (String(rawAlt).trim() !== '') continue;
117
-
118
- applicableCount += 1;
119
-
120
- const baseOccurrence = {
121
- summary: 'Review whether <img> is decorative (alt="").',
122
- hint: 'Confirm the image is purely decorative. If it conveys information or function, provide meaningful alt text.',
123
- i18n: {
124
- summaryKey: 'img_altDecorative_summary_cantTell',
125
- hintKey: 'img_altDecorative_hint_cantTell',
126
- params: { element: 'img' }
127
- },
128
- data: {
129
- visibilityFilter: { targetSet: 'acc', accEligible: null, reasons: [] },
130
- details: null
131
- }
132
- };
134
+ for (const el of uniqueEls) {
135
+ if (!el || !el.getAttribute) continue;
133
136
 
134
- if (helpers && typeof helpers.reportOccurrence === 'function') {
135
- occurrences.push(helpers.reportOccurrence(el, baseOccurrence));
136
- } else {
137
- occurrences.push({ selector: '', html: '', ...baseOccurrence });
137
+ if (isAccTreeEligible) {
138
+ const elig = (() => {
139
+ try {
140
+ return isAccTreeEligible(el, ctx);
141
+ } catch {
142
+ return { eligible: true, reasons: [] };
138
143
  }
144
+ })();
145
+ if (elig && elig.eligible === false) continue;
139
146
  }
140
147
 
141
- if (applicableCount === 0) {
142
- return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
148
+ if (isRolePresentationExcluded(el)) continue;
149
+
150
+ // Rule-specific applicability (only elements that already have a text alternative mechanism)
151
+ const rawAlt = el.getAttribute('alt');
152
+ if (rawAlt == null) continue;
153
+ if (String(rawAlt).trim() !== '') continue;
154
+
155
+ applicableCount += 1;
156
+
157
+ const baseOccurrence = {
158
+ summary: 'Review whether <img> is decorative (alt="").',
159
+ hint: 'Confirm the image is purely decorative. If it conveys information or function, provide meaningful alt text.',
160
+ i18n: {
161
+ summaryKey: 'img_altDecorative_summary_cantTell',
162
+ hintKey: 'img_altDecorative_hint_cantTell',
163
+ params: { element: 'img' }
164
+ },
165
+ data: {
166
+ visibilityFilter: { targetSet: 'acc', accEligible: null, reasons: [] },
167
+ details: null
168
+ }
169
+ };
170
+
171
+ if (helpers && typeof helpers.reportOccurrence === 'function') {
172
+ occurrences.push(helpers.reportOccurrence(el, baseOccurrence));
173
+ } else {
174
+ occurrences.push({ selector: '', html: '', ...baseOccurrence });
143
175
  }
176
+ }
177
+
178
+ if (applicableCount === 0) {
179
+ return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
180
+ }
144
181
 
145
- return { ruleId: rule.ruleId, outcome: 'cantTell', severity: 'minor', occurrences };
182
+ return { ruleId: rule.ruleId, outcome: 'cantTell', severity: 'minor', occurrences };
146
183
  }
147
184
 
148
185
  module.exports = { id, meta, runInPage };