@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
  /**
@@ -18,7 +20,7 @@
18
20
  * a frame can have a non-empty title while still failing uniqueness.
19
21
  * - Compares the title ATTRIBUTE specifically, not the full computed
20
22
  * accessible name (aria-label could legitimately differ in wording even
21
- * when title happens to collide) — matches a widely-used reference engine's frame-title-unique.
23
+ * when title happens to collide).
22
24
  * - Not rule-gated on isAccTreeEligible: duplicate titles are a static
23
25
  * markup property. Engine-level hidden-subtree filtering still applies
24
26
  * unless engineOptions.includeHiddenElements is true.
@@ -28,7 +30,8 @@ const id = 'iframe-title-unique';
28
30
 
29
31
  const meta = {
30
32
  title: 'Frame titles must be unique',
31
- description: 'Checks that no two <iframe>/<frame> elements in scope share the same title attribute value.',
33
+ description:
34
+ 'Checks that no two <iframe>/<frame> elements in scope share the same title attribute value.',
32
35
  i18n: {
33
36
  titleKey: 'iframeTitleUnique_title',
34
37
  descriptionKey: 'iframeTitleUnique_description'
@@ -37,7 +40,13 @@ const meta = {
37
40
  tags: ['wcag2a', 'wcag412', 'structure', 'atomic', 'automatic', 'name', 'iframe'],
38
41
  wcagSc: ['4.1.2'],
39
42
  normativeMappings: [
40
- { standard: 'WCAG', version: '2.2', requirement: '4.1.2', title: 'Name, Role, Value', conformanceLevel: 'A' }
43
+ {
44
+ standard: 'WCAG',
45
+ version: '2.2',
46
+ requirement: '4.1.2',
47
+ title: 'Name, Role, Value',
48
+ conformanceLevel: 'A'
49
+ }
41
50
  ],
42
51
  defaultSeverity: 'moderate',
43
52
  category: 'robust',
@@ -47,9 +56,11 @@ const meta = {
47
56
  };
48
57
 
49
58
  function runInPage(ctx) {
50
- const { document, helpers, rule } = ctx;
59
+ const { helpers, rule } = ctx;
51
60
 
52
- const nodes = helpers.queryAllSmart ? helpers.queryAllSmart('iframe, frame') : helpers.queryAll('iframe, frame');
61
+ const nodes = helpers.queryAllSmart
62
+ ? helpers.queryAllSmart('iframe, frame')
63
+ : helpers.queryAll('iframe, frame');
53
64
 
54
65
  const groups = new Map(); // trimmed title -> elements[]
55
66
  let applicableCount = 0;
@@ -79,7 +90,9 @@ function runInPage(ctx) {
79
90
  for (const el of els) {
80
91
  const tag = el.tagName.toLowerCase();
81
92
  const stableSelector = helpers.buildSelector ? helpers.buildSelector(el) : 'html';
82
- const html = helpers.getOuterHtmlSnippet ? helpers.getOuterHtmlSnippet(el) : (el.outerHTML || '');
93
+ const html = helpers.getOuterHtmlSnippet
94
+ ? helpers.getOuterHtmlSnippet(el)
95
+ : el.outerHTML || '';
83
96
 
84
97
  occurrences.push({
85
98
  selector: stableSelector,
@@ -92,16 +105,26 @@ function runInPage(ctx) {
92
105
  params: { element: tag, title }
93
106
  },
94
107
  data: {
95
- details: { reasonCode: 'IFRAME_TITLE_DUPLICATE', element: tag, title, duplicateCount: els.length }
108
+ details: {
109
+ reasonCode: 'IFRAME_TITLE_DUPLICATE',
110
+ element: tag,
111
+ title,
112
+ duplicateCount: els.length
113
+ }
96
114
  }
97
115
  });
98
116
  }
99
117
  }
100
118
 
101
119
  if (occurrences.length) {
102
- return { ruleId: rule.ruleId, outcome: 'fail', severity: rule.defaultSeverity || 'moderate', occurrences };
120
+ return {
121
+ ruleId: rule.ruleId,
122
+ outcome: 'fail',
123
+ severity: rule.defaultSeverity || 'moderate',
124
+ occurrences
125
+ };
103
126
  }
104
127
  return { ruleId: rule.ruleId, outcome: 'pass', severity: 'minor', occurrences: [] };
105
128
  }
106
129
 
107
- module.exports = { id, meta, runInPage };
130
+ module.exports = { id, meta, runInPage };
@@ -1,3 +1,5 @@
1
+ /* SPDX-License-Identifier: MPL-2.0 */
2
+
1
3
  'use strict';
2
4
 
3
5
  /**
@@ -20,7 +22,8 @@ const id = 'img-alt-present';
20
22
 
21
23
  const meta = {
22
24
  title: '<img> must have an alt attribute',
23
- description: 'Checks that <img> elements provide an alt attribute to support a text alternative mechanism.',
25
+ description:
26
+ 'Checks that <img> elements provide an alt attribute to support a text alternative mechanism.',
24
27
  i18n: {
25
28
  titleKey: 'img_altPresent_title',
26
29
  descriptionKey: 'img_altPresent_description'
@@ -29,7 +32,13 @@ const meta = {
29
32
  tags: ['wcag2a', 'wcag111', 'nontext', 'images', 'atomic', 'automatic'],
30
33
  wcagSc: ['1.1.1'],
31
34
  normativeMappings: [
32
- { standard: 'WCAG', version: '2.2', requirement: '1.1.1', title: 'Non-text Content', conformanceLevel: 'A' }
35
+ {
36
+ standard: 'WCAG',
37
+ version: '2.2',
38
+ requirement: '1.1.1',
39
+ title: 'Non-text Content',
40
+ conformanceLevel: 'A'
41
+ }
33
42
  ],
34
43
  defaultSeverity: 'serious',
35
44
  category: 'perceivable',
@@ -46,12 +55,17 @@ function runInPage(ctx) {
46
55
  const { document, root, helpers, rule } = ctx;
47
56
  const safeRoot = root || document;
48
57
 
49
- const queryAllSmart = helpers && typeof helpers.queryAllSmart === 'function' ? helpers.queryAllSmart : null;
50
- const getEligibilityInfo = helpers && typeof helpers.getEligibilityInfo === 'function' ? helpers.getEligibilityInfo : null;
58
+ const queryAllSmart =
59
+ helpers && typeof helpers.queryAllSmart === 'function' ? helpers.queryAllSmart : null;
60
+ const getEligibilityInfo =
61
+ helpers && typeof helpers.getEligibilityInfo === 'function' ? helpers.getEligibilityInfo : null;
51
62
 
52
- const isAccTreeEligible = helpers && typeof helpers.isAccTreeEligible === 'function' ? helpers.isAccTreeEligible : null;
53
- const getFocusableInfo = helpers && typeof helpers.getFocusableInfo === 'function' ? helpers.getFocusableInfo : null;
54
- const getAriaNameInfo = helpers && typeof helpers.getAriaNameInfo === 'function' ? helpers.getAriaNameInfo : null;
63
+ const isAccTreeEligible =
64
+ helpers && typeof helpers.isAccTreeEligible === 'function' ? helpers.isAccTreeEligible : null;
65
+ const getFocusableInfo =
66
+ helpers && typeof helpers.getFocusableInfo === 'function' ? helpers.getFocusableInfo : null;
67
+ const getAriaNameInfo =
68
+ helpers && typeof helpers.getAriaNameInfo === 'function' ? helpers.getAriaNameInfo : null;
55
69
 
56
70
  // Prefer tagName collection when available (cheap), otherwise fall back.
57
71
  function getImgsCollection() {
@@ -73,7 +87,8 @@ function runInPage(ctx) {
73
87
  }
74
88
 
75
89
  try {
76
- if (safeRoot && typeof safeRoot.querySelectorAll === 'function') return safeRoot.querySelectorAll('img');
90
+ if (safeRoot && typeof safeRoot.querySelectorAll === 'function')
91
+ return safeRoot.querySelectorAll('img');
77
92
  } catch {
78
93
  // fall through
79
94
  }
@@ -94,19 +109,6 @@ function runInPage(ctx) {
94
109
  // Helper-safe trim
95
110
  const trim = (v) => (v == null ? '' : String(v)).trim();
96
111
 
97
- // Deterministic tabindex fallback (only used if helper missing)
98
- function isFocusableFallback(el) {
99
- try {
100
- const tabindex = el.getAttribute('tabindex');
101
- if (tabindex == null) return false;
102
- const s = String(tabindex).trim();
103
- if (!s) return false;
104
- return !Number.isNaN(Number(s));
105
- } catch {
106
- return false;
107
- }
108
- }
109
-
110
112
  for (let i = 0; i < imgLen; i++) {
111
113
  const el = imgs[i];
112
114
  if (!el || !el.getAttribute) continue;
@@ -125,7 +127,7 @@ function runInPage(ctx) {
125
127
  }
126
128
 
127
129
  // Role (presentation/none) exclusion only when NOT focusable.
128
- let role = '';
130
+ let role;
129
131
  try {
130
132
  role = trim(el.getAttribute('role')).toLowerCase();
131
133
  } catch {
@@ -133,17 +135,13 @@ function runInPage(ctx) {
133
135
  }
134
136
 
135
137
  if (role === 'presentation' || role === 'none') {
136
- let focusable = false;
137
- if (getFocusableInfo) {
138
- try {
139
- const fi = getFocusableInfo(el, ctx);
140
- // Preserve existing behavior: prefer fi.focusable; fall back to fi.tabbable if present.
141
- focusable = !!(fi && (fi.focusable || fi.tabbable));
142
- } catch {
143
- focusable = false;
144
- }
145
- } else {
146
- focusable = isFocusableFallback(el);
138
+ let focusable;
139
+ try {
140
+ const fi = getFocusableInfo(el, ctx);
141
+ // Preserve existing behavior: prefer fi.focusable; fall back to fi.tabbable if present.
142
+ focusable = !!(fi && (fi.focusable || fi.tabbable));
143
+ } catch {
144
+ focusable = false;
147
145
  }
148
146
 
149
147
  if (!focusable) continue;
@@ -153,7 +151,7 @@ function runInPage(ctx) {
153
151
  applicableCount += 1;
154
152
 
155
153
  // alt attribute presence check (empty allowed)
156
- let hasAlt = false;
154
+ let hasAlt;
157
155
  try {
158
156
  hasAlt = el.getAttribute('alt') !== null;
159
157
  } catch {
@@ -167,7 +165,7 @@ function runInPage(ctx) {
167
165
  // image with a non-empty ARIA name is not missing a text alternative
168
166
  // just because it lacks an alt attribute.
169
167
  if (getAriaNameInfo) {
170
- let ariaName = null;
168
+ let ariaName;
171
169
  try {
172
170
  ariaName = getAriaNameInfo(el, ctx);
173
171
  } catch {
@@ -176,19 +174,23 @@ function runInPage(ctx) {
176
174
  if (ariaName && ariaName.present) continue;
177
175
  }
178
176
 
179
- // A non-empty title attribute is HTML-AAM's own next fallback naming
180
- // source for <img> once alt is entirely absent (not merely alt="",
181
- // which explicitly marks decorative and stays excluded from this
182
- // branch since hasAlt already short-circuited above) -- confirmed
183
- // against a widely-used reference engine's own image-alt rule, which lists a non-empty
184
- // title as one of its "any" satisfying conditions alongside has-alt/
185
- // aria-label/aria-labelledby. Found via a real page (AliExpress's
186
- // logo, 2026-07-23): <img src="..." title="...">, no alt attribute
187
- // at all -- a real false positive, not a missing text alternative.
177
+ // A non-empty title attribute is HTML-AAM's next fallback naming source
178
+ // for <img> once alt is entirely absent (not merely alt="", which
179
+ // explicitly marks decorative and stays excluded from this branch since
180
+ // hasAlt already short-circuited above). An `<img src="..." title="...">`
181
+ // with no alt attribute at all is not missing a text alternative.
188
182
  const title = trim(el.getAttribute('title'));
189
183
  if (title) continue;
190
184
 
191
- const eligInfo = getEligibilityInfo ? (() => { try { return getEligibilityInfo(el, ctx, { targetSet: 'acc' }); } catch { return null; } })() : null;
185
+ const eligInfo = getEligibilityInfo
186
+ ? (() => {
187
+ try {
188
+ return getEligibilityInfo(el, ctx, { targetSet: 'acc' });
189
+ } catch {
190
+ return null;
191
+ }
192
+ })()
193
+ : null;
192
194
 
193
195
  const baseOccurrence = {
194
196
  summary: 'Missing alt attribute on <img>.',
@@ -201,12 +203,7 @@ function runInPage(ctx) {
201
203
  data: { visibilityFilter: eligInfo || { targetSet: 'acc', accEligible: null, reasons: [] } }
202
204
  };
203
205
 
204
- if (helpers && typeof helpers.reportOccurrence === 'function') {
205
- occurrences.push(helpers.reportOccurrence(el, baseOccurrence));
206
- } else {
207
- // Never compute selector/snippet in the rule.
208
- occurrences.push({ __node: el, selector: '', html: '', ...baseOccurrence });
209
- }
206
+ occurrences.push(helpers.reportOccurrence(el, baseOccurrence));
210
207
  }
211
208
 
212
209
  if (applicableCount === 0) {
@@ -217,7 +214,12 @@ function runInPage(ctx) {
217
214
  return { ruleId: rule.ruleId, outcome: 'pass', severity: 'minor', occurrences: [] };
218
215
  }
219
216
 
220
- return { ruleId: rule.ruleId, outcome: 'fail', severity: rule.defaultSeverity || 'minor', occurrences };
217
+ return {
218
+ ruleId: rule.ruleId,
219
+ outcome: 'fail',
220
+ severity: rule.defaultSeverity || 'minor',
221
+ occurrences
222
+ };
221
223
  }
222
224
 
223
225
  module.exports = { id, meta, runInPage };
@@ -1,3 +1,5 @@
1
+ /* SPDX-License-Identifier: MPL-2.0 */
2
+
1
3
  'use strict';
2
4
 
3
5
  /**
@@ -18,138 +20,165 @@
18
20
  const id = 'input-image-alt-present';
19
21
 
20
22
  const meta = {
21
- title: '<input type="image"> must have an alt attribute',
22
- description:
23
- 'Checks that <input type="image"> elements provide an alt attribute to support a text alternative mechanism.',
24
- i18n: {
25
- titleKey: 'inputImage_altPresent_title',
26
- descriptionKey: 'inputImage_altPresent_description'
27
- },
28
- helpUrl: null,
29
- tags: ['wcag2a', 'wcag111', 'nontext', 'images', 'atomic', 'automatic'],
30
- wcagSc: ['1.1.1'],
31
- normativeMappings: [
32
- { standard: 'WCAG', version: '2.2', requirement: '1.1.1', title: 'Non-text Content', conformanceLevel: 'A' }
33
- ],
34
- defaultSeverity: 'serious',
35
- category: 'perceivable',
36
- type: 'automatic',
37
- defaultConfidence: 'high',
38
- coverage: {
39
- facetsBySc: {
40
- '1.1.1': ['input-image-alt-attr-present']
41
- }
23
+ title: '<input type="image"> must have an alt attribute',
24
+ description:
25
+ 'Checks that <input type="image"> elements provide an alt attribute to support a text alternative mechanism.',
26
+ i18n: {
27
+ titleKey: 'inputImage_altPresent_title',
28
+ descriptionKey: 'inputImage_altPresent_description'
29
+ },
30
+ helpUrl: null,
31
+ tags: ['wcag2a', 'wcag111', 'nontext', 'images', 'atomic', 'automatic'],
32
+ wcagSc: ['1.1.1'],
33
+ normativeMappings: [
34
+ {
35
+ standard: 'WCAG',
36
+ version: '2.2',
37
+ requirement: '1.1.1',
38
+ title: 'Non-text Content',
39
+ conformanceLevel: 'A'
40
+ }
41
+ ],
42
+ defaultSeverity: 'serious',
43
+ category: 'perceivable',
44
+ type: 'automatic',
45
+ defaultConfidence: 'high',
46
+ coverage: {
47
+ facetsBySc: {
48
+ '1.1.1': ['input-image-alt-attr-present']
42
49
  }
50
+ }
43
51
  };
44
52
 
45
53
  function runInPage(ctx) {
46
- const { document, root, helpers, rule } = ctx;
47
- const safeRoot = root || document;
48
-
49
- const queryAllSmart = helpers && typeof helpers.queryAllSmart === 'function' ? helpers.queryAllSmart : null;
50
- const queryAll = helpers && typeof helpers.queryAll === 'function'
51
- ? helpers.queryAll
52
- : (sel) => {
53
- try { return safeRoot && safeRoot.querySelectorAll ? Array.from(safeRoot.querySelectorAll(sel)) : []; }
54
- catch { return []; }
54
+ const { document, root, helpers, rule } = ctx;
55
+ const safeRoot = root || document;
56
+
57
+ const queryAllSmart =
58
+ helpers && typeof helpers.queryAllSmart === 'function' ? helpers.queryAllSmart : null;
59
+ const queryAll =
60
+ helpers && typeof helpers.queryAll === 'function'
61
+ ? helpers.queryAll
62
+ : (sel) => {
63
+ try {
64
+ return safeRoot && safeRoot.querySelectorAll
65
+ ? Array.from(safeRoot.querySelectorAll(sel))
66
+ : [];
67
+ } catch {
68
+ return [];
69
+ }
55
70
  };
56
71
 
57
- const getEligibilityInfo = helpers && typeof helpers.getEligibilityInfo === 'function'
58
- ? helpers.getEligibilityInfo
59
- : null;
72
+ const getEligibilityInfo =
73
+ helpers && typeof helpers.getEligibilityInfo === 'function' ? helpers.getEligibilityInfo : null;
60
74
 
61
- const isAccTreeEligible = helpers && typeof helpers.isAccTreeEligible === 'function'
62
- ? helpers.isAccTreeEligible
63
- : null;
75
+ const isAccTreeEligible =
76
+ helpers && typeof helpers.isAccTreeEligible === 'function' ? helpers.isAccTreeEligible : null;
64
77
 
65
- const getAriaNameInfo = helpers && typeof helpers.getAriaNameInfo === 'function'
66
- ? helpers.getAriaNameInfo
67
- : null;
78
+ const getAriaNameInfo =
79
+ helpers && typeof helpers.getAriaNameInfo === 'function' ? helpers.getAriaNameInfo : null;
68
80
 
69
- const inputs = (() => {
70
- try { return Array.from((queryAllSmart ? queryAllSmart('input[type="image"]') : queryAll('input[type="image"]')) || []); }
71
- catch { return queryAll('input[type="image"]'); }
72
- })();
73
-
74
- if (!inputs.length) {
75
- return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
81
+ const inputs = (() => {
82
+ try {
83
+ return Array.from(
84
+ (queryAllSmart ? queryAllSmart('input[type="image"]') : queryAll('input[type="image"]')) ||
85
+ []
86
+ );
87
+ } catch {
88
+ return queryAll('input[type="image"]');
76
89
  }
90
+ })();
77
91
 
78
- const occurrences = [];
79
- let applicableCount = 0;
80
-
81
- for (const el of inputs) {
82
- if (!el || !el.getAttribute) continue;
92
+ if (!inputs.length) {
93
+ return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
94
+ }
83
95
 
84
- // Applicability: eligible in the acc tree (with helper exceptions).
85
- if (isAccTreeEligible) {
86
- const elig = (() => {
87
- try { return isAccTreeEligible(el, ctx); } catch { return { eligible: true, reasons: [] }; }
88
- })();
89
- if (elig && elig.eligible === false) continue;
90
- }
91
-
92
- applicableCount += 1;
93
-
94
- const hasAlt = el.getAttribute('alt') !== null;
95
- if (hasAlt) continue;
96
-
97
- // aria-label / aria-labelledby is also a valid, standards-recognized
98
- // text-alternative mechanism for <input type="image"> (HTML-AAM
99
- // accessible name computation includes ARIA naming before falling
100
- // back to alt).
101
- if (getAriaNameInfo) {
102
- let ariaName = null;
103
- try {
104
- ariaName = getAriaNameInfo(el, ctx);
105
- } catch {
106
- ariaName = null;
107
- }
108
- if (ariaName && ariaName.present) continue;
109
- }
96
+ const occurrences = [];
97
+ let applicableCount = 0;
110
98
 
111
- // A non-empty title attribute is HTML-AAM's own next fallback naming
112
- // source once alt is entirely absent -- also accepted by a widely-used
113
- // reference engine's equivalent input-image-alt rule (non-empty-title, same "any" list
114
- // as non-empty-alt/aria-label/aria-labelledby). See img-alt-present's
115
- // sibling fix (2026-07-23, AliExpress's title-only logo <img>) for
116
- // the real page this was found via -- same gap, same fix, different
117
- // element.
118
- const titleRaw = (() => { try { return el.getAttribute('title'); } catch { return null; } })();
119
- if (titleRaw !== null && String(titleRaw).trim()) continue;
120
-
121
- const eligInfo = getEligibilityInfo ? getEligibilityInfo(el, ctx, { targetSet: 'acc' }) : null;
122
-
123
- const baseOccurrence = {
124
- summary: 'Missing alt attribute on <input type="image">.',
125
- hint: 'Add an alt attribute (use alt="" only when a separate accessible name is provided).',
126
- i18n: {
127
- summaryKey: 'inputImage_altPresent_summary_fail',
128
- hintKey: 'inputImage_altPresent_hint_fail',
129
- params: { element: 'input[type=image]' }
130
- },
131
- data: {
132
- visibilityFilter: eligInfo || { targetSet: 'acc', accEligible: null, reasons: [] }
133
- }
134
- };
99
+ for (const el of inputs) {
100
+ if (!el || !el.getAttribute) continue;
135
101
 
136
- if (helpers && typeof helpers.reportOccurrence === 'function') {
137
- occurrences.push(helpers.reportOccurrence(el, baseOccurrence));
138
- } else {
139
- // Never compute selector/snippet in the rule.
140
- occurrences.push({ selector: '', html: '', ...baseOccurrence });
102
+ // Applicability: eligible in the acc tree (with helper exceptions).
103
+ if (isAccTreeEligible) {
104
+ const elig = (() => {
105
+ try {
106
+ return isAccTreeEligible(el, ctx);
107
+ } catch {
108
+ return { eligible: true, reasons: [] };
141
109
  }
110
+ })();
111
+ if (elig && elig.eligible === false) continue;
142
112
  }
143
113
 
144
- if (applicableCount === 0) {
145
- return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
114
+ applicableCount += 1;
115
+
116
+ const hasAlt = el.getAttribute('alt') !== null;
117
+ if (hasAlt) continue;
118
+
119
+ // aria-label / aria-labelledby is also a valid, standards-recognized
120
+ // text-alternative mechanism for <input type="image"> (HTML-AAM
121
+ // accessible name computation includes ARIA naming before falling
122
+ // back to alt).
123
+ if (getAriaNameInfo) {
124
+ let ariaName;
125
+ try {
126
+ ariaName = getAriaNameInfo(el, ctx);
127
+ } catch {
128
+ ariaName = null;
129
+ }
130
+ if (ariaName && ariaName.present) continue;
146
131
  }
147
132
 
148
- if (!occurrences.length) {
149
- return { ruleId: rule.ruleId, outcome: 'pass', severity: 'minor', occurrences: [] };
133
+ // A non-empty title attribute is HTML-AAM's own next fallback naming
134
+ // source once alt is entirely absent. Same gap img-alt-present handles
135
+ // for <img title="..."> with no alt.
136
+ const titleRaw = (() => {
137
+ try {
138
+ return el.getAttribute('title');
139
+ } catch {
140
+ return null;
141
+ }
142
+ })();
143
+ if (titleRaw !== null && String(titleRaw).trim()) continue;
144
+
145
+ const eligInfo = getEligibilityInfo ? getEligibilityInfo(el, ctx, { targetSet: 'acc' }) : null;
146
+
147
+ const baseOccurrence = {
148
+ summary: 'Missing alt attribute on <input type="image">.',
149
+ hint: 'Add an alt attribute (use alt="" only when a separate accessible name is provided).',
150
+ i18n: {
151
+ summaryKey: 'inputImage_altPresent_summary_fail',
152
+ hintKey: 'inputImage_altPresent_hint_fail',
153
+ params: { element: 'input[type=image]' }
154
+ },
155
+ data: {
156
+ visibilityFilter: eligInfo || { targetSet: 'acc', accEligible: null, reasons: [] }
157
+ }
158
+ };
159
+
160
+ if (helpers && typeof helpers.reportOccurrence === 'function') {
161
+ occurrences.push(helpers.reportOccurrence(el, baseOccurrence));
162
+ } else {
163
+ // Never compute selector/snippet in the rule.
164
+ occurrences.push({ selector: '', html: '', ...baseOccurrence });
150
165
  }
151
-
152
- return { ruleId: rule.ruleId, outcome: 'fail', severity: rule.defaultSeverity || 'minor', occurrences };
166
+ }
167
+
168
+ if (applicableCount === 0) {
169
+ return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
170
+ }
171
+
172
+ if (!occurrences.length) {
173
+ return { ruleId: rule.ruleId, outcome: 'pass', severity: 'minor', occurrences: [] };
174
+ }
175
+
176
+ return {
177
+ ruleId: rule.ruleId,
178
+ outcome: 'fail',
179
+ severity: rule.defaultSeverity || 'minor',
180
+ occurrences
181
+ };
153
182
  }
154
183
 
155
184
  module.exports = { id, meta, runInPage };