@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
@@ -30,7 +30,8 @@ const id = 'image-redundant-alt';
30
30
 
31
31
  const meta = {
32
32
  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.',
33
+ description:
34
+ 'Checks that an <img> alt text is not identical to other visible text already present in its immediate parent element.',
34
35
  i18n: {
35
36
  titleKey: 'imageRedundantAlt_title',
36
37
  descriptionKey: 'imageRedundantAlt_description'
@@ -47,10 +48,12 @@ const meta = {
47
48
  };
48
49
 
49
50
  function runInPage(ctx) {
50
- const { document, helpers, rule } = ctx;
51
+ const { helpers, rule } = ctx;
51
52
 
52
53
  function normalizeWs(s) {
53
- return String(s || '').replace(/\s+/g, ' ').trim();
54
+ return String(s || '')
55
+ .replace(/\s+/g, ' ')
56
+ .trim();
54
57
  }
55
58
 
56
59
  function getOwnTextExcludingImg(parent, imgEl) {
@@ -66,7 +69,9 @@ function runInPage(ctx) {
66
69
  return normalizeWs(text);
67
70
  }
68
71
 
69
- const nodes = helpers.queryAllSmart ? helpers.queryAllSmart('img[alt]') : helpers.queryAll('img[alt]');
72
+ const nodes = helpers.queryAllSmart
73
+ ? helpers.queryAllSmart('img[alt]')
74
+ : helpers.queryAll('img[alt]');
70
75
 
71
76
  const occurrences = [];
72
77
  let applicableCount = 0;
@@ -87,12 +92,12 @@ function runInPage(ctx) {
87
92
  if (otherText.toLowerCase() !== alt.toLowerCase()) continue;
88
93
 
89
94
  const stableSelector = helpers.buildSelector ? helpers.buildSelector(el) : 'html';
90
- const html = helpers.getOuterHtmlSnippet ? helpers.getOuterHtmlSnippet(el) : (el.outerHTML || '');
95
+ const html = helpers.getOuterHtmlSnippet ? helpers.getOuterHtmlSnippet(el) : el.outerHTML || '';
91
96
 
92
97
  occurrences.push({
93
98
  selector: stableSelector,
94
99
  html,
95
- summary: 'This image\'s alt text duplicates other visible text right next to it.',
100
+ summary: "This image's alt text duplicates other visible text right next to it.",
96
101
  hint: 'Make the alt text empty (alt="") if the image is purely decorative alongside the text, or remove the redundant duplication.',
97
102
  i18n: {
98
103
  summaryKey: 'imageRedundantAlt_summary_cantTell',
@@ -109,7 +114,12 @@ function runInPage(ctx) {
109
114
  return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
110
115
  }
111
116
  if (occurrences.length) {
112
- return { ruleId: rule.ruleId, outcome: 'cantTell', severity: rule.defaultSeverity || 'minor', occurrences };
117
+ return {
118
+ ruleId: rule.ruleId,
119
+ outcome: 'cantTell',
120
+ severity: rule.defaultSeverity || 'minor',
121
+ occurrences
122
+ };
113
123
  }
114
124
  return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
115
125
  }
@@ -11,20 +11,27 @@
11
11
  * Human review is required to confirm that the provided text alternative is accurate and appropriate.
12
12
  */
13
13
 
14
- const id = "img-alt-decorative";
14
+ const id = 'img-alt-decorative';
15
15
 
16
16
  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.",
17
+ title: '<img> with alt="" must be decorative (manual review)',
18
+ description:
19
+ 'Flags <img> elements with empty alt for human review that they are purely decorative.',
19
20
  i18n: {
20
- titleKey: "img_altDecorative_title",
21
- descriptionKey: "img_altDecorative_description"
21
+ titleKey: 'img_altDecorative_title',
22
+ descriptionKey: 'img_altDecorative_description'
22
23
  },
23
24
  helpUrl: null,
24
- tags: ["wcag2a", "wcag111", "nontext", "images", "manual", "atomic"],
25
+ tags: ['wcag2a', 'wcag111', 'nontext', 'images', 'manual', 'atomic'],
25
26
  wcagSc: ['1.1.1'],
26
27
  normativeMappings: [
27
- { standard: 'WCAG', version: '2.2', requirement: '1.1.1', title: 'Non-text Content', conformanceLevel: 'A' }
28
+ {
29
+ standard: 'WCAG',
30
+ version: '2.2',
31
+ requirement: '1.1.1',
32
+ title: 'Non-text Content',
33
+ conformanceLevel: 'A'
34
+ }
28
35
  ],
29
36
  defaultSeverity: 'minor',
30
37
  category: 'perceivable',
@@ -38,111 +45,139 @@ const meta = {
38
45
  };
39
46
 
40
47
  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 []; }
48
+ const { document, root, helpers, rule } = ctx;
49
+ const safeRoot = root || document;
50
+
51
+ const queryAllSmart =
52
+ helpers && typeof helpers.queryAllSmart === 'function' ? helpers.queryAllSmart : null;
53
+ const queryAll =
54
+ helpers && typeof helpers.queryAll === 'function'
55
+ ? helpers.queryAll
56
+ : (sel) => {
57
+ try {
58
+ return safeRoot && safeRoot.querySelectorAll
59
+ ? Array.from(safeRoot.querySelectorAll(sel))
60
+ : [];
61
+ } catch {
62
+ return [];
63
+ }
51
64
  };
52
65
 
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()));
66
+ const isAccTreeEligible =
67
+ helpers && typeof helpers.isAccTreeEligible === 'function' ? helpers.isAccTreeEligible : null;
68
+
69
+ const getFocusableInfo =
70
+ helpers && typeof helpers.getFocusableInfo === 'function' ? helpers.getFocusableInfo : null;
71
+
72
+ function isRolePresentationExcluded(el) {
73
+ const role = (() => {
74
+ try {
75
+ return String(el.getAttribute('role') || '')
76
+ .trim()
77
+ .toLowerCase();
78
+ } catch {
79
+ return '';
80
+ }
81
+ })();
82
+ if (role !== 'presentation' && role !== 'none') return false;
83
+
84
+ // Exclude only when NOT focusable (mirrors img-alt-present policy)
85
+ let focusable;
86
+ if (getFocusableInfo) {
87
+ const fi = (() => {
88
+ try {
89
+ return getFocusableInfo(el, ctx);
90
+ } catch {
91
+ return null;
76
92
  }
77
- return !focusable;
93
+ })();
94
+ focusable = !!(fi && fi.focusable);
95
+ } else {
96
+ const tabindex = el.getAttribute('tabindex');
97
+ focusable =
98
+ tabindex != null &&
99
+ String(tabindex).trim() !== '' &&
100
+ !Number.isNaN(Number(String(tabindex).trim()));
78
101
  }
102
+ return !focusable;
103
+ }
79
104
 
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); }
105
+ const els = (() => {
106
+ // Only likely candidates:
107
+ // - alt="" (exact)
108
+ // - alt that starts/ends with space (to catch whitespace-only like " ")
109
+ const sel = 'img[alt=""], img[alt^=" "], img[alt$=" "]';
110
+ try {
111
+ return Array.from((queryAllSmart ? queryAllSmart(sel) : queryAll(sel)) || []);
112
+ } catch {
113
+ return queryAll(sel);
92
114
  }
93
-
94
- if (!els.length) {
95
- return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
115
+ })();
116
+ const uniqueEls = [];
117
+ const seen = new Set();
118
+ for (const el of els) {
119
+ if (!seen.has(el)) {
120
+ seen.add(el);
121
+ uniqueEls.push(el);
96
122
  }
123
+ }
97
124
 
98
- const occurrences = [];
99
- let applicableCount = 0;
100
-
101
- for (const el of uniqueEls) {
102
- if (!el || !el.getAttribute) continue;
125
+ if (!els.length) {
126
+ return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
127
+ }
103
128
 
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
- }
129
+ const occurrences = [];
130
+ let applicableCount = 0;
110
131
 
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
- };
132
+ for (const el of uniqueEls) {
133
+ if (!el || !el.getAttribute) continue;
133
134
 
134
- if (helpers && typeof helpers.reportOccurrence === 'function') {
135
- occurrences.push(helpers.reportOccurrence(el, baseOccurrence));
136
- } else {
137
- occurrences.push({ selector: '', html: '', ...baseOccurrence });
135
+ if (isAccTreeEligible) {
136
+ const elig = (() => {
137
+ try {
138
+ return isAccTreeEligible(el, ctx);
139
+ } catch {
140
+ return { eligible: true, reasons: [] };
138
141
  }
142
+ })();
143
+ if (elig && elig.eligible === false) continue;
139
144
  }
140
145
 
141
- if (applicableCount === 0) {
142
- return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
146
+ if (isRolePresentationExcluded(el)) continue;
147
+
148
+ // Rule-specific applicability (only elements that already have a text alternative mechanism)
149
+ const rawAlt = el.getAttribute('alt');
150
+ if (rawAlt == null) continue;
151
+ if (String(rawAlt).trim() !== '') continue;
152
+
153
+ applicableCount += 1;
154
+
155
+ const baseOccurrence = {
156
+ summary: 'Review whether <img> is decorative (alt="").',
157
+ hint: 'Confirm the image is purely decorative. If it conveys information or function, provide meaningful alt text.',
158
+ i18n: {
159
+ summaryKey: 'img_altDecorative_summary_cantTell',
160
+ hintKey: 'img_altDecorative_hint_cantTell',
161
+ params: { element: 'img' }
162
+ },
163
+ data: {
164
+ visibilityFilter: { targetSet: 'acc', accEligible: null, reasons: [] },
165
+ details: null
166
+ }
167
+ };
168
+
169
+ if (helpers && typeof helpers.reportOccurrence === 'function') {
170
+ occurrences.push(helpers.reportOccurrence(el, baseOccurrence));
171
+ } else {
172
+ occurrences.push({ selector: '', html: '', ...baseOccurrence });
143
173
  }
174
+ }
175
+
176
+ if (applicableCount === 0) {
177
+ return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
178
+ }
144
179
 
145
- return { ruleId: rule.ruleId, outcome: 'cantTell', severity: 'minor', occurrences };
180
+ return { ruleId: rule.ruleId, outcome: 'cantTell', severity: 'minor', occurrences };
146
181
  }
147
182
 
148
183
  module.exports = { id, meta, runInPage };
@@ -11,20 +11,26 @@
11
11
  * Human review is required to confirm that the provided text alternative is accurate and appropriate.
12
12
  */
13
13
 
14
- const id = "img-alt-quality";
14
+ const id = 'img-alt-quality';
15
15
 
16
16
  const meta = {
17
- title: "<img> alt text must be appropriate (manual review)",
18
- description: "Flags <img> elements with non-empty alt text for human review of appropriateness.",
17
+ title: '<img> alt text must be appropriate (manual review)',
18
+ description: 'Flags <img> elements with non-empty alt text for human review of appropriateness.',
19
19
  i18n: {
20
- titleKey: "img_altQuality_title",
21
- descriptionKey: "img_altQuality_description"
20
+ titleKey: 'img_altQuality_title',
21
+ descriptionKey: 'img_altQuality_description'
22
22
  },
23
23
  helpUrl: null,
24
- tags: ["wcag2a", "wcag111", "nontext", "images", "manual", "atomic"],
24
+ tags: ['wcag2a', 'wcag111', 'nontext', 'images', 'manual', 'atomic'],
25
25
  wcagSc: ['1.1.1'],
26
26
  normativeMappings: [
27
- { standard: 'WCAG', version: '2.2', requirement: '1.1.1', title: 'Non-text Content', conformanceLevel: 'A' }
27
+ {
28
+ standard: 'WCAG',
29
+ version: '2.2',
30
+ requirement: '1.1.1',
31
+ title: 'Non-text Content',
32
+ conformanceLevel: 'A'
33
+ }
28
34
  ],
29
35
  defaultSeverity: 'minor',
30
36
  category: 'perceivable',
@@ -38,145 +44,188 @@ const meta = {
38
44
  };
39
45
 
40
46
  function runInPage(ctx) {
41
- const { document, root, helpers, rule } = ctx;
42
- const safeRoot = root || document;
43
-
44
- // Cap occurrences to keep manual “quality” checks fast on large pages.
45
- // Deterministic: we keep DOM order, just stop collecting after N.
46
- const MAX_OCCURRENCES = 50;
47
-
48
- const queryAllSmart = helpers && typeof helpers.queryAllSmart === 'function' ? helpers.queryAllSmart : null;
49
- const queryAll = helpers && typeof helpers.queryAll === 'function'
50
- ? helpers.queryAll
51
- : (sel) => {
52
- try { return safeRoot && safeRoot.querySelectorAll ? Array.from(safeRoot.querySelectorAll(sel)) : []; }
53
- catch { return []; }
47
+ const { document, root, helpers, rule } = ctx;
48
+ const safeRoot = root || document;
49
+
50
+ // Cap occurrences to keep manual “quality” checks fast on large pages.
51
+ // Deterministic: we keep DOM order, just stop collecting after N.
52
+ const MAX_OCCURRENCES = 50;
53
+
54
+ const queryAllSmart =
55
+ helpers && typeof helpers.queryAllSmart === 'function' ? helpers.queryAllSmart : null;
56
+ const queryAll =
57
+ helpers && typeof helpers.queryAll === 'function'
58
+ ? helpers.queryAll
59
+ : (sel) => {
60
+ try {
61
+ return safeRoot && safeRoot.querySelectorAll
62
+ ? Array.from(safeRoot.querySelectorAll(sel))
63
+ : [];
64
+ } catch {
65
+ return [];
66
+ }
54
67
  };
55
68
 
56
- const getEligibilityInfo = helpers && typeof helpers.getEligibilityInfo === 'function'
57
- ? helpers.getEligibilityInfo
58
- : null;
59
-
60
- const isAccTreeEligible = helpers && typeof helpers.isAccTreeEligible === 'function'
61
- ? helpers.isAccTreeEligible
62
- : null;
63
-
64
- const getFocusableInfo = helpers && typeof helpers.getFocusableInfo === 'function'
65
- ? helpers.getFocusableInfo
66
- : null;
67
-
68
- function isRolePresentationExcluded(el) {
69
- const role = (() => {
70
- try { return String(el.getAttribute('role') || '').trim().toLowerCase(); }
71
- catch { return ''; }
72
- })();
73
- if (role !== 'presentation' && role !== 'none') return false;
74
-
75
- // Exclude only when NOT focusable (mirrors img-alt-present policy)
76
- let focusable = false;
77
- if (getFocusableInfo) {
78
- const fi = (() => { try { return getFocusableInfo(el, ctx); } catch { return null; } })();
79
- focusable = !!(fi && fi.focusable);
80
- } else {
81
- const tabindex = el.getAttribute('tabindex');
82
- focusable = tabindex != null && String(tabindex).trim() !== '' && !Number.isNaN(Number(String(tabindex).trim()));
83
- }
84
- return !focusable;
85
- }
69
+ const getEligibilityInfo =
70
+ helpers && typeof helpers.getEligibilityInfo === 'function' ? helpers.getEligibilityInfo : null;
86
71
 
87
- const selector = 'img[alt]:not([alt=""])';
88
- const els = (() => {
89
- try { return Array.from((queryAllSmart ? queryAllSmart(selector) : queryAll(selector)) || []); }
90
- catch { return queryAll(selector); }
91
- })();
72
+ const isAccTreeEligible =
73
+ helpers && typeof helpers.isAccTreeEligible === 'function' ? helpers.isAccTreeEligible : null;
92
74
 
93
- if (!els.length) {
94
- return {
95
- ruleId: rule.ruleId,
96
- outcome: 'notApplicable',
97
- severity: 'minor',
98
- occurrences: [],
99
- data: { details: { applicableCount: 0, reportedCount: 0, maxOccurrences: MAX_OCCURRENCES, truncated: false } }
100
- };
101
- }
75
+ const getFocusableInfo =
76
+ helpers && typeof helpers.getFocusableInfo === 'function' ? helpers.getFocusableInfo : null;
102
77
 
103
- const occurrences = [];
104
- let applicableCount = 0; // total applicable elements
105
- let collectedCount = 0; // how many occurrences we actually reported
78
+ function isRolePresentationExcluded(el) {
79
+ const role = (() => {
80
+ try {
81
+ return String(el.getAttribute('role') || '')
82
+ .trim()
83
+ .toLowerCase();
84
+ } catch {
85
+ return '';
86
+ }
87
+ })();
88
+ if (role !== 'presentation' && role !== 'none') return false;
89
+
90
+ // Exclude only when NOT focusable (mirrors img-alt-present policy)
91
+ let focusable;
92
+ if (getFocusableInfo) {
93
+ const fi = (() => {
94
+ try {
95
+ return getFocusableInfo(el, ctx);
96
+ } catch {
97
+ return null;
98
+ }
99
+ })();
100
+ focusable = !!(fi && fi.focusable);
101
+ } else {
102
+ const tabindex = el.getAttribute('tabindex');
103
+ focusable =
104
+ tabindex != null &&
105
+ String(tabindex).trim() !== '' &&
106
+ !Number.isNaN(Number(String(tabindex).trim()));
107
+ }
108
+ return !focusable;
109
+ }
106
110
 
107
- for (const el of els) {
108
- if (!el || !el.getAttribute) continue;
111
+ const selector = 'img[alt]:not([alt=""])';
112
+ const els = (() => {
113
+ try {
114
+ return Array.from((queryAllSmart ? queryAllSmart(selector) : queryAll(selector)) || []);
115
+ } catch {
116
+ return queryAll(selector);
117
+ }
118
+ })();
109
119
 
110
- if (isAccTreeEligible) {
111
- const elig = (() => {
112
- try { return isAccTreeEligible(el, ctx); } catch { return { eligible: true, reasons: [] }; }
113
- })();
114
- if (elig && elig.eligible === false) continue;
120
+ if (!els.length) {
121
+ return {
122
+ ruleId: rule.ruleId,
123
+ outcome: 'notApplicable',
124
+ severity: 'minor',
125
+ occurrences: [],
126
+ data: {
127
+ details: {
128
+ applicableCount: 0,
129
+ reportedCount: 0,
130
+ maxOccurrences: MAX_OCCURRENCES,
131
+ truncated: false
115
132
  }
133
+ }
134
+ };
135
+ }
116
136
 
117
- if (isRolePresentationExcluded(el)) continue;
118
-
119
- // Rule-specific applicability: non-empty alt
120
- const alt = (() => {
121
- try { return String(el.getAttribute('alt') || '').trim(); } catch { return ''; }
122
- })();
123
- if (!alt) continue;
124
-
125
- applicableCount += 1;
126
-
127
- // IMPORTANT: stop doing expensive occurrence building after we hit the cap
128
- if (collectedCount >= MAX_OCCURRENCES) continue;
129
-
130
- const eligInfo = getEligibilityInfo ? getEligibilityInfo(el, ctx, { targetSet: 'acc' }) : null;
131
- const baseOccurrence = {
132
- summary: 'Review alt text on <img> for accuracy and appropriateness.',
133
- hint: 'Ensure the alt text conveys the image’s purpose/information in context (not redundant, not filename-like).',
134
- i18n: {
135
- summaryKey: 'img_altQuality_summary_cantTell',
136
- hintKey: 'img_altQuality_hint_cantTell',
137
- params: { element: 'img' }
138
- },
139
- data: {
140
- visibilityFilter: eligInfo || { targetSet: 'acc', accEligible: null, reasons: [] },
141
- details: null
142
- }
143
- };
137
+ const occurrences = [];
138
+ let applicableCount = 0; // total applicable elements
139
+ let collectedCount = 0; // how many occurrences we actually reported
144
140
 
145
- if (helpers && typeof helpers.reportOccurrence === 'function') {
146
- occurrences.push(helpers.reportOccurrence(el, baseOccurrence));
147
- } else {
148
- occurrences.push({ selector: '', html: '', ...baseOccurrence });
149
- }
141
+ for (const el of els) {
142
+ if (!el || !el.getAttribute) continue;
150
143
 
151
- collectedCount += 1;
144
+ if (isAccTreeEligible) {
145
+ const elig = (() => {
146
+ try {
147
+ return isAccTreeEligible(el, ctx);
148
+ } catch {
149
+ return { eligible: true, reasons: [] };
150
+ }
151
+ })();
152
+ if (elig && elig.eligible === false) continue;
152
153
  }
153
154
 
154
- if (applicableCount === 0) {
155
- return {
156
- ruleId: rule.ruleId,
157
- outcome: 'notApplicable',
158
- severity: 'minor',
159
- occurrences: [],
160
- data: { details: { applicableCount: 0, reportedCount: 0, maxOccurrences: MAX_OCCURRENCES, truncated: false } }
161
- };
155
+ if (isRolePresentationExcluded(el)) continue;
156
+
157
+ // Rule-specific applicability: non-empty alt
158
+ const alt = (() => {
159
+ try {
160
+ return String(el.getAttribute('alt') || '').trim();
161
+ } catch {
162
+ return '';
163
+ }
164
+ })();
165
+ if (!alt) continue;
166
+
167
+ applicableCount += 1;
168
+
169
+ // IMPORTANT: stop doing expensive occurrence building after we hit the cap
170
+ if (collectedCount >= MAX_OCCURRENCES) continue;
171
+
172
+ const eligInfo = getEligibilityInfo ? getEligibilityInfo(el, ctx, { targetSet: 'acc' }) : null;
173
+ const baseOccurrence = {
174
+ summary: 'Review alt text on <img> for accuracy and appropriateness.',
175
+ hint: 'Ensure the alt text conveys the image’s purpose/information in context (not redundant, not filename-like).',
176
+ i18n: {
177
+ summaryKey: 'img_altQuality_summary_cantTell',
178
+ hintKey: 'img_altQuality_hint_cantTell',
179
+ params: { element: 'img' }
180
+ },
181
+ data: {
182
+ visibilityFilter: eligInfo || { targetSet: 'acc', accEligible: null, reasons: [] },
183
+ details: null
184
+ }
185
+ };
186
+
187
+ if (helpers && typeof helpers.reportOccurrence === 'function') {
188
+ occurrences.push(helpers.reportOccurrence(el, baseOccurrence));
189
+ } else {
190
+ occurrences.push({ selector: '', html: '', ...baseOccurrence });
162
191
  }
163
192
 
164
- const truncated = applicableCount > collectedCount;
193
+ collectedCount += 1;
194
+ }
165
195
 
196
+ if (applicableCount === 0) {
166
197
  return {
167
- ruleId: rule.ruleId,
168
- outcome: 'cantTell',
169
- severity: 'minor',
170
- occurrences,
171
- data: {
172
- details: {
173
- applicableCount,
174
- reportedCount: collectedCount,
175
- maxOccurrences: MAX_OCCURRENCES,
176
- truncated
177
- }
198
+ ruleId: rule.ruleId,
199
+ outcome: 'notApplicable',
200
+ severity: 'minor',
201
+ occurrences: [],
202
+ data: {
203
+ details: {
204
+ applicableCount: 0,
205
+ reportedCount: 0,
206
+ maxOccurrences: MAX_OCCURRENCES,
207
+ truncated: false
178
208
  }
209
+ }
179
210
  };
211
+ }
212
+
213
+ const truncated = applicableCount > collectedCount;
214
+
215
+ return {
216
+ ruleId: rule.ruleId,
217
+ outcome: 'cantTell',
218
+ severity: 'minor',
219
+ occurrences,
220
+ data: {
221
+ details: {
222
+ applicableCount,
223
+ reportedCount: collectedCount,
224
+ maxOccurrences: MAX_OCCURRENCES,
225
+ truncated
226
+ }
227
+ }
228
+ };
180
229
  }
181
230
 
182
231
  module.exports = { id, meta, runInPage };