@surea11y/core 1.4.0 → 1.5.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 (115) hide show
  1. package/CHANGELOG.md +88 -7
  2. package/README.md +19 -3
  3. package/bin/surea11y-core.js +0 -0
  4. package/docs/API_STABILITY.md +2 -2
  5. package/docs/ARIA_DEPRECATION.md +95 -0
  6. package/docs/ENGINE_OPTIONS.md +14 -10
  7. package/docs/I18N.md +176 -20
  8. package/docs/INTEGRATION.md +28 -6
  9. package/docs/LIMITATIONS.md +1 -1
  10. package/docs/OUTPUT_SCHEMA.md +13 -3
  11. package/docs/REPORT.md +2 -0
  12. package/docs/RULE_AUTHORING.md +53 -0
  13. package/docs/RULE_CATALOG.md +6 -6
  14. package/docs/TROUBLESHOOTING.md +2 -2
  15. package/package.json +8 -1
  16. package/src/checks/automatic/aria-allowed-attr.js +661 -99
  17. package/src/checks/automatic/aria-allowed-role.js +14 -16
  18. package/src/checks/automatic/aria-braille-equivalent.js +17 -19
  19. package/src/checks/automatic/aria-conditional-attr.js +17 -19
  20. package/src/checks/automatic/aria-deprecated-role.js +122 -41
  21. package/src/checks/automatic/aria-hidden-body.js +2 -9
  22. package/src/checks/automatic/aria-hidden-focus.js +99 -18
  23. package/src/checks/automatic/aria-prohibited-attr.js +54 -55
  24. package/src/checks/automatic/aria-prohibited-children.js +26 -26
  25. package/src/checks/automatic/aria-required-attr.js +14 -17
  26. package/src/checks/automatic/aria-required-children.js +17 -20
  27. package/src/checks/automatic/aria-required-parent.js +17 -20
  28. package/src/checks/automatic/aria-roles-valid.js +66 -27
  29. package/src/checks/automatic/aria-valid-attr-value.js +18 -21
  30. package/src/checks/automatic/aria-valid-attr.js +14 -17
  31. package/src/checks/automatic/autocomplete-valid.js +52 -18
  32. package/src/checks/automatic/avoid-inline-spacing.js +192 -34
  33. package/src/checks/automatic/binary-control-name-present.js +30 -24
  34. package/src/checks/automatic/button-name-present.js +73 -45
  35. package/src/checks/automatic/canvas-text-alternative-present.js +15 -8
  36. package/src/checks/automatic/combobox-name-present.js +23 -18
  37. package/src/checks/automatic/css-orientation-lock.js +22 -22
  38. package/src/checks/automatic/definition-list-children-valid.js +18 -21
  39. package/src/checks/automatic/deprecated-elements-not-used.js +14 -16
  40. package/src/checks/automatic/dialog-name-present.js +24 -19
  41. package/src/checks/automatic/dlitem-parent-valid.js +15 -17
  42. package/src/checks/automatic/duplicate-id-aria.js +45 -37
  43. package/src/checks/automatic/form-control-programmatic-label-present.js +48 -4
  44. package/src/checks/automatic/form-control-single-label.js +109 -43
  45. package/src/checks/automatic/html-xml-lang-mismatch.js +2 -9
  46. package/src/checks/automatic/iframe-focusable-content.js +29 -31
  47. package/src/checks/automatic/iframe-name-present.js +15 -17
  48. package/src/checks/automatic/iframe-title-unique.js +18 -23
  49. package/src/checks/automatic/img-alt-present.js +16 -9
  50. package/src/checks/automatic/input-image-alt-present.js +99 -48
  51. package/src/checks/automatic/label-in-name.js +102 -33
  52. package/src/checks/automatic/language-page-present.js +5 -1
  53. package/src/checks/automatic/link-in-text-block.js +19 -21
  54. package/src/checks/automatic/link-name-present.js +75 -47
  55. package/src/checks/automatic/list-children-valid.js +15 -17
  56. package/src/checks/automatic/listbox-name-present.js +23 -18
  57. package/src/checks/automatic/listitem-parent-valid.js +14 -17
  58. package/src/checks/automatic/menuitem-name-present.js +24 -19
  59. package/src/checks/automatic/meta-refresh-no-exceptions.js +44 -18
  60. package/src/checks/automatic/meta-refresh-timing-absent.js +44 -21
  61. package/src/checks/automatic/meta-viewport-zoom-enabled.js +51 -32
  62. package/src/checks/automatic/meter-name-present.js +24 -19
  63. package/src/checks/automatic/nested-interactive-controls-absent.js +179 -51
  64. package/src/checks/automatic/object-text-alternative-present.js +14 -7
  65. package/src/checks/automatic/option-name-present.js +24 -19
  66. package/src/checks/automatic/progressbar-name-present.js +27 -22
  67. package/src/checks/automatic/searchbox-name-present.js +27 -18
  68. package/src/checks/automatic/server-side-image-map-absent.js +15 -18
  69. package/src/checks/automatic/slider-name-present.js +26 -19
  70. package/src/checks/automatic/spinbutton-name-present.js +27 -18
  71. package/src/checks/automatic/summary-name-present.js +24 -19
  72. package/src/checks/automatic/tab-name-present.js +24 -19
  73. package/src/checks/automatic/table-headers-attr-valid.js +15 -17
  74. package/src/checks/automatic/table-th-has-data-cells.js +82 -25
  75. package/src/checks/automatic/target-size-minimum.js +104 -81
  76. package/src/checks/automatic/td-has-header.js +15 -20
  77. package/src/checks/automatic/textbox-name-present.js +23 -18
  78. package/src/checks/automatic/tooltip-name-present.js +24 -19
  79. package/src/checks/automatic/treeitem-name-present.js +24 -19
  80. package/src/checks/automatic/valid-lang.js +31 -20
  81. package/src/checks/manual/accesskeys-manual.js +18 -19
  82. package/src/checks/manual/aria-checked-state-mismatch-manual.js +19 -22
  83. package/src/checks/{automatic/bypass-blocks-present.js → manual/bypass-blocks-present-manual.js} +98 -44
  84. package/src/checks/manual/empty-heading-manual.js +15 -17
  85. package/src/checks/manual/empty-table-header-manual.js +27 -30
  86. package/src/checks/manual/form-control-programmatic-label-quality-manual.js +2 -9
  87. package/src/checks/manual/heading-order-manual.js +17 -22
  88. package/src/checks/manual/image-redundant-alt-manual.js +14 -17
  89. package/src/checks/manual/input-image-alt-decorative-manual.js +24 -0
  90. package/src/checks/manual/label-title-only-manual.js +15 -17
  91. package/src/checks/manual/landmark-banner-is-top-level-manual.js +25 -39
  92. package/src/checks/manual/landmark-contentinfo-is-top-level-manual.js +21 -27
  93. package/src/checks/manual/landmark-main-is-top-level-manual.js +14 -17
  94. package/src/checks/manual/landmark-no-duplicate-banner-manual.js +2 -7
  95. package/src/checks/manual/landmark-no-duplicate-contentinfo-manual.js +2 -7
  96. package/src/checks/manual/landmark-no-duplicate-main-manual.js +2 -7
  97. package/src/checks/manual/landmark-one-main-manual.js +2 -9
  98. package/src/checks/manual/landmark-unique-manual.js +22 -27
  99. package/src/checks/manual/link-name-quality-manual.js +15 -17
  100. package/src/checks/manual/meta-viewport-large-manual.js +14 -17
  101. package/src/checks/manual/mouse-only-event-handlers-manual.js +17 -19
  102. package/src/checks/manual/page-has-heading-one-manual.js +2 -9
  103. package/src/checks/manual/presentation-role-conflict-manual.js +19 -21
  104. package/src/checks/manual/region-manual.js +13 -6
  105. package/src/checks/manual/scope-attr-valid-manual.js +14 -17
  106. package/src/checks/manual/skip-link-manual.js +39 -47
  107. package/src/checks/manual/tabindex-manual.js +14 -17
  108. package/src/checks/manual/table-duplicate-name-manual.js +14 -17
  109. package/src/core.js +8818 -4330
  110. package/src/report.js +14 -0
  111. package/src/sarif.js +2 -2
  112. package/surea11y.browser.js +4023 -3911
  113. package/surea11y.i18n.de.js +22 -0
  114. package/surea11y.i18n.es.js +22 -0
  115. package/surea11y.i18n.fr.js +22 -0
@@ -215,31 +215,29 @@ function runInPage(ctx) {
215
215
  const eligInfo = helpers.getEligibilityInfo
216
216
  ? helpers.getEligibilityInfo(el, ctx, { targetSet: 'acc' })
217
217
  : null;
218
- const stableSelector = helpers.buildSelector ? helpers.buildSelector(el) : 'html';
219
- const html = helpers.getOuterHtmlSnippet ? helpers.getOuterHtmlSnippet(el) : el.outerHTML || '';
220
218
  const tag = (el.tagName || '').toLowerCase();
221
219
  const ratioStr = c.round2 ? c.round2(ratio) : String(ratio);
222
220
 
223
- occurrences.push({
224
- selector: stableSelector,
225
- html,
226
- summary:
227
- 'This link in a block of text relies on color alone to be distinguished from the surrounding text.',
228
- hint: 'Add an underline, a font-weight/style difference, or increase the color contrast between the link and surrounding text to at least 3:1.',
229
- i18n: {
230
- summaryKey: 'linkInTextBlock_summary_fail',
231
- hintKey: 'linkInTextBlock_hint_fail',
232
- params: { element: tag, ratio: String(ratioStr), threshold: '3' }
233
- },
234
- data: {
235
- visibilityFilter: eligInfo || { targetSet: 'acc', accEligible: null, reasons: [] },
236
- details: {
237
- reasonCode: 'COLOR_ONLY_DIFFERENTIATION',
238
- metrics: { ratio, threshold: 3 },
239
- colors: { linkForegroundHex: fgLinkHex, surroundingTextForegroundHex: fgParentHex }
221
+ occurrences.push(
222
+ helpers.reportOccurrence(el, {
223
+ summary:
224
+ 'This link in a block of text relies on color alone to be distinguished from the surrounding text.',
225
+ hint: 'Add an underline, a font-weight/style difference, or increase the color contrast between the link and surrounding text to at least 3:1.',
226
+ i18n: {
227
+ summaryKey: 'linkInTextBlock_summary_fail',
228
+ hintKey: 'linkInTextBlock_hint_fail',
229
+ params: { element: tag, ratio: String(ratioStr), threshold: '3' }
230
+ },
231
+ data: {
232
+ visibilityFilter: eligInfo || { targetSet: 'acc', accEligible: null, reasons: [] },
233
+ details: {
234
+ reasonCode: 'COLOR_ONLY_DIFFERENTIATION',
235
+ metrics: { ratio, threshold: 3 },
236
+ colors: { linkForegroundHex: fgLinkHex, surroundingTextForegroundHex: fgParentHex }
237
+ }
240
238
  }
241
- }
242
- });
239
+ })
240
+ );
243
241
  }
244
242
 
245
243
  if (applicableCount === 0) {
@@ -59,7 +59,14 @@ function runInPage(ctx) {
59
59
 
60
60
  for (const el of nodes) {
61
61
  // isAccTreeEligible returns { eligible, reasons }, not a boolean.
62
- const eligResult = helpers.isAccTreeEligible ? helpers.isAccTreeEligible(el, ctx) : true;
62
+ // Naming rules apply only to elements included in the accessibility tree
63
+ // (ACT c487ae), which excludes focusable aria-hidden content;
64
+ // aria-hidden-focus (ACT 6cfa84) covers that markup instead.
65
+ const eligResult = helpers.isIncludedInAccessibilityTree
66
+ ? helpers.isIncludedInAccessibilityTree(el, ctx)
67
+ : helpers.isAccTreeEligible
68
+ ? helpers.isAccTreeEligible(el, ctx)
69
+ : true;
63
70
  const eligible =
64
71
  typeof eligResult === 'boolean' ? eligResult : !!(eligResult && eligResult.eligible);
65
72
  if (!eligible) continue;
@@ -69,33 +76,59 @@ function runInPage(ctx) {
69
76
  const nameInfo = helpers.getAccessibleNameInfo ? helpers.getAccessibleNameInfo(el, ctx) : null;
70
77
  const programmaticName = nameInfo && typeof nameInfo.value === 'string' ? nameInfo.value : '';
71
78
 
72
- // A native <a href>/<area href> (or [role="link"]) whose role has been
73
- // overridden to one of these roles is no longer semantically a link —
74
- // per the WAI-ARIA Accessible Name and Description Computation spec
75
- // these roles are name-from-author-only, and their rendered content
76
- // represents a VALUE, not a NAME (see button-name-present.js's identical
77
- // fix for a real-world example — <button role="combobox">List</button> —
78
- // of the same class of bug on a different host tag).
79
79
  const role = el.getAttribute ? el.getAttribute('role') : null;
80
80
  const roleNorm = String(role || '')
81
81
  .replace(/\s+/g, ' ')
82
82
  .trim()
83
83
  .toLowerCase();
84
- const VALUE_ROLES = [
85
- 'textbox',
86
- 'progressbar',
87
- 'scrollbar',
88
- 'slider',
89
- 'spinbutton',
90
- 'combobox',
91
- 'listbox'
84
+ // ARIA 1.2 "Name From: author, contents". Every other known role is
85
+ // name-from-author-only. An unknown role falls back to the implicit role.
86
+ // <generated:aria-name-from-content>
87
+ const NAME_FROM_CONTENT_ROLES = [
88
+ 'button',
89
+ 'cell',
90
+ 'checkbox',
91
+ 'columnheader',
92
+ 'doc-backlink',
93
+ 'doc-biblioref',
94
+ 'doc-glossref',
95
+ 'doc-noteref',
96
+ 'graphics-object',
97
+ 'gridcell',
98
+ 'heading',
99
+ 'link',
100
+ 'menuitem',
101
+ 'menuitemcheckbox',
102
+ 'menuitemradio',
103
+ 'option',
104
+ 'radio',
105
+ 'row',
106
+ 'rowgroup',
107
+ 'rowheader',
108
+ 'switch',
109
+ 'tab',
110
+ 'tooltip',
111
+ 'treeitem'
92
112
  ];
93
- const isContentNameCandidate = !VALUE_ROLES.includes(roleNorm);
113
+ // </generated:aria-name-from-content>
114
+ const isKnownRoleToken =
115
+ helpers && helpers.aria && typeof helpers.aria.isKnownRole === 'function'
116
+ ? (() => {
117
+ try {
118
+ return !!helpers.aria.isKnownRole(roleNorm);
119
+ } catch {
120
+ return false;
121
+ }
122
+ })()
123
+ : false;
124
+ const isContentNameCandidate =
125
+ !roleNorm || !isKnownRoleToken || NAME_FROM_CONTENT_ROLES.includes(roleNorm);
94
126
 
95
127
  const contentName =
96
128
  programmaticName.trim().length === 0 && isContentNameCandidate
97
129
  ? getConservativeSubtreeText(el)
98
130
  : '';
131
+
99
132
  const finalName = (programmaticName.trim().length ? programmaticName : contentName).trim();
100
133
 
101
134
  if (finalName.length === 0) {
@@ -106,39 +139,34 @@ function runInPage(ctx) {
106
139
  ? helpers.getEligibilityInfo(el, ctx, { targetSet: 'acc' })
107
140
  : null;
108
141
 
109
- const stableSelector = helpers.buildSelector ? helpers.buildSelector(el) : 'html';
110
- const html = helpers.getOuterHtmlSnippet
111
- ? helpers.getOuterHtmlSnippet(el)
112
- : el.outerHTML || '';
113
142
  const tag = (el.tagName || '').toLowerCase();
114
143
 
115
- occurrences.push({
116
- selector: stableSelector,
117
- html,
118
-
119
- // Human fallbacks (allowed)
120
- summary: 'This link has no accessible name.',
121
- hint: 'Provide link text or an accessible-name mechanism (for example aria-label) so assistive technologies can identify the link.',
122
-
123
- // Validator requires these keys to exist in the English dictionary
124
- i18n: {
125
- summaryKey: 'linkNamePresent_summary_fail',
126
- hintKey: 'linkNamePresent_hint_fail',
127
- params: { element: tag }
128
- },
129
-
130
- data: {
131
- visibilityFilter: eligInfo || { targetSet: 'acc', accEligible: null, reasons: [] },
132
- details: {
133
- reasonCode: 'name_missing',
134
- metrics: {
135
- programmaticNameLength: programmaticName.trim().length,
136
- contentNameLength: contentName.trim().length
137
- },
138
- refs: { accessibleName: nameInfo || null }
144
+ occurrences.push(
145
+ helpers.reportOccurrence(el, {
146
+ // Human fallbacks (allowed)
147
+ summary: 'This link has no accessible name.',
148
+ hint: 'Provide link text or an accessible-name mechanism (for example aria-label) so assistive technologies can identify the link.',
149
+
150
+ // Validator requires these keys to exist in the English dictionary
151
+ i18n: {
152
+ summaryKey: 'linkNamePresent_summary_fail',
153
+ hintKey: 'linkNamePresent_hint_fail',
154
+ params: { element: tag }
155
+ },
156
+
157
+ data: {
158
+ visibilityFilter: eligInfo || { targetSet: 'acc', accEligible: null, reasons: [] },
159
+ details: {
160
+ reasonCode: 'name_missing',
161
+ metrics: {
162
+ programmaticNameLength: programmaticName.trim().length,
163
+ contentNameLength: contentName.trim().length
164
+ },
165
+ refs: { accessibleName: nameInfo || null }
166
+ }
139
167
  }
140
- }
141
- });
168
+ })
169
+ );
142
170
  }
143
171
  }
144
172
 
@@ -122,23 +122,21 @@ function runInPage(ctx) {
122
122
  const dedupedInvalidTags = [...new Set(invalidTags)];
123
123
 
124
124
  const tag = el.tagName.toLowerCase();
125
- const stableSelector = helpers.buildSelector ? helpers.buildSelector(el) : 'html';
126
- const html = helpers.getOuterHtmlSnippet ? helpers.getOuterHtmlSnippet(el) : el.outerHTML || '';
127
-
128
- occurrences.push({
129
- selector: stableSelector,
130
- html,
131
- summary: 'This list contains a direct child that is not a list item.',
132
- hint: 'Only use <li> (or <script>/<template>) as direct children of <ul>/<ol>; move other markup inside an <li>.',
133
- i18n: {
134
- summaryKey: 'listChildrenValid_summary_fail',
135
- hintKey: 'listChildrenValid_hint_fail',
136
- params: { element: tag, invalidChildren: dedupedInvalidTags.join(', ') }
137
- },
138
- data: {
139
- details: { reasonCode: 'LIST_INVALID_CHILD', element: tag, invalidChildren: invalidTags }
140
- }
141
- });
125
+
126
+ occurrences.push(
127
+ helpers.reportOccurrence(el, {
128
+ summary: 'This list contains a direct child that is not a list item.',
129
+ hint: 'Only use <li> (or <script>/<template>) as direct children of <ul>/<ol>; move other markup inside an <li>.',
130
+ i18n: {
131
+ summaryKey: 'listChildrenValid_summary_fail',
132
+ hintKey: 'listChildrenValid_hint_fail',
133
+ params: { element: tag, invalidChildren: dedupedInvalidTags.join(', ') }
134
+ },
135
+ data: {
136
+ details: { reasonCode: 'LIST_INVALID_CHILD', element: tag, invalidChildren: invalidTags }
137
+ }
138
+ })
139
+ );
142
140
  }
143
141
 
144
142
  if (applicableCount === 0) {
@@ -109,9 +109,16 @@ function runInPage(ctx) {
109
109
  return '';
110
110
  }
111
111
 
112
+ // Naming rules apply only to elements included in the accessibility tree
113
+ // (ACT c487ae and siblings), which excludes focusable aria-hidden content.
114
+ // aria-hidden-focus (ACT 6cfa84) covers that markup instead.
112
115
  function isEligibleAcc(helpers, el, ctx) {
113
116
  const fn =
114
- helpers && typeof helpers.isAccTreeEligible === 'function' ? helpers.isAccTreeEligible : null;
117
+ helpers && typeof helpers.isIncludedInAccessibilityTree === 'function'
118
+ ? helpers.isIncludedInAccessibilityTree
119
+ : helpers && typeof helpers.isAccTreeEligible === 'function'
120
+ ? helpers.isAccTreeEligible
121
+ : null;
115
122
  if (!fn) return true;
116
123
  try {
117
124
  const r = fn(el, ctx);
@@ -220,24 +227,22 @@ function runInPage(ctx) {
220
227
  }
221
228
  })()
222
229
  : null;
223
- const stableSelector = helpers.buildSelector ? helpers.buildSelector(el) : 'html';
224
- const html = helpers.getOuterHtmlSnippet ? helpers.getOuterHtmlSnippet(el) : el.outerHTML || '';
225
230
 
226
- occurrences.push({
227
- selector: stableSelector,
228
- html,
229
- summary: 'This element has no accessible name.',
230
- hint: "Provide aria-label, aria-labelledby, or a title attribute — visible text content is not exposed as this listbox's accessible name.",
231
- i18n: {
232
- summaryKey: 'listboxNamePresent_summary_fail',
233
- hintKey: 'listboxNamePresent_hint_fail',
234
- params: { controlType: 'listbox' }
235
- },
236
- data: {
237
- details: { reasonCode: 'name_missing', controlType: 'listbox', methodTried: res.method },
238
- visibilityFilter: eligInfo || { targetSet: 'acc', accEligible: null, reasons: [] }
239
- }
240
- });
231
+ occurrences.push(
232
+ helpers.reportOccurrence(el, {
233
+ summary: 'This element has no accessible name.',
234
+ hint: "Provide aria-label, aria-labelledby, or a title attribute — visible text content is not exposed as this listbox's accessible name.",
235
+ i18n: {
236
+ summaryKey: 'listboxNamePresent_summary_fail',
237
+ hintKey: 'listboxNamePresent_hint_fail',
238
+ params: { controlType: 'listbox' }
239
+ },
240
+ data: {
241
+ details: { reasonCode: 'name_missing', controlType: 'listbox', methodTried: res.method },
242
+ visibilityFilter: eligInfo || { targetSet: 'acc', accEligible: null, reasons: [] }
243
+ }
244
+ })
245
+ );
241
246
  }
242
247
 
243
248
  if (applicableCount === 0) {
@@ -116,23 +116,20 @@ function runInPage(ctx) {
116
116
 
117
117
  if (valid) continue;
118
118
 
119
- const stableSelector = helpers.buildSelector ? helpers.buildSelector(el) : 'html';
120
- const html = helpers.getOuterHtmlSnippet ? helpers.getOuterHtmlSnippet(el) : el.outerHTML || '';
121
-
122
- occurrences.push({
123
- selector: stableSelector,
124
- html,
125
- summary: 'This list item is not contained by a list container.',
126
- hint: 'Place this <li> inside a <ul>/<ol>, or give its parent role="list".',
127
- i18n: {
128
- summaryKey: 'listitemParentValid_summary_fail',
129
- hintKey: 'listitemParentValid_hint_fail',
130
- params: { parentElement: parentTag }
131
- },
132
- data: {
133
- details: { reasonCode: 'LISTITEM_INVALID_PARENT', parentElement: parentTag }
134
- }
135
- });
119
+ occurrences.push(
120
+ helpers.reportOccurrence(el, {
121
+ summary: 'This list item is not contained by a list container.',
122
+ hint: 'Place this <li> inside a <ul>/<ol>, or give its parent role="list".',
123
+ i18n: {
124
+ summaryKey: 'listitemParentValid_summary_fail',
125
+ hintKey: 'listitemParentValid_hint_fail',
126
+ params: { parentElement: parentTag }
127
+ },
128
+ data: {
129
+ details: { reasonCode: 'LISTITEM_INVALID_PARENT', parentElement: parentTag }
130
+ }
131
+ })
132
+ );
136
133
  }
137
134
 
138
135
  if (applicableCount === 0) {
@@ -84,9 +84,16 @@ function runInPage(ctx) {
84
84
  return '';
85
85
  }
86
86
 
87
+ // Naming rules apply only to elements included in the accessibility tree
88
+ // (ACT c487ae and siblings), which excludes focusable aria-hidden content.
89
+ // aria-hidden-focus (ACT 6cfa84) covers that markup instead.
87
90
  function isEligibleAcc(helpers, el, ctx) {
88
91
  const fn =
89
- helpers && typeof helpers.isAccTreeEligible === 'function' ? helpers.isAccTreeEligible : null;
92
+ helpers && typeof helpers.isIncludedInAccessibilityTree === 'function'
93
+ ? helpers.isIncludedInAccessibilityTree
94
+ : helpers && typeof helpers.isAccTreeEligible === 'function'
95
+ ? helpers.isAccTreeEligible
96
+ : null;
90
97
  if (!fn) return true;
91
98
  try {
92
99
  const r = fn(el, ctx);
@@ -143,24 +150,22 @@ function runInPage(ctx) {
143
150
  }
144
151
  })()
145
152
  : null;
146
- const stableSelector = helpers.buildSelector ? helpers.buildSelector(el) : 'html';
147
- const html = helpers.getOuterHtmlSnippet ? helpers.getOuterHtmlSnippet(el) : el.outerHTML || '';
148
-
149
- occurrences.push({
150
- selector: stableSelector,
151
- html,
152
- summary: 'This menu item has no accessible name.',
153
- hint: 'Provide visible text that is not hidden from assistive technologies, or provide aria-label or aria-labelledby.',
154
- i18n: {
155
- summaryKey: 'menuitemNamePresent_summary_fail',
156
- hintKey: 'menuitemNamePresent_hint_fail',
157
- params: { role }
158
- },
159
- data: {
160
- details: { reasonCode: 'name_missing', controlType: role, methodTried: res.method },
161
- visibilityFilter: eligInfo || { targetSet: 'acc', accEligible: null, reasons: [] }
162
- }
163
- });
153
+
154
+ occurrences.push(
155
+ helpers.reportOccurrence(el, {
156
+ summary: 'This menu item has no accessible name.',
157
+ hint: 'Provide visible text that is not hidden from assistive technologies, or provide aria-label or aria-labelledby.',
158
+ i18n: {
159
+ summaryKey: 'menuitemNamePresent_summary_fail',
160
+ hintKey: 'menuitemNamePresent_hint_fail',
161
+ params: { role }
162
+ },
163
+ data: {
164
+ details: { reasonCode: 'name_missing', controlType: role, methodTried: res.method },
165
+ visibilityFilter: eligInfo || { targetSet: 'acc', accEligible: null, reasons: [] }
166
+ }
167
+ })
168
+ );
164
169
  }
165
170
 
166
171
  if (applicableCount === 0) {
@@ -87,6 +87,34 @@ function runInPage(ctx) {
87
87
  : [];
88
88
 
89
89
  const occurrences = [];
90
+ // HTML's shared declarative refresh steps. Returns the delay in seconds, or
91
+ // null when the value is not a valid refresh directive, in which case the
92
+ // browser never refreshes and there is nothing to report. Rejects a leading
93
+ // sign ("+72001"), a non-numeric time ("foo"), and a separator other than
94
+ // "," or ";" ("0:1").
95
+ function parseRefreshDelay(input) {
96
+ const s = String(input == null ? '' : input);
97
+ const isSpace = (c) => c === ' ' || c === '\t' || c === '\n' || c === '\f' || c === '\r';
98
+ let i = 0;
99
+ while (i < s.length && isSpace(s[i])) i++;
100
+ let digits = '';
101
+ while (i < s.length && s[i] >= '0' && s[i] <= '9') digits += s[i++];
102
+ if (!digits) return null;
103
+ if (s[i] === '.') {
104
+ i++;
105
+ while (i < s.length && s[i] >= '0' && s[i] <= '9') i++;
106
+ }
107
+ if (i >= s.length) return parseInt(digits, 10);
108
+ let sawSpace = false;
109
+ while (i < s.length && isSpace(s[i])) {
110
+ sawSpace = true;
111
+ i++;
112
+ }
113
+ if (i >= s.length) return parseInt(digits, 10);
114
+ if (s[i] === ';' || s[i] === ',' || sawSpace) return parseInt(digits, 10);
115
+ return null;
116
+ }
117
+
90
118
  let applicableCount = 0;
91
119
 
92
120
  for (const el of nodes) {
@@ -94,27 +122,25 @@ function runInPage(ctx) {
94
122
  if (el.closest && el.closest('noscript')) continue; // never applies with scripting enabled
95
123
  const raw = String(el.getAttribute('content') || '').trim();
96
124
  if (!raw) continue;
125
+ if (parseRefreshDelay(raw) === null) continue;
97
126
 
98
127
  applicableCount += 1;
99
128
 
100
- const stableSelector = helpers.buildSelector ? helpers.buildSelector(el) : 'html';
101
- const html = helpers.getOuterHtmlSnippet ? helpers.getOuterHtmlSnippet(el) : el.outerHTML || '';
102
-
103
- occurrences.push({
104
- selector: stableSelector,
105
- html,
106
- summary:
107
- 'This page uses a meta refresh, which is an automatic context change not initiated by the user.',
108
- hint: 'Remove the meta refresh; trigger the redirect/refresh only in response to a user action instead.',
109
- i18n: {
110
- summaryKey: 'metaRefreshNoExceptions_summary_fail',
111
- hintKey: 'metaRefreshNoExceptions_hint_fail',
112
- params: {}
113
- },
114
- data: {
115
- details: { reasonCode: 'META_REFRESH_PRESENT' }
116
- }
117
- });
129
+ occurrences.push(
130
+ helpers.reportOccurrence(el, {
131
+ summary:
132
+ 'This page uses a meta refresh, which is an automatic context change not initiated by the user.',
133
+ hint: 'Remove the meta refresh; trigger the redirect/refresh only in response to a user action instead.',
134
+ i18n: {
135
+ summaryKey: 'metaRefreshNoExceptions_summary_fail',
136
+ hintKey: 'metaRefreshNoExceptions_hint_fail',
137
+ params: {}
138
+ },
139
+ data: {
140
+ details: { reasonCode: 'META_REFRESH_PRESENT' }
141
+ }
142
+ })
143
+ );
118
144
  }
119
145
 
120
146
  if (applicableCount === 0) {
@@ -79,39 +79,62 @@ function runInPage(ctx) {
79
79
  const occurrences = [];
80
80
  let applicableCount = 0;
81
81
 
82
+ // HTML's shared declarative refresh steps. Returns the delay in seconds, or
83
+ // null when the value is not a valid refresh directive, in which case the
84
+ // browser never refreshes and there is nothing to report. Rejects a leading
85
+ // sign ("+72001"), a non-numeric time ("foo"), and a separator other than
86
+ // "," or ";" ("0:1").
87
+ function parseRefreshDelay(input) {
88
+ const s = String(input == null ? '' : input);
89
+ const isSpace = (c) => c === ' ' || c === '\t' || c === '\n' || c === '\f' || c === '\r';
90
+ let i = 0;
91
+ while (i < s.length && isSpace(s[i])) i++;
92
+ let digits = '';
93
+ while (i < s.length && s[i] >= '0' && s[i] <= '9') digits += s[i++];
94
+ if (!digits) return null;
95
+ if (s[i] === '.') {
96
+ i++;
97
+ while (i < s.length && s[i] >= '0' && s[i] <= '9') i++;
98
+ }
99
+ if (i >= s.length) return parseInt(digits, 10);
100
+ let sawSpace = false;
101
+ while (i < s.length && isSpace(s[i])) {
102
+ sawSpace = true;
103
+ i++;
104
+ }
105
+ if (i >= s.length) return parseInt(digits, 10);
106
+ if (s[i] === ';' || s[i] === ',' || sawSpace) return parseInt(digits, 10);
107
+ return null;
108
+ }
109
+
82
110
  for (const el of nodes) {
83
111
  if (!el || !el.getAttribute) continue;
84
112
  if (el.closest && el.closest('noscript')) continue; // never applies with scripting enabled — see meta-refresh-no-exceptions.js's header comment
85
113
  const raw = String(el.getAttribute('content') || '').trim();
86
114
  if (!raw) continue;
87
115
 
88
- const match = raw.match(/^([0-9]*\.?[0-9]+)/);
89
- if (!match) continue;
90
- const delay = parseFloat(match[1]);
91
- if (Number.isNaN(delay)) continue;
116
+ const delay = parseRefreshDelay(raw);
117
+ if (delay === null) continue;
92
118
 
93
119
  applicableCount += 1;
94
120
 
95
121
  if (!(delay > 0)) continue;
96
122
  if (delay > EXEMPT_DELAY_SECONDS) continue; // WCAG 2.2.1 Exception 3 (>20 hours)
97
123
 
98
- const stableSelector = helpers.buildSelector ? helpers.buildSelector(el) : 'html';
99
- const html = helpers.getOuterHtmlSnippet ? helpers.getOuterHtmlSnippet(el) : el.outerHTML || '';
100
-
101
- occurrences.push({
102
- selector: stableSelector,
103
- html,
104
- summary: 'This page refreshes itself automatically after a delay.',
105
- hint: 'Remove the timed meta refresh, or provide a way for users to turn it off, extend it, or pause it before it triggers.',
106
- i18n: {
107
- summaryKey: 'metaRefreshTimingAbsent_summary_fail',
108
- hintKey: 'metaRefreshTimingAbsent_hint_fail',
109
- params: { delay: String(delay) }
110
- },
111
- data: {
112
- details: { reasonCode: 'META_REFRESH_DELAYED', delay }
113
- }
114
- });
124
+ occurrences.push(
125
+ helpers.reportOccurrence(el, {
126
+ summary: 'This page refreshes itself automatically after a delay.',
127
+ hint: 'Remove the timed meta refresh, or provide a way for users to turn it off, extend it, or pause it before it triggers.',
128
+ i18n: {
129
+ summaryKey: 'metaRefreshTimingAbsent_summary_fail',
130
+ hintKey: 'metaRefreshTimingAbsent_hint_fail',
131
+ params: { delay: String(delay) }
132
+ },
133
+ data: {
134
+ details: { reasonCode: 'META_REFRESH_DELAYED', delay }
135
+ }
136
+ })
137
+ );
115
138
  }
116
139
 
117
140
  if (applicableCount === 0) {