@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
@@ -37,7 +37,13 @@ const meta = {
37
37
  tags: ['wcag2a', 'wcag111', 'media', 'video', 'atomic', 'automatic'],
38
38
  wcagSc: ['1.1.1'],
39
39
  normativeMappings: [
40
- { standard: 'WCAG', version: '2.2', requirement: '1.1.1', title: 'Non-text Content', conformanceLevel: 'A' }
40
+ {
41
+ standard: 'WCAG',
42
+ version: '2.2',
43
+ requirement: '1.1.1',
44
+ title: 'Non-text Content',
45
+ conformanceLevel: 'A'
46
+ }
41
47
  ],
42
48
  defaultSeverity: 'serious',
43
49
  category: 'perceivable',
@@ -54,32 +60,28 @@ function runInPage(ctx) {
54
60
  const { document, root, helpers, rule } = ctx;
55
61
  const safeRoot = root || document;
56
62
 
57
- const reportOccurrence = helpers && typeof helpers.reportOccurrence === 'function'
58
- ? helpers.reportOccurrence
59
- : null;
63
+ const reportOccurrence =
64
+ helpers && typeof helpers.reportOccurrence === 'function' ? helpers.reportOccurrence : null;
60
65
 
61
- const queryAllSmart = helpers && typeof helpers.queryAllSmart === 'function' ? helpers.queryAllSmart : null;
62
- const queryAll = helpers && typeof helpers.queryAll === 'function'
63
- ? helpers.queryAll
64
- : null;
66
+ const queryAllSmart =
67
+ helpers && typeof helpers.queryAllSmart === 'function' ? helpers.queryAllSmart : null;
68
+ const queryAll = helpers && typeof helpers.queryAll === 'function' ? helpers.queryAll : null;
65
69
 
66
- const getEligibilityInfo = helpers && typeof helpers.getEligibilityInfo === 'function'
67
- ? helpers.getEligibilityInfo
68
- : null;
70
+ const getEligibilityInfo =
71
+ helpers && typeof helpers.getEligibilityInfo === 'function' ? helpers.getEligibilityInfo : null;
69
72
 
70
- const isAccTreeEligible = helpers && typeof helpers.isAccTreeEligible === 'function'
71
- ? helpers.isAccTreeEligible
72
- : null;
73
+ const isAccTreeEligible =
74
+ helpers && typeof helpers.isAccTreeEligible === 'function' ? helpers.isAccTreeEligible : null;
73
75
 
74
- const isFocusableInfo = helpers && typeof helpers.getFocusableInfo === 'function'
75
- ? helpers.getFocusableInfo
76
- : null;
76
+ const isFocusableInfo =
77
+ helpers && typeof helpers.getFocusableInfo === 'function' ? helpers.getFocusableInfo : null;
77
78
 
78
- const getAriaNameInfo = helpers && typeof helpers.getAriaNameInfo === 'function'
79
- ? helpers.getAriaNameInfo
80
- : null;
79
+ const getAriaNameInfo =
80
+ helpers && typeof helpers.getAriaNameInfo === 'function' ? helpers.getAriaNameInfo : null;
81
81
 
82
- function trim(v) { return (v == null ? '' : String(v)).trim(); }
82
+ function trim(v) {
83
+ return (v == null ? '' : String(v)).trim();
84
+ }
83
85
 
84
86
  function computeNameInfo(el) {
85
87
  // <video> is not a labelable element (no browser computes an accessible
@@ -89,11 +91,22 @@ function runInPage(ctx) {
89
91
  let aria = null;
90
92
 
91
93
  if (getAriaNameInfo) {
92
- aria = (() => { try { return getAriaNameInfo(el, ctx); } catch { return null; } })();
94
+ aria = (() => {
95
+ try {
96
+ return getAriaNameInfo(el, ctx);
97
+ } catch {
98
+ return null;
99
+ }
100
+ })();
93
101
  }
94
102
 
95
103
  if (aria && aria.present && trim(aria.value)) {
96
- return { present: true, value: trim(aria.value), mechanism: aria.mechanism || 'aria', flags: (aria.flags || []).slice(0) };
104
+ return {
105
+ present: true,
106
+ value: trim(aria.value),
107
+ mechanism: aria.mechanism || 'aria',
108
+ flags: (aria.flags || []).slice(0)
109
+ };
97
110
  }
98
111
 
99
112
  const title = trim(el.getAttribute && el.getAttribute('title'));
@@ -113,14 +126,22 @@ function runInPage(ctx) {
113
126
  try {
114
127
  if (queryAllSmart) return Array.from(queryAllSmart('video') || []);
115
128
  if (queryAll) return Array.from(queryAll('video') || []);
116
- return safeRoot && safeRoot.querySelectorAll ? Array.from(safeRoot.querySelectorAll('video')) : [];
129
+ return safeRoot && safeRoot.querySelectorAll
130
+ ? Array.from(safeRoot.querySelectorAll('video'))
131
+ : [];
117
132
  } catch {
118
- try { return safeRoot && safeRoot.querySelectorAll ? Array.from(safeRoot.querySelectorAll('video')) : []; }
119
- catch { return []; }
133
+ try {
134
+ return safeRoot && safeRoot.querySelectorAll
135
+ ? Array.from(safeRoot.querySelectorAll('video'))
136
+ : [];
137
+ } catch {
138
+ return [];
139
+ }
120
140
  }
121
141
  })();
122
142
 
123
- if (!videos.length) return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
143
+ if (!videos.length)
144
+ return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
124
145
 
125
146
  const occurrences = [];
126
147
  let applicableCount = 0;
@@ -134,7 +155,11 @@ function runInPage(ctx) {
134
155
  // Eligibility: only elements exposed to AT (with helper exceptions)
135
156
  if (isAccTreeEligible) {
136
157
  const elig = (() => {
137
- try { return isAccTreeEligible(el, ctx); } catch { return { eligible: true, reasons: [] }; }
158
+ try {
159
+ return isAccTreeEligible(el, ctx);
160
+ } catch {
161
+ return { eligible: true, reasons: [] };
162
+ }
138
163
  })();
139
164
  if (elig && elig.eligible === false) continue;
140
165
  }
@@ -142,13 +167,20 @@ function runInPage(ctx) {
142
167
  // Role presentation/none excluded ONLY if not focusable (mirrors img behavior)
143
168
  const role = trim(el.getAttribute('role')).toLowerCase();
144
169
  if (role === 'presentation' || role === 'none') {
145
- let focusable = false;
170
+ let focusable;
146
171
  if (isFocusableInfo) {
147
- const fi = (() => { try { return isFocusableInfo(el, ctx); } catch { return null; } })();
172
+ const fi = (() => {
173
+ try {
174
+ return isFocusableInfo(el, ctx);
175
+ } catch {
176
+ return null;
177
+ }
178
+ })();
148
179
  focusable = !!(fi && fi.focusable);
149
180
  } else {
150
181
  const tabindex = el.getAttribute('tabindex');
151
- focusable = tabindex != null && trim(tabindex) !== '' && !Number.isNaN(Number(trim(tabindex)));
182
+ focusable =
183
+ tabindex != null && trim(tabindex) !== '' && !Number.isNaN(Number(trim(tabindex)));
152
184
  }
153
185
  if (!focusable) continue;
154
186
  }
@@ -160,12 +192,16 @@ function runInPage(ctx) {
160
192
 
161
193
  let eligInfo = null;
162
194
  if (getEligibilityInfo) {
163
- try { eligInfo = getEligibilityInfo(el, ctx, { targetSet: 'acc' }); } catch { eligInfo = null; }
195
+ try {
196
+ eligInfo = getEligibilityInfo(el, ctx, { targetSet: 'acc' });
197
+ } catch {
198
+ eligInfo = null;
199
+ }
164
200
  }
165
201
 
166
202
  const baseOccurrence = {
167
203
  summary: 'Missing text alternative for <video> poster.',
168
- hint: 'Provide an accessible name (e.g., aria-label/aria-labelledby) for the poster image.',
204
+ hint: 'Provide an accessible name for the poster image (aria-label/aria-labelledby preferred, or a title attribute as a fallback).',
169
205
  i18n: {
170
206
  summaryKey: 'videoPoster_textAltPresent_summary_fail',
171
207
  hintKey: 'videoPoster_textAltPresent_hint_fail',
@@ -185,9 +221,16 @@ function runInPage(ctx) {
185
221
  }
186
222
  }
187
223
 
188
- if (applicableCount === 0) return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
189
- if (!occurrences.length) return { ruleId: rule.ruleId, outcome: 'pass', severity: 'minor', occurrences: [] };
190
- return { ruleId: rule.ruleId, outcome: 'fail', severity: rule.defaultSeverity || 'minor', occurrences };
224
+ if (applicableCount === 0)
225
+ return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
226
+ if (!occurrences.length)
227
+ return { ruleId: rule.ruleId, outcome: 'pass', severity: 'minor', occurrences: [] };
228
+ return {
229
+ ruleId: rule.ruleId,
230
+ outcome: 'fail',
231
+ severity: rule.defaultSeverity || 'minor',
232
+ occurrences
233
+ };
191
234
  }
192
235
 
193
236
  module.exports = { id, meta, runInPage };
@@ -40,8 +40,10 @@ const meta = {
40
40
  };
41
41
 
42
42
  function runInPage(ctx) {
43
- const { document, helpers, rule } = ctx;
44
- const nodes = helpers.queryAllSmart ? helpers.queryAllSmart('[accesskey]') : helpers.queryAll('[accesskey]');
43
+ const { helpers, rule } = ctx;
44
+ const nodes = helpers.queryAllSmart
45
+ ? helpers.queryAllSmart('[accesskey]')
46
+ : helpers.queryAll('[accesskey]');
45
47
 
46
48
  const groups = new Map(); // normalized key -> elements[]
47
49
  for (const el of nodes) {
@@ -59,12 +61,14 @@ function runInPage(ctx) {
59
61
  if (els.length <= 1) continue;
60
62
  for (const el of els) {
61
63
  const stableSelector = helpers.buildSelector ? helpers.buildSelector(el) : 'html';
62
- const html = helpers.getOuterHtmlSnippet ? helpers.getOuterHtmlSnippet(el) : (el.outerHTML || '');
64
+ const html = helpers.getOuterHtmlSnippet
65
+ ? helpers.getOuterHtmlSnippet(el)
66
+ : el.outerHTML || '';
63
67
 
64
68
  occurrences.push({
65
69
  selector: stableSelector,
66
70
  html,
67
- summary: 'This element\'s accesskey is shared with another element on the page.',
71
+ summary: "This element's accesskey is shared with another element on the page.",
68
72
  hint: 'Make each accesskey value unique across the page.',
69
73
  i18n: {
70
74
  summaryKey: 'accesskeys_summary_cantTell',
@@ -81,7 +85,12 @@ function runInPage(ctx) {
81
85
  if (!occurrences.length) {
82
86
  return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
83
87
  }
84
- return { ruleId: rule.ruleId, outcome: 'cantTell', severity: rule.defaultSeverity || 'minor', occurrences };
88
+ return {
89
+ ruleId: rule.ruleId,
90
+ outcome: 'cantTell',
91
+ severity: rule.defaultSeverity || 'minor',
92
+ occurrences
93
+ };
85
94
  }
86
95
 
87
96
  module.exports = { id, meta, runInPage };
@@ -11,237 +11,236 @@
11
11
  * Human review is required to confirm that the provided text alternative is accurate and appropriate.
12
12
  */
13
13
 
14
- const id = "area-alt-decorative";
14
+ const id = 'area-alt-decorative';
15
15
 
16
16
  const meta = {
17
- title: "<area> with alt=\"\" must be decorative (manual review)",
18
- description: "Flags <area> elements with empty alt for human review that they are decorative/non-informative.",
19
- i18n: {
20
- titleKey: "area_altDecorative_title",
21
- descriptionKey: "area_altDecorative_description"
22
- },
23
- helpUrl: null,
24
- tags: ["wcag2a", "wcag111", "nontext", "images", "imagemap", "manual", "atomic"],
25
- wcagSc: ['1.1.1'],
26
- normativeMappings: [
27
- {standard: 'WCAG', version: '2.2', requirement: '1.1.1', title: 'Non-text Content', conformanceLevel: 'A'}
28
- ],
29
- defaultSeverity: 'minor',
30
- category: 'perceivable',
31
- type: 'manual',
32
- defaultConfidence: 'medium',
33
- coverage: {
34
- facetsBySc: {
35
- '1.1.1': ['text-alternative-quality']
36
- }
17
+ title: '<area> with alt="" must be decorative (manual review)',
18
+ description:
19
+ 'Flags <area> elements with empty alt for human review that they are decorative/non-informative.',
20
+ i18n: {
21
+ titleKey: 'area_altDecorative_title',
22
+ descriptionKey: 'area_altDecorative_description'
23
+ },
24
+ helpUrl: null,
25
+ tags: ['wcag2a', 'wcag111', 'nontext', 'images', 'imagemap', 'manual', 'atomic'],
26
+ wcagSc: ['1.1.1'],
27
+ normativeMappings: [
28
+ {
29
+ standard: 'WCAG',
30
+ version: '2.2',
31
+ requirement: '1.1.1',
32
+ title: 'Non-text Content',
33
+ conformanceLevel: 'A'
34
+ }
35
+ ],
36
+ defaultSeverity: 'minor',
37
+ category: 'perceivable',
38
+ type: 'manual',
39
+ defaultConfidence: 'medium',
40
+ coverage: {
41
+ facetsBySc: {
42
+ '1.1.1': ['text-alternative-quality']
37
43
  }
44
+ }
38
45
  };
39
46
 
40
47
  function runInPage(ctx) {
41
- const {document, root, helpers, rule} = ctx;
42
- const safeRoot = root || document;
43
-
44
- const queryAllSmart = helpers && typeof helpers.queryAllSmart === 'function' ? helpers.queryAllSmart : null;
45
- const queryAll = helpers && typeof helpers.queryAll === 'function'
46
- ? helpers.queryAll
47
- : (sel) => {
48
- try {
49
- return safeRoot && safeRoot.querySelectorAll ? Array.from(safeRoot.querySelectorAll(sel)) : [];
50
- } catch {
51
- return [];
52
- }
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
+ }
53
64
  };
54
65
 
55
- const __accEligCache = new WeakMap();
56
- function accEligibleCached(node) {
57
- if (!isAccTreeEligible) return { eligible: true, reasons: [] };
58
- if (!node || typeof node !== 'object') return { eligible: true, reasons: [] };
59
- const cached = __accEligCache.get(node);
60
- if (cached) return cached;
61
- let res;
62
- try { res = isAccTreeEligible(node, ctx); }
63
- catch { res = { eligible: true, reasons: [] }; }
64
- res = res && typeof res === 'object' ? res : { eligible: !!res, reasons: [] };
65
- __accEligCache.set(node, res);
66
- return res;
66
+ const __accEligCache = new WeakMap();
67
+ function accEligibleCached(node) {
68
+ if (!isAccTreeEligible) return { eligible: true, reasons: [] };
69
+ if (!node || typeof node !== 'object') return { eligible: true, reasons: [] };
70
+ const cached = __accEligCache.get(node);
71
+ if (cached) return cached;
72
+ let res;
73
+ try {
74
+ res = isAccTreeEligible(node, ctx);
75
+ } catch {
76
+ res = { eligible: true, reasons: [] };
67
77
  }
78
+ res = res && typeof res === 'object' ? res : { eligible: !!res, reasons: [] };
79
+ __accEligCache.set(node, res);
80
+ return res;
81
+ }
68
82
 
69
- const getEligibilityInfo = helpers && typeof helpers.getEligibilityInfo === 'function'
70
- ? helpers.getEligibilityInfo
71
- : null;
72
-
73
- const isAccTreeEligible = helpers && typeof helpers.isAccTreeEligible === 'function'
74
- ? helpers.isAccTreeEligible
75
- : null;
83
+ const getEligibilityInfo =
84
+ helpers && typeof helpers.getEligibilityInfo === 'function' ? helpers.getEligibilityInfo : null;
76
85
 
86
+ const isAccTreeEligible =
87
+ helpers && typeof helpers.isAccTreeEligible === 'function' ? helpers.isAccTreeEligible : null;
77
88
 
78
- // --- image-map semantics (rule-local; match automatic <area> applicability) ---
79
- function normUsemap(val) {
80
- try {
81
- const t = String(val || '').trim();
82
- if (!t) return '';
83
- return (t[0] === '#') ? t.slice(1).trim().toLowerCase() : t.toLowerCase();
84
- } catch {
85
- return '';
86
- }
89
+ // --- image-map semantics (rule-local; match automatic <area> applicability) ---
90
+ function normUsemap(val) {
91
+ try {
92
+ const t = String(val || '').trim();
93
+ if (!t) return '';
94
+ return t[0] === '#' ? t.slice(1).trim().toLowerCase() : t.toLowerCase();
95
+ } catch {
96
+ return '';
87
97
  }
98
+ }
88
99
 
89
- function getMapName(mapEl) {
90
- try {
91
- if (!mapEl || !mapEl.getAttribute) return '';
92
- const n = String(mapEl.getAttribute('name') || mapEl.getAttribute('id') || '').trim();
93
- return n ? n.toLowerCase() : '';
94
- } catch {
95
- return '';
96
- }
100
+ function getMapName(mapEl) {
101
+ try {
102
+ if (!mapEl || !mapEl.getAttribute) return '';
103
+ const n = String(mapEl.getAttribute('name') || mapEl.getAttribute('id') || '').trim();
104
+ return n ? n.toLowerCase() : '';
105
+ } catch {
106
+ return '';
97
107
  }
108
+ }
109
+
110
+ const getFocusableInfo =
111
+ helpers && typeof helpers.getFocusableInfo === 'function' ? helpers.getFocusableInfo : null;
112
+
113
+ function isRolePresentationExcluded(el) {
114
+ const role = (() => {
115
+ try {
116
+ return String(el.getAttribute('role') || '')
117
+ .trim()
118
+ .toLowerCase();
119
+ } catch {
120
+ return '';
121
+ }
122
+ })();
123
+ if (role !== 'presentation' && role !== 'none') return false;
98
124
 
99
- function getReferencingImgForArea(areaEl) {
125
+ // Exclude only when NOT focusable (mirrors img-alt-present policy)
126
+ let focusable;
127
+ if (getFocusableInfo) {
128
+ const fi = (() => {
100
129
  try {
101
- if (!areaEl || !areaEl.closest) return null;
102
- const map = areaEl.closest('map');
103
- if (!map) return null;
104
- const mapName = getMapName(map);
105
- if (!mapName) return null;
106
-
107
- const imgs = Array.from(document.querySelectorAll('img[usemap]'));
108
- for (const img of imgs) {
109
- const u = normUsemap(img.getAttribute('usemap'));
110
- if (u && u === mapName) return img; // deterministic: first match in doc order
111
- }
130
+ return getFocusableInfo(el, ctx);
112
131
  } catch {
132
+ return null;
113
133
  }
114
- return null;
134
+ })();
135
+ focusable = !!(fi && fi.focusable);
136
+ } else {
137
+ const tabindex = el.getAttribute('tabindex');
138
+ focusable =
139
+ tabindex != null &&
140
+ String(tabindex).trim() !== '' &&
141
+ !Number.isNaN(Number(String(tabindex).trim()));
115
142
  }
143
+ return !focusable;
144
+ }
116
145
 
117
- const getFocusableInfo = helpers && typeof helpers.getFocusableInfo === 'function'
118
- ? helpers.getFocusableInfo
119
- : null;
120
-
121
- function isRolePresentationExcluded(el) {
122
- const role = (() => {
123
- try {
124
- return String(el.getAttribute('role') || '').trim().toLowerCase();
125
- } catch {
126
- return '';
127
- }
128
- })();
129
- if (role !== 'presentation' && role !== 'none') return false;
130
-
131
- // Exclude only when NOT focusable (mirrors img-alt-present policy)
132
- let focusable = false;
133
- if (getFocusableInfo) {
134
- const fi = (() => {
135
- try {
136
- return getFocusableInfo(el, ctx);
137
- } catch {
138
- return null;
139
- }
140
- })();
141
- focusable = !!(fi && fi.focusable);
142
- } else {
143
- const tabindex = el.getAttribute('tabindex');
144
- focusable = tabindex != null && String(tabindex).trim() !== '' && !Number.isNaN(Number(String(tabindex).trim()));
145
- }
146
- return !focusable;
146
+ const els = (() => {
147
+ try {
148
+ return Array.from((queryAllSmart ? queryAllSmart('area') : queryAll('area')) || []);
149
+ } catch {
150
+ return queryAll('area');
151
+ }
152
+ })();
153
+
154
+ const __usemapIndex = new Map(); // mapName -> img (first in document order)
155
+ try {
156
+ const imgs = Array.from(document.querySelectorAll('img[usemap]'));
157
+ for (const img of imgs) {
158
+ const u = normUsemap(img.getAttribute('usemap'));
159
+ if (!u) continue;
160
+ if (!__usemapIndex.has(u)) __usemapIndex.set(u, img); // keep first match only
147
161
  }
162
+ } catch {}
148
163
 
164
+ if (!els.length) {
165
+ return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
166
+ }
149
167
 
150
- const els = (() => {
151
- try {
152
- return Array.from((queryAllSmart ? queryAllSmart("area") : queryAll("area")) || []);
153
- } catch {
154
- return queryAll("area");
155
- }
156
- })();
168
+ const occurrences = [];
169
+ let applicableCount = 0;
170
+
171
+ for (const el of els) {
172
+ if (!el || !el.getAttribute) continue;
157
173
 
158
- const __usemapIndex = new Map(); // mapName -> img (first in document order)
174
+ // Must belong to a *used* image map (referenced by an <img usemap>). If unused, not applicable.
175
+ let img;
159
176
  try {
160
- const imgs = Array.from(document.querySelectorAll('img[usemap]'));
161
- for (const img of imgs) {
162
- const u = normUsemap(img.getAttribute('usemap'));
163
- if (!u) continue;
164
- if (!__usemapIndex.has(u)) __usemapIndex.set(u, img); // keep first match only
165
- }
177
+ const map = el.closest && el.closest('map');
178
+ const mapName = map ? getMapName(map) : '';
179
+ img = mapName ? __usemapIndex.get(mapName) || null : null;
166
180
  } catch {
181
+ img = null;
167
182
  }
183
+ if (!img) continue;
168
184
 
169
- if (!els.length) {
170
- return {ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: []};
185
+ // The referencing <img> must be eligible in the accessibility tree.
186
+ if (isAccTreeEligible) {
187
+ const imgElig = accEligibleCached(img);
188
+ if (imgElig && imgElig.eligible === false) continue;
171
189
  }
172
190
 
173
- const occurrences = [];
174
- let applicableCount = 0;
175
-
176
- for (const el of els) {
177
- if (!el || !el.getAttribute) continue;
178
-
179
- // Must belong to a *used* image map (referenced by an <img usemap>). If unused, not applicable.
180
- let img = null;
191
+ if (isAccTreeEligible) {
192
+ const elig = (() => {
181
193
  try {
182
- const map = el.closest && el.closest('map');
183
- const mapName = map ? getMapName(map) : '';
184
- img = mapName ? (__usemapIndex.get(mapName) || null) : null;
194
+ return isAccTreeEligible(el, ctx);
185
195
  } catch {
186
- img = null;
187
- }
188
- if (!img) continue;
189
-
190
- // The referencing <img> must be eligible in the accessibility tree.
191
- if (isAccTreeEligible) {
192
- const imgElig = accEligibleCached(img);
193
- if (imgElig && imgElig.eligible === false) continue;
196
+ return { eligible: true, reasons: [] };
194
197
  }
198
+ })();
199
+ if (elig && elig.eligible === false) continue;
200
+ }
195
201
 
196
- if (isAccTreeEligible) {
197
- const elig = (() => {
198
- try {
199
- return isAccTreeEligible(el, ctx);
200
- } catch {
201
- return {eligible: true, reasons: []};
202
- }
203
- })();
204
- if (elig && elig.eligible === false) continue;
205
- }
202
+ if (isRolePresentationExcluded(el)) continue;
206
203
 
207
- if (isRolePresentationExcluded(el)) continue;
208
-
209
- // Rule-specific applicability (only elements that already have a text alternative mechanism)
210
- let alt = null;
211
- try { alt = el.getAttribute('alt'); } catch { alt = null; }
212
- if (alt === null) continue;
213
- if (String(alt).trim() !== '') continue; // only alt=""
214
-
215
- applicableCount += 1;
216
-
217
- const eligInfo = getEligibilityInfo ? getEligibilityInfo(el, ctx, {targetSet: 'acc'}) : null;
218
-
219
- const baseOccurrence = {
220
- summary: 'Review whether <area> is decorative (alt="").',
221
- hint: 'Confirm the area does not convey information or function. If it is interactive or meaningful, provide meaningful alt text.',
222
- i18n: {
223
- summaryKey: 'area_altDecorative_summary_cantTell',
224
- hintKey: 'area_altDecorative_hint_cantTell',
225
- params: {element: (el.tagName || '').toLowerCase()}
226
- },
227
- data: {
228
- visibilityFilter: eligInfo || {targetSet: 'acc', accEligible: null, reasons: []},
229
- details: null
230
- }
231
- };
232
-
233
- if (helpers && typeof helpers.reportOccurrence === 'function') {
234
- occurrences.push(helpers.reportOccurrence(el, baseOccurrence));
235
- } else {
236
- occurrences.push({selector: '', html: '', ...baseOccurrence});
237
- }
204
+ // Rule-specific applicability (only elements that already have a text alternative mechanism)
205
+ let alt;
206
+ try {
207
+ alt = el.getAttribute('alt');
208
+ } catch {
209
+ alt = null;
238
210
  }
239
-
240
- if (applicableCount === 0) {
241
- return {ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: []};
211
+ if (alt === null) continue;
212
+ if (String(alt).trim() !== '') continue; // only alt=""
213
+
214
+ applicableCount += 1;
215
+
216
+ const eligInfo = getEligibilityInfo ? getEligibilityInfo(el, ctx, { targetSet: 'acc' }) : null;
217
+
218
+ const baseOccurrence = {
219
+ summary: 'Review whether <area> is decorative (alt="").',
220
+ hint: 'Confirm the area does not convey information or function. If it is interactive or meaningful, provide meaningful alt text.',
221
+ i18n: {
222
+ summaryKey: 'area_altDecorative_summary_cantTell',
223
+ hintKey: 'area_altDecorative_hint_cantTell',
224
+ params: { element: (el.tagName || '').toLowerCase() }
225
+ },
226
+ data: {
227
+ visibilityFilter: eligInfo || { targetSet: 'acc', accEligible: null, reasons: [] },
228
+ details: null
229
+ }
230
+ };
231
+
232
+ if (helpers && typeof helpers.reportOccurrence === 'function') {
233
+ occurrences.push(helpers.reportOccurrence(el, baseOccurrence));
234
+ } else {
235
+ occurrences.push({ selector: '', html: '', ...baseOccurrence });
242
236
  }
237
+ }
238
+
239
+ if (applicableCount === 0) {
240
+ return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
241
+ }
243
242
 
244
- return {ruleId: rule.ruleId, outcome: 'cantTell', severity: 'minor', occurrences};
243
+ return { ruleId: rule.ruleId, outcome: 'cantTell', severity: 'minor', occurrences };
245
244
  }
246
245
 
247
- module.exports = {id, meta, runInPage};
246
+ module.exports = { id, meta, runInPage };