@surea11y/core 1.4.1 → 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 (104) hide show
  1. package/CHANGELOG.md +47 -7
  2. package/README.md +19 -3
  3. package/docs/API_STABILITY.md +2 -2
  4. package/docs/ENGINE_OPTIONS.md +14 -10
  5. package/docs/I18N.md +176 -20
  6. package/docs/INTEGRATION.md +28 -6
  7. package/docs/LIMITATIONS.md +1 -1
  8. package/docs/OUTPUT_SCHEMA.md +13 -3
  9. package/docs/REPORT.md +2 -0
  10. package/docs/RULE_AUTHORING.md +53 -0
  11. package/docs/TROUBLESHOOTING.md +2 -2
  12. package/package.json +6 -1
  13. package/src/checks/automatic/aria-allowed-attr.js +27 -30
  14. package/src/checks/automatic/aria-allowed-role.js +14 -16
  15. package/src/checks/automatic/aria-braille-equivalent.js +17 -19
  16. package/src/checks/automatic/aria-conditional-attr.js +17 -19
  17. package/src/checks/automatic/aria-deprecated-role.js +62 -49
  18. package/src/checks/automatic/aria-hidden-body.js +2 -9
  19. package/src/checks/automatic/aria-hidden-focus.js +99 -18
  20. package/src/checks/automatic/aria-prohibited-attr.js +54 -55
  21. package/src/checks/automatic/aria-prohibited-children.js +26 -26
  22. package/src/checks/automatic/aria-required-attr.js +14 -17
  23. package/src/checks/automatic/aria-required-children.js +17 -20
  24. package/src/checks/automatic/aria-required-parent.js +17 -20
  25. package/src/checks/automatic/aria-roles-valid.js +37 -23
  26. package/src/checks/automatic/aria-valid-attr-value.js +18 -21
  27. package/src/checks/automatic/aria-valid-attr.js +14 -17
  28. package/src/checks/automatic/autocomplete-valid.js +15 -17
  29. package/src/checks/automatic/avoid-inline-spacing.js +14 -16
  30. package/src/checks/automatic/binary-control-name-present.js +19 -21
  31. package/src/checks/automatic/button-name-present.js +23 -28
  32. package/src/checks/automatic/combobox-name-present.js +15 -17
  33. package/src/checks/automatic/css-orientation-lock.js +22 -22
  34. package/src/checks/automatic/definition-list-children-valid.js +18 -21
  35. package/src/checks/automatic/deprecated-elements-not-used.js +14 -16
  36. package/src/checks/automatic/dialog-name-present.js +16 -18
  37. package/src/checks/automatic/dlitem-parent-valid.js +15 -17
  38. package/src/checks/automatic/duplicate-id-aria.js +45 -37
  39. package/src/checks/automatic/form-control-single-label.js +38 -40
  40. package/src/checks/automatic/html-xml-lang-mismatch.js +2 -9
  41. package/src/checks/automatic/iframe-focusable-content.js +29 -31
  42. package/src/checks/automatic/iframe-name-present.js +15 -17
  43. package/src/checks/automatic/iframe-title-unique.js +18 -23
  44. package/src/checks/automatic/label-in-name.js +31 -36
  45. package/src/checks/automatic/link-in-text-block.js +19 -21
  46. package/src/checks/automatic/link-name-present.js +25 -30
  47. package/src/checks/automatic/list-children-valid.js +15 -17
  48. package/src/checks/automatic/listbox-name-present.js +15 -17
  49. package/src/checks/automatic/listitem-parent-valid.js +14 -17
  50. package/src/checks/automatic/menuitem-name-present.js +16 -18
  51. package/src/checks/automatic/meta-refresh-no-exceptions.js +15 -18
  52. package/src/checks/automatic/meta-refresh-timing-absent.js +14 -17
  53. package/src/checks/automatic/meta-viewport-zoom-enabled.js +14 -17
  54. package/src/checks/automatic/meter-name-present.js +16 -18
  55. package/src/checks/automatic/nested-interactive-controls-absent.js +17 -19
  56. package/src/checks/automatic/option-name-present.js +16 -18
  57. package/src/checks/automatic/progressbar-name-present.js +19 -21
  58. package/src/checks/automatic/searchbox-name-present.js +19 -17
  59. package/src/checks/automatic/server-side-image-map-absent.js +15 -18
  60. package/src/checks/automatic/slider-name-present.js +15 -17
  61. package/src/checks/automatic/spinbutton-name-present.js +19 -17
  62. package/src/checks/automatic/summary-name-present.js +16 -18
  63. package/src/checks/automatic/tab-name-present.js +16 -18
  64. package/src/checks/automatic/table-headers-attr-valid.js +15 -17
  65. package/src/checks/automatic/table-th-has-data-cells.js +15 -19
  66. package/src/checks/automatic/target-size-minimum.js +104 -81
  67. package/src/checks/automatic/td-has-header.js +15 -20
  68. package/src/checks/automatic/textbox-name-present.js +15 -17
  69. package/src/checks/automatic/tooltip-name-present.js +16 -18
  70. package/src/checks/automatic/treeitem-name-present.js +16 -18
  71. package/src/checks/automatic/valid-lang.js +15 -17
  72. package/src/checks/manual/accesskeys-manual.js +18 -19
  73. package/src/checks/manual/aria-checked-state-mismatch-manual.js +19 -22
  74. package/src/checks/manual/bypass-blocks-present-manual.js +4 -12
  75. package/src/checks/manual/empty-heading-manual.js +15 -17
  76. package/src/checks/manual/empty-table-header-manual.js +27 -30
  77. package/src/checks/manual/form-control-programmatic-label-quality-manual.js +2 -9
  78. package/src/checks/manual/heading-order-manual.js +17 -22
  79. package/src/checks/manual/image-redundant-alt-manual.js +14 -17
  80. package/src/checks/manual/label-title-only-manual.js +15 -17
  81. package/src/checks/manual/landmark-banner-is-top-level-manual.js +14 -17
  82. package/src/checks/manual/landmark-contentinfo-is-top-level-manual.js +14 -17
  83. package/src/checks/manual/landmark-main-is-top-level-manual.js +14 -17
  84. package/src/checks/manual/landmark-no-duplicate-banner-manual.js +2 -7
  85. package/src/checks/manual/landmark-no-duplicate-contentinfo-manual.js +2 -7
  86. package/src/checks/manual/landmark-no-duplicate-main-manual.js +2 -7
  87. package/src/checks/manual/landmark-one-main-manual.js +2 -9
  88. package/src/checks/manual/landmark-unique-manual.js +22 -27
  89. package/src/checks/manual/link-name-quality-manual.js +15 -17
  90. package/src/checks/manual/meta-viewport-large-manual.js +14 -17
  91. package/src/checks/manual/mouse-only-event-handlers-manual.js +17 -19
  92. package/src/checks/manual/page-has-heading-one-manual.js +2 -9
  93. package/src/checks/manual/presentation-role-conflict-manual.js +19 -21
  94. package/src/checks/manual/region-manual.js +13 -6
  95. package/src/checks/manual/scope-attr-valid-manual.js +14 -17
  96. package/src/checks/manual/skip-link-manual.js +39 -47
  97. package/src/checks/manual/tabindex-manual.js +14 -17
  98. package/src/checks/manual/table-duplicate-name-manual.js +14 -17
  99. package/src/core.js +4203 -3604
  100. package/src/report.js +14 -0
  101. package/surea11y.browser.js +1960 -3671
  102. package/surea11y.i18n.de.js +22 -0
  103. package/surea11y.i18n.es.js +22 -0
  104. package/surea11y.i18n.fr.js +22 -0
@@ -148,8 +148,6 @@ function runInPage(ctx) {
148
148
  const name = getAccessibleNameText(el);
149
149
  if (name) continue;
150
150
 
151
- const stableSelector = helpers.buildSelector ? helpers.buildSelector(el) : 'html';
152
- const html = helpers.getOuterHtmlSnippet ? helpers.getOuterHtmlSnippet(el) : el.outerHTML || '';
153
151
  const eligInfo = helpers.getEligibilityInfo
154
152
  ? (() => {
155
153
  try {
@@ -160,21 +158,21 @@ function runInPage(ctx) {
160
158
  })()
161
159
  : null;
162
160
 
163
- occurrences.push({
164
- selector: stableSelector,
165
- html,
166
- summary: 'This heading has no accessible name.',
167
- hint: 'Add text content (or aria-label/aria-labelledby) to this heading, or remove it if it is not needed.',
168
- i18n: {
169
- summaryKey: 'emptyHeading_summary_cantTell',
170
- hintKey: 'emptyHeading_hint_cantTell',
171
- params: {}
172
- },
173
- data: {
174
- details: { reasonCode: 'HEADING_EMPTY' },
175
- visibilityFilter: eligInfo || { targetSet: 'acc', accEligible: null, reasons: [] }
176
- }
177
- });
161
+ occurrences.push(
162
+ helpers.reportOccurrence(el, {
163
+ summary: 'This heading has no accessible name.',
164
+ hint: 'Add text content (or aria-label/aria-labelledby) to this heading, or remove it if it is not needed.',
165
+ i18n: {
166
+ summaryKey: 'emptyHeading_summary_cantTell',
167
+ hintKey: 'emptyHeading_hint_cantTell',
168
+ params: {}
169
+ },
170
+ data: {
171
+ details: { reasonCode: 'HEADING_EMPTY' },
172
+ visibilityFilter: eligInfo || { targetSet: 'acc', accEligible: null, reasons: [] }
173
+ }
174
+ })
175
+ );
178
176
  }
179
177
 
180
178
  if (applicableCount === 0) {
@@ -141,43 +141,40 @@ function runInPage(ctx) {
141
141
 
142
142
  if (getVisibleText(el)) continue;
143
143
 
144
- const stableSelector = helpers.buildSelector ? helpers.buildSelector(el) : 'html';
145
- const html = helpers.getOuterHtmlSnippet ? helpers.getOuterHtmlSnippet(el) : el.outerHTML || '';
146
-
147
144
  const ariaName = getAriaOnlyName(el);
148
145
  if (ariaName) {
149
- occurrences.push({
150
- selector: stableSelector,
151
- html,
152
- summary:
153
- 'This table header cell has no visible text — its only accessible name comes from aria-label/aria-labelledby, which real screen-reader/browser combinations (e.g. NVDA+Firefox, iOS VoiceOver+Safari) are known to ignore on <th> elements.',
154
- hint: 'Add visible text content to this header cell (in addition to, or instead of, aria-label/aria-labelledby) — visible text is the only naming mechanism confirmed to work across tested screen readers.',
146
+ occurrences.push(
147
+ helpers.reportOccurrence(el, {
148
+ summary:
149
+ 'This table header cell has no visible text — its only accessible name comes from aria-label/aria-labelledby, which real screen-reader/browser combinations (e.g. NVDA+Firefox, iOS VoiceOver+Safari) are known to ignore on <th> elements.',
150
+ hint: 'Add visible text content to this header cell (in addition to, or instead of, aria-label/aria-labelledby) — visible text is the only naming mechanism confirmed to work across tested screen readers.',
151
+ i18n: {
152
+ summaryKey: 'emptyTableHeader_summary_cantTell_ariaOnly',
153
+ hintKey: 'emptyTableHeader_hint_cantTell_ariaOnly',
154
+ params: {}
155
+ },
156
+ data: {
157
+ details: { reasonCode: 'TABLE_HEADER_NAME_NOT_VISIBLE_TEXT', ariaName }
158
+ }
159
+ })
160
+ );
161
+ continue;
162
+ }
163
+
164
+ occurrences.push(
165
+ helpers.reportOccurrence(el, {
166
+ summary: 'This table header cell has no accessible name.',
167
+ hint: 'Add text content (or aria-label/aria-labelledby) to this header cell, or remove it if it is not needed.',
155
168
  i18n: {
156
- summaryKey: 'emptyTableHeader_summary_cantTell_ariaOnly',
157
- hintKey: 'emptyTableHeader_hint_cantTell_ariaOnly',
169
+ summaryKey: 'emptyTableHeader_summary_cantTell',
170
+ hintKey: 'emptyTableHeader_hint_cantTell',
158
171
  params: {}
159
172
  },
160
173
  data: {
161
- details: { reasonCode: 'TABLE_HEADER_NAME_NOT_VISIBLE_TEXT', ariaName }
174
+ details: { reasonCode: 'TABLE_HEADER_EMPTY' }
162
175
  }
163
- });
164
- continue;
165
- }
166
-
167
- occurrences.push({
168
- selector: stableSelector,
169
- html,
170
- summary: 'This table header cell has no accessible name.',
171
- hint: 'Add text content (or aria-label/aria-labelledby) to this header cell, or remove it if it is not needed.',
172
- i18n: {
173
- summaryKey: 'emptyTableHeader_summary_cantTell',
174
- hintKey: 'emptyTableHeader_hint_cantTell',
175
- params: {}
176
- },
177
- data: {
178
- details: { reasonCode: 'TABLE_HEADER_EMPTY' }
179
- }
180
- });
176
+ })
177
+ );
181
178
  }
182
179
 
183
180
  if (applicableCount === 0) {
@@ -185,20 +185,13 @@ function runInPage(ctx) {
185
185
 
186
186
  const reasonCode =
187
187
  method === 'title' ? 'label_from_title_primary' : 'label_from_placeholder_primary';
188
- const methodLabel =
189
- method === 'title'
190
- ? 'title'
191
- : method === 'placeholder'
192
- ? 'placeholder'
193
- : 'title or placeholder';
194
-
195
188
  const baseOccurrence = {
196
- summary: 'Form control’s primary label is derived from title or placeholder.',
189
+ summary: `Form control’s primary label is derived from ${method}.`,
197
190
  hint: 'Prefer a persistent <label> or aria-labelledby. Avoid relying on placeholder/title as the primary label.',
198
191
  i18n: {
199
192
  summaryKey: 'formControl_programmaticLabelQuality_summary_cantTell',
200
193
  hintKey: 'formControl_programmaticLabelQuality_hint_cantTell',
201
- params: { element: (el.tagName || '').toLowerCase(), method, methodLabel }
194
+ params: { element: (el.tagName || '').toLowerCase(), method }
202
195
  },
203
196
  data: {
204
197
  visibilityFilter: vf || { targetSet: 'acc', accEligible: null, reasons: [] },
@@ -125,29 +125,24 @@ function runInPage(ctx) {
125
125
  const { el, level } = headings[i];
126
126
 
127
127
  if (level > highestSoFar + 1) {
128
- const stableSelector = helpers.buildSelector ? helpers.buildSelector(el) : 'html';
129
- const html = helpers.getOuterHtmlSnippet
130
- ? helpers.getOuterHtmlSnippet(el)
131
- : el.outerHTML || '';
132
-
133
- occurrences.push({
134
- selector: stableSelector,
135
- html,
136
- summary: `This heading jumps from level ${highestSoFar} to level ${level}, skipping a level.`,
137
- hint: 'Use consecutive heading levels (do not skip a level when going deeper) so the document outline stays predictable.',
138
- i18n: {
139
- summaryKey: 'headingOrder_summary_cantTell',
140
- hintKey: 'headingOrder_hint_cantTell',
141
- params: { fromLevel: String(highestSoFar), toLevel: String(level) }
142
- },
143
- data: {
144
- details: {
145
- reasonCode: 'HEADING_ORDER_SKIPPED_LEVEL',
146
- fromLevel: highestSoFar,
147
- toLevel: level
128
+ occurrences.push(
129
+ helpers.reportOccurrence(el, {
130
+ summary: `This heading jumps from level ${highestSoFar} to level ${level}, skipping a level.`,
131
+ hint: 'Use consecutive heading levels (do not skip a level when going deeper) so the document outline stays predictable.',
132
+ i18n: {
133
+ summaryKey: 'headingOrder_summary_cantTell',
134
+ hintKey: 'headingOrder_hint_cantTell',
135
+ params: { fromLevel: String(highestSoFar), toLevel: String(level) }
136
+ },
137
+ data: {
138
+ details: {
139
+ reasonCode: 'HEADING_ORDER_SKIPPED_LEVEL',
140
+ fromLevel: highestSoFar,
141
+ toLevel: level
142
+ }
148
143
  }
149
- }
150
- });
144
+ })
145
+ );
151
146
  }
152
147
 
153
148
  if (level > highestSoFar) highestSoFar = level;
@@ -111,23 +111,20 @@ function runInPage(ctx) {
111
111
 
112
112
  if (otherText.toLowerCase() !== alt.toLowerCase()) continue;
113
113
 
114
- const stableSelector = helpers.buildSelector ? helpers.buildSelector(el) : 'html';
115
- const html = helpers.getOuterHtmlSnippet ? helpers.getOuterHtmlSnippet(el) : el.outerHTML || '';
116
-
117
- occurrences.push({
118
- selector: stableSelector,
119
- html,
120
- summary: "This image's alt text duplicates other visible text right next to it.",
121
- hint: 'Make the alt text empty (alt="") if the image is purely decorative alongside the text, or remove the redundant duplication.',
122
- i18n: {
123
- summaryKey: 'imageRedundantAlt_summary_cantTell',
124
- hintKey: 'imageRedundantAlt_hint_cantTell',
125
- params: { alt }
126
- },
127
- data: {
128
- details: { reasonCode: 'IMAGE_ALT_REDUNDANT', alt }
129
- }
130
- });
114
+ occurrences.push(
115
+ helpers.reportOccurrence(el, {
116
+ summary: "This image's alt text duplicates other visible text right next to it.",
117
+ hint: 'Make the alt text empty (alt="") if the image is purely decorative alongside the text, or remove the redundant duplication.',
118
+ i18n: {
119
+ summaryKey: 'imageRedundantAlt_summary_cantTell',
120
+ hintKey: 'imageRedundantAlt_hint_cantTell',
121
+ params: { alt }
122
+ },
123
+ data: {
124
+ details: { reasonCode: 'IMAGE_ALT_REDUNDANT', alt }
125
+ }
126
+ })
127
+ );
131
128
  }
132
129
 
133
130
  if (applicableCount === 0) {
@@ -94,23 +94,21 @@ function runInPage(ctx) {
94
94
  if (!nameInfo || nameInfo.mechanism !== 'title') continue;
95
95
 
96
96
  const tag = el.tagName.toLowerCase();
97
- const stableSelector = helpers.buildSelector ? helpers.buildSelector(el) : 'html';
98
- const html = helpers.getOuterHtmlSnippet ? helpers.getOuterHtmlSnippet(el) : el.outerHTML || '';
99
-
100
- occurrences.push({
101
- selector: stableSelector,
102
- html,
103
- summary: 'This form control relies on the title attribute as its only label.',
104
- hint: 'Add a visible <label> (or aria-label/aria-labelledby) in addition to, or instead of, the title attribute.',
105
- i18n: {
106
- summaryKey: 'labelTitleOnly_summary_cantTell',
107
- hintKey: 'labelTitleOnly_hint_cantTell',
108
- params: { element: tag }
109
- },
110
- data: {
111
- details: { reasonCode: 'LABEL_TITLE_ONLY', element: tag }
112
- }
113
- });
97
+
98
+ occurrences.push(
99
+ helpers.reportOccurrence(el, {
100
+ summary: 'This form control relies on the title attribute as its only label.',
101
+ hint: 'Add a visible <label> (or aria-label/aria-labelledby) in addition to, or instead of, the title attribute.',
102
+ i18n: {
103
+ summaryKey: 'labelTitleOnly_summary_cantTell',
104
+ hintKey: 'labelTitleOnly_hint_cantTell',
105
+ params: { element: tag }
106
+ },
107
+ data: {
108
+ details: { reasonCode: 'LABEL_TITLE_ONLY', element: tag }
109
+ }
110
+ })
111
+ );
114
112
  }
115
113
 
116
114
  if (applicableCount === 0) {
@@ -209,23 +209,20 @@ function runInPage(ctx) {
209
209
  for (const el of banners) {
210
210
  if (!hasLandmarkAncestor(el)) continue;
211
211
 
212
- const stableSelector = helpers.buildSelector ? helpers.buildSelector(el) : 'html';
213
- const html = helpers.getOuterHtmlSnippet ? helpers.getOuterHtmlSnippet(el) : el.outerHTML || '';
214
-
215
- occurrences.push({
216
- selector: stableSelector,
217
- html,
218
- summary: 'This banner landmark is nested inside another landmark region.',
219
- hint: 'Move the banner landmark (header/role="banner") so it is not contained by another landmark; a banner should be a top-level region of the page.',
220
- i18n: {
221
- summaryKey: 'landmarkBannerIsTopLevel_summary_cantTell',
222
- hintKey: 'landmarkBannerIsTopLevel_hint_cantTell',
223
- params: {}
224
- },
225
- data: {
226
- details: { reasonCode: 'LANDMARK_BANNER_NOT_TOP_LEVEL' }
227
- }
228
- });
212
+ occurrences.push(
213
+ helpers.reportOccurrence(el, {
214
+ summary: 'This banner landmark is nested inside another landmark region.',
215
+ hint: 'Move the banner landmark (header/role="banner") so it is not contained by another landmark; a banner should be a top-level region of the page.',
216
+ i18n: {
217
+ summaryKey: 'landmarkBannerIsTopLevel_summary_cantTell',
218
+ hintKey: 'landmarkBannerIsTopLevel_hint_cantTell',
219
+ params: {}
220
+ },
221
+ data: {
222
+ details: { reasonCode: 'LANDMARK_BANNER_NOT_TOP_LEVEL' }
223
+ }
224
+ })
225
+ );
229
226
  }
230
227
 
231
228
  if (occurrences.length) {
@@ -202,23 +202,20 @@ function runInPage(ctx) {
202
202
  for (const el of contentinfos) {
203
203
  if (!hasLandmarkAncestor(el)) continue;
204
204
 
205
- const stableSelector = helpers.buildSelector ? helpers.buildSelector(el) : 'html';
206
- const html = helpers.getOuterHtmlSnippet ? helpers.getOuterHtmlSnippet(el) : el.outerHTML || '';
207
-
208
- occurrences.push({
209
- selector: stableSelector,
210
- html,
211
- summary: 'This contentinfo landmark is nested inside another landmark region.',
212
- hint: 'Move the contentinfo landmark (footer/role="contentinfo") so it is not contained by another landmark; contentinfo should be a top-level region of the page.',
213
- i18n: {
214
- summaryKey: 'landmarkContentinfoIsTopLevel_summary_cantTell',
215
- hintKey: 'landmarkContentinfoIsTopLevel_hint_cantTell',
216
- params: {}
217
- },
218
- data: {
219
- details: { reasonCode: 'LANDMARK_CONTENTINFO_NOT_TOP_LEVEL' }
220
- }
221
- });
205
+ occurrences.push(
206
+ helpers.reportOccurrence(el, {
207
+ summary: 'This contentinfo landmark is nested inside another landmark region.',
208
+ hint: 'Move the contentinfo landmark (footer/role="contentinfo") so it is not contained by another landmark; contentinfo should be a top-level region of the page.',
209
+ i18n: {
210
+ summaryKey: 'landmarkContentinfoIsTopLevel_summary_cantTell',
211
+ hintKey: 'landmarkContentinfoIsTopLevel_hint_cantTell',
212
+ params: {}
213
+ },
214
+ data: {
215
+ details: { reasonCode: 'LANDMARK_CONTENTINFO_NOT_TOP_LEVEL' }
216
+ }
217
+ })
218
+ );
222
219
  }
223
220
 
224
221
  if (occurrences.length) {
@@ -192,23 +192,20 @@ function runInPage(ctx) {
192
192
  for (const el of mains) {
193
193
  if (!hasLandmarkAncestor(el)) continue;
194
194
 
195
- const stableSelector = helpers.buildSelector ? helpers.buildSelector(el) : 'html';
196
- const html = helpers.getOuterHtmlSnippet ? helpers.getOuterHtmlSnippet(el) : el.outerHTML || '';
197
-
198
- occurrences.push({
199
- selector: stableSelector,
200
- html,
201
- summary: 'This main landmark is nested inside another landmark region.',
202
- hint: 'Move the main landmark (<main>/role="main") so it is not contained by another landmark; main should be a top-level region of the page.',
203
- i18n: {
204
- summaryKey: 'landmarkMainIsTopLevel_summary_cantTell',
205
- hintKey: 'landmarkMainIsTopLevel_hint_cantTell',
206
- params: {}
207
- },
208
- data: {
209
- details: { reasonCode: 'LANDMARK_MAIN_NOT_TOP_LEVEL' }
210
- }
211
- });
195
+ occurrences.push(
196
+ helpers.reportOccurrence(el, {
197
+ summary: 'This main landmark is nested inside another landmark region.',
198
+ hint: 'Move the main landmark (<main>/role="main") so it is not contained by another landmark; main should be a top-level region of the page.',
199
+ i18n: {
200
+ summaryKey: 'landmarkMainIsTopLevel_summary_cantTell',
201
+ hintKey: 'landmarkMainIsTopLevel_hint_cantTell',
202
+ params: {}
203
+ },
204
+ data: {
205
+ details: { reasonCode: 'LANDMARK_MAIN_NOT_TOP_LEVEL' }
206
+ }
207
+ })
208
+ );
212
209
  }
213
210
 
214
211
  if (occurrences.length) {
@@ -171,12 +171,7 @@ function runInPage(ctx) {
171
171
  }
172
172
 
173
173
  const occurrences = banners.map((el) => {
174
- const stableSelector = helpers.buildSelector ? helpers.buildSelector(el) : 'html';
175
- const html = helpers.getOuterHtmlSnippet ? helpers.getOuterHtmlSnippet(el) : el.outerHTML || '';
176
-
177
- return {
178
- selector: stableSelector,
179
- html,
174
+ return helpers.reportOccurrence(el, {
180
175
  summary: 'This page has more than one banner landmark.',
181
176
  hint: 'Keep only one banner landmark (header/role="banner") per page.',
182
177
  i18n: {
@@ -187,7 +182,7 @@ function runInPage(ctx) {
187
182
  data: {
188
183
  details: { reasonCode: 'LANDMARK_DUPLICATE_BANNER', count: banners.length }
189
184
  }
190
- };
185
+ });
191
186
  });
192
187
 
193
188
  return {
@@ -163,12 +163,7 @@ function runInPage(ctx) {
163
163
  }
164
164
 
165
165
  const occurrences = contentinfos.map((el) => {
166
- const stableSelector = helpers.buildSelector ? helpers.buildSelector(el) : 'html';
167
- const html = helpers.getOuterHtmlSnippet ? helpers.getOuterHtmlSnippet(el) : el.outerHTML || '';
168
-
169
- return {
170
- selector: stableSelector,
171
- html,
166
+ return helpers.reportOccurrence(el, {
172
167
  summary: 'This page has more than one contentinfo landmark.',
173
168
  hint: 'Keep only one contentinfo landmark (footer/role="contentinfo") per page.',
174
169
  i18n: {
@@ -179,7 +174,7 @@ function runInPage(ctx) {
179
174
  data: {
180
175
  details: { reasonCode: 'LANDMARK_DUPLICATE_CONTENTINFO', count: contentinfos.length }
181
176
  }
182
- };
177
+ });
183
178
  });
184
179
 
185
180
  return {
@@ -108,12 +108,7 @@ function runInPage(ctx) {
108
108
  }
109
109
 
110
110
  const occurrences = mains.map((el) => {
111
- const stableSelector = helpers.buildSelector ? helpers.buildSelector(el) : 'html';
112
- const html = helpers.getOuterHtmlSnippet ? helpers.getOuterHtmlSnippet(el) : el.outerHTML || '';
113
-
114
- return {
115
- selector: stableSelector,
116
- html,
111
+ return helpers.reportOccurrence(el, {
117
112
  summary: 'This page has more than one main landmark.',
118
113
  hint: 'Keep only one main landmark (<main>/role="main") per page.',
119
114
  i18n: {
@@ -124,7 +119,7 @@ function runInPage(ctx) {
124
119
  data: {
125
120
  details: { reasonCode: 'LANDMARK_DUPLICATE_MAIN', count: mains.length }
126
121
  }
127
- };
122
+ });
128
123
  });
129
124
 
130
125
  return {
@@ -123,19 +123,12 @@ function runInPage(ctx) {
123
123
  return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
124
124
  }
125
125
 
126
- const stableSelector = helpers.buildSelector ? helpers.buildSelector(body) : 'body';
127
- const html = helpers.getOuterHtmlSnippet
128
- ? helpers.getOuterHtmlSnippet(body)
129
- : (body.outerHTML || '').slice(0, 200);
130
-
131
126
  return {
132
127
  ruleId: rule.ruleId,
133
128
  outcome: 'cantTell',
134
129
  severity: rule.defaultSeverity || 'minor',
135
130
  occurrences: [
136
- {
137
- selector: stableSelector,
138
- html,
131
+ helpers.reportOccurrence(body, {
139
132
  summary: 'This page has no main landmark.',
140
133
  hint: 'Add a main landmark (<main> or role="main") around the page\'s primary content.',
141
134
  i18n: {
@@ -146,7 +139,7 @@ function runInPage(ctx) {
146
139
  data: {
147
140
  details: { reasonCode: 'LANDMARK_MAIN_MISSING' }
148
141
  }
149
- }
142
+ })
150
143
  ]
151
144
  };
152
145
  }
@@ -219,34 +219,29 @@ function runInPage(ctx) {
219
219
  if (group.length <= 1) continue;
220
220
 
221
221
  for (const { el } of group) {
222
- const stableSelector = helpers.buildSelector ? helpers.buildSelector(el) : 'html';
223
- const html = helpers.getOuterHtmlSnippet
224
- ? helpers.getOuterHtmlSnippet(el)
225
- : el.outerHTML || '';
226
-
227
- occurrences.push({
228
- selector: stableSelector,
229
- html,
230
- summary: normalizedName
231
- ? `This ${role} landmark shares its accessible name with another ${role} landmark.`
232
- : `This ${role} landmark has no accessible name, and more than one unnamed ${role} landmark exists on this page.`,
233
- hint: `Give each ${role} landmark a distinct name via aria-label or aria-labelledby.`,
234
- i18n: {
235
- summaryKey: normalizedName
236
- ? 'landmarkUnique_summary_cantTell_duplicateName'
237
- : 'landmarkUnique_summary_cantTell_bothUnnamed',
238
- hintKey: 'landmarkUnique_hint_cantTell',
239
- params: { role }
240
- },
241
- data: {
242
- details: {
243
- reasonCode: 'LANDMARK_NOT_UNIQUE',
244
- role,
245
- name: normalizedName,
246
- groupSize: group.length
222
+ occurrences.push(
223
+ helpers.reportOccurrence(el, {
224
+ summary: normalizedName
225
+ ? `This ${role} landmark shares its accessible name with another ${role} landmark.`
226
+ : `This ${role} landmark has no accessible name, and more than one unnamed ${role} landmark exists on this page.`,
227
+ hint: `Give each ${role} landmark a distinct name via aria-label or aria-labelledby.`,
228
+ i18n: {
229
+ summaryKey: normalizedName
230
+ ? 'landmarkUnique_summary_cantTell_duplicateName'
231
+ : 'landmarkUnique_summary_cantTell_bothUnnamed',
232
+ hintKey: 'landmarkUnique_hint_cantTell',
233
+ params: { role }
234
+ },
235
+ data: {
236
+ details: {
237
+ reasonCode: 'LANDMARK_NOT_UNIQUE',
238
+ role,
239
+ name: normalizedName,
240
+ groupSize: group.length
241
+ }
247
242
  }
248
- }
249
- });
243
+ })
244
+ );
250
245
  }
251
246
  }
252
247
  }
@@ -133,27 +133,25 @@ function runInPage(ctx) {
133
133
 
134
134
  if (!GENERIC_LINK_TEXT.has(normalized)) continue;
135
135
 
136
- const stableSelector = helpers.buildSelector ? helpers.buildSelector(el) : 'html';
137
- const html = helpers.getOuterHtmlSnippet ? helpers.getOuterHtmlSnippet(el) : el.outerHTML || '';
138
136
  const eligInfo = helpers.getEligibilityInfo
139
137
  ? helpers.getEligibilityInfo(el, ctx, { targetSet: 'acc' })
140
138
  : null;
141
139
 
142
- occurrences.push({
143
- selector: stableSelector,
144
- html,
145
- summary: `This link's accessible name ("${rawName.trim()}") is a generic, non-descriptive phrase.`,
146
- hint: 'Make the link text itself describe its destination/purpose (e.g. "Download the 2026 pricing guide" instead of "Download"), or confirm the surrounding context already makes the purpose clear.',
147
- i18n: {
148
- summaryKey: 'linkNameQuality_summary_cantTell',
149
- hintKey: 'linkNameQuality_hint_cantTell',
150
- params: { name: rawName.trim() }
151
- },
152
- data: {
153
- details: { reasonCode: 'GENERIC_LINK_TEXT', normalizedName: normalized },
154
- visibilityFilter: eligInfo || { targetSet: 'acc', accEligible: null, reasons: [] }
155
- }
156
- });
140
+ occurrences.push(
141
+ helpers.reportOccurrence(el, {
142
+ summary: `This link's accessible name ("${rawName.trim()}") is a generic, non-descriptive phrase.`,
143
+ hint: 'Make the link text itself describe its destination/purpose (e.g. "Download the 2026 pricing guide" instead of "Download"), or confirm the surrounding context already makes the purpose clear.',
144
+ i18n: {
145
+ summaryKey: 'linkNameQuality_summary_cantTell',
146
+ hintKey: 'linkNameQuality_hint_cantTell',
147
+ params: { name: rawName.trim() }
148
+ },
149
+ data: {
150
+ details: { reasonCode: 'GENERIC_LINK_TEXT', normalizedName: normalized },
151
+ visibilityFilter: eligInfo || { targetSet: 'acc', accEligible: null, reasons: [] }
152
+ }
153
+ })
154
+ );
157
155
  }
158
156
 
159
157
  if (applicableCount === 0) {
@@ -104,23 +104,20 @@ function runInPage(ctx) {
104
104
 
105
105
  if (!reasons.length) continue;
106
106
 
107
- const stableSelector = helpers.buildSelector ? helpers.buildSelector(el) : 'html';
108
- const html = helpers.getOuterHtmlSnippet ? helpers.getOuterHtmlSnippet(el) : el.outerHTML || '';
109
-
110
- occurrences.push({
111
- selector: stableSelector,
112
- html,
113
- summary: 'This viewport meta tag restricts zoom below the 500% best-practice target.',
114
- hint: 'Remove user-scalable=no and raise maximum-scale to at least 5 (500%) if possible.',
115
- i18n: {
116
- summaryKey: 'metaViewportLarge_summary_cantTell',
117
- hintKey: 'metaViewportLarge_hint_cantTell',
118
- params: { reasons: reasons.join(', ') }
119
- },
120
- data: {
121
- details: { reasonCode: 'VIEWPORT_ZOOM_BELOW_500', reasons }
122
- }
123
- });
107
+ occurrences.push(
108
+ helpers.reportOccurrence(el, {
109
+ summary: 'This viewport meta tag restricts zoom below the 500% best-practice target.',
110
+ hint: 'Remove user-scalable=no and raise maximum-scale to at least 5 (500%) if possible.',
111
+ i18n: {
112
+ summaryKey: 'metaViewportLarge_summary_cantTell',
113
+ hintKey: 'metaViewportLarge_hint_cantTell',
114
+ params: { reasons: reasons.join(', ') }
115
+ },
116
+ data: {
117
+ details: { reasonCode: 'VIEWPORT_ZOOM_BELOW_500', reasons }
118
+ }
119
+ })
120
+ );
124
121
  }
125
122
 
126
123
  if (applicableCount === 0) {