@surea11y/core 1.0.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 (164) hide show
  1. package/CHANGELOG.md +49 -0
  2. package/LICENSE +21 -0
  3. package/README.md +145 -0
  4. package/bin/core.js +244 -0
  5. package/docs/BINDING_AUTHORS_GUIDE.md +41 -0
  6. package/docs/CLI.md +49 -0
  7. package/docs/ENGINE_OPTIONS.md +155 -0
  8. package/docs/I18N.md +47 -0
  9. package/docs/INTEGRATION.md +156 -0
  10. package/docs/LIMITATIONS.md +31 -0
  11. package/docs/OUTPUT_SCHEMA.md +237 -0
  12. package/docs/POLICY.md +71 -0
  13. package/docs/RULE_AUTHORING.md +375 -0
  14. package/docs/RULE_CATALOG.md +180 -0
  15. package/docs/RULE_TAXONOMY.md +145 -0
  16. package/docs/TROUBLESHOOTING.md +48 -0
  17. package/docs/WCAG_CONFORMANCE.md +49 -0
  18. package/package.json +60 -0
  19. package/src/catalogs/composites.wcag.js +490 -0
  20. package/src/checks/automatic/area-alt-present.js +225 -0
  21. package/src/checks/automatic/aria-allowed-attr.js +206 -0
  22. package/src/checks/automatic/aria-allowed-role.js +102 -0
  23. package/src/checks/automatic/aria-braille-equivalent.js +139 -0
  24. package/src/checks/automatic/aria-conditional-attr.js +110 -0
  25. package/src/checks/automatic/aria-deprecated-role.js +106 -0
  26. package/src/checks/automatic/aria-hidden-body.js +87 -0
  27. package/src/checks/automatic/aria-hidden-focus.js +480 -0
  28. package/src/checks/automatic/aria-prohibited-attr.js +156 -0
  29. package/src/checks/automatic/aria-prohibited-children.js +265 -0
  30. package/src/checks/automatic/aria-required-attr.js +154 -0
  31. package/src/checks/automatic/aria-required-children.js +274 -0
  32. package/src/checks/automatic/aria-required-parent.js +222 -0
  33. package/src/checks/automatic/aria-role-name-present.js +201 -0
  34. package/src/checks/automatic/aria-roles-valid.js +110 -0
  35. package/src/checks/automatic/aria-valid-attr-value.js +123 -0
  36. package/src/checks/automatic/aria-valid-attr.js +109 -0
  37. package/src/checks/automatic/autocomplete-valid.js +134 -0
  38. package/src/checks/automatic/avoid-inline-spacing.js +107 -0
  39. package/src/checks/automatic/binary-control-name-present.js +294 -0
  40. package/src/checks/automatic/button-name-present.js +146 -0
  41. package/src/checks/automatic/bypass-blocks-present.js +162 -0
  42. package/src/checks/automatic/canvas-text-alternative-present.js +140 -0
  43. package/src/checks/automatic/combobox-name-present.js +267 -0
  44. package/src/checks/automatic/contrast-computable.js +378 -0
  45. package/src/checks/automatic/contrast-enhanced.js +517 -0
  46. package/src/checks/automatic/contrast-minimum.js +512 -0
  47. package/src/checks/automatic/css-orientation-lock.js +206 -0
  48. package/src/checks/automatic/definition-list-children-valid.js +148 -0
  49. package/src/checks/automatic/deprecated-elements-not-used.js +91 -0
  50. package/src/checks/automatic/dialog-name-present.js +209 -0
  51. package/src/checks/automatic/dlitem-parent-valid.js +100 -0
  52. package/src/checks/automatic/duplicate-id-aria.js +126 -0
  53. package/src/checks/automatic/embed-text-alternative-present.js +190 -0
  54. package/src/checks/automatic/form-control-programmatic-label-present.js +409 -0
  55. package/src/checks/automatic/form-control-single-label.js +117 -0
  56. package/src/checks/automatic/html-xml-lang-mismatch.js +91 -0
  57. package/src/checks/automatic/iframe-focusable-content.js +141 -0
  58. package/src/checks/automatic/iframe-name-present.js +102 -0
  59. package/src/checks/automatic/iframe-title-unique.js +107 -0
  60. package/src/checks/automatic/img-alt-present.js +223 -0
  61. package/src/checks/automatic/input-image-alt-present.js +155 -0
  62. package/src/checks/automatic/label-in-name.js +326 -0
  63. package/src/checks/automatic/language-page-present.js +159 -0
  64. package/src/checks/automatic/link-in-text-block.js +218 -0
  65. package/src/checks/automatic/link-name-present.js +114 -0
  66. package/src/checks/automatic/list-children-valid.js +152 -0
  67. package/src/checks/automatic/listbox-name-present.js +236 -0
  68. package/src/checks/automatic/listitem-parent-valid.js +118 -0
  69. package/src/checks/automatic/menuitem-name-present.js +201 -0
  70. package/src/checks/automatic/meta-refresh-no-exceptions.js +105 -0
  71. package/src/checks/automatic/meta-refresh-timing-absent.js +107 -0
  72. package/src/checks/automatic/meta-viewport-zoom-enabled.js +118 -0
  73. package/src/checks/automatic/meter-name-present.js +160 -0
  74. package/src/checks/automatic/nested-interactive-controls-absent.js +135 -0
  75. package/src/checks/automatic/object-text-alternative-present.js +193 -0
  76. package/src/checks/automatic/option-name-present.js +157 -0
  77. package/src/checks/automatic/page-title-present.js +86 -0
  78. package/src/checks/automatic/progressbar-name-present.js +165 -0
  79. package/src/checks/automatic/role-img-alt-present.js +206 -0
  80. package/src/checks/automatic/searchbox-name-present.js +236 -0
  81. package/src/checks/automatic/server-side-image-map-absent.js +88 -0
  82. package/src/checks/automatic/slider-name-present.js +276 -0
  83. package/src/checks/automatic/spinbutton-name-present.js +236 -0
  84. package/src/checks/automatic/summary-name-present.js +153 -0
  85. package/src/checks/automatic/svg-image-text-alternative-present.js +220 -0
  86. package/src/checks/automatic/svg-text-alternative-present.js +298 -0
  87. package/src/checks/automatic/tab-name-present.js +200 -0
  88. package/src/checks/automatic/table-headers-attr-valid.js +122 -0
  89. package/src/checks/automatic/table-th-has-data-cells.js +117 -0
  90. package/src/checks/automatic/target-size-minimum.js +605 -0
  91. package/src/checks/automatic/td-has-header.js +151 -0
  92. package/src/checks/automatic/textbox-name-present.js +236 -0
  93. package/src/checks/automatic/tooltip-name-present.js +158 -0
  94. package/src/checks/automatic/treeitem-name-present.js +157 -0
  95. package/src/checks/automatic/valid-lang.js +100 -0
  96. package/src/checks/automatic/video-poster-text-alternative-present.js +193 -0
  97. package/src/checks/manual/accesskeys-manual.js +93 -0
  98. package/src/checks/manual/area-alt-decorative-manual.js +247 -0
  99. package/src/checks/manual/area-alt-quality-manual.js +204 -0
  100. package/src/checks/manual/aria-checked-state-mismatch-manual.js +141 -0
  101. package/src/checks/manual/aria-text-manual.js +109 -0
  102. package/src/checks/manual/canvas-text-alternative-quality-manual.js +170 -0
  103. package/src/checks/manual/css-hidden-focus.js +259 -0
  104. package/src/checks/manual/embed-text-alternative-quality-manual.js +204 -0
  105. package/src/checks/manual/empty-heading-manual.js +182 -0
  106. package/src/checks/manual/empty-table-header-manual.js +163 -0
  107. package/src/checks/manual/focus-order-semantics-manual.js +117 -0
  108. package/src/checks/manual/form-control-programmatic-label-quality-manual.js +291 -0
  109. package/src/checks/manual/heading-order-manual.js +130 -0
  110. package/src/checks/manual/identical-links-same-purpose-manual.js +142 -0
  111. package/src/checks/manual/image-redundant-alt-manual.js +118 -0
  112. package/src/checks/manual/img-alt-decorative-manual.js +148 -0
  113. package/src/checks/manual/img-alt-quality-manual.js +182 -0
  114. package/src/checks/manual/input-image-alt-decorative-manual.js +144 -0
  115. package/src/checks/manual/input-image-alt-quality-manual.js +144 -0
  116. package/src/checks/manual/label-title-only-manual.js +115 -0
  117. package/src/checks/manual/landmark-banner-is-top-level-manual.js +180 -0
  118. package/src/checks/manual/landmark-contentinfo-is-top-level-manual.js +169 -0
  119. package/src/checks/manual/landmark-main-is-top-level-manual.js +167 -0
  120. package/src/checks/manual/landmark-no-duplicate-banner-manual.js +177 -0
  121. package/src/checks/manual/landmark-no-duplicate-contentinfo-manual.js +169 -0
  122. package/src/checks/manual/landmark-no-duplicate-main-manual.js +132 -0
  123. package/src/checks/manual/landmark-one-main-manual.js +151 -0
  124. package/src/checks/manual/landmark-unique-manual.js +252 -0
  125. package/src/checks/manual/link-name-quality-manual.js +143 -0
  126. package/src/checks/manual/media-transcript-present-manual.js +373 -0
  127. package/src/checks/manual/meta-viewport-large-manual.js +119 -0
  128. package/src/checks/manual/mouse-only-event-handlers-manual.js +134 -0
  129. package/src/checks/manual/no-autoplay-audio-manual.js +116 -0
  130. package/src/checks/manual/object-text-alternative-quality-manual.js +194 -0
  131. package/src/checks/manual/p-as-heading-manual.js +163 -0
  132. package/src/checks/manual/page-has-heading-one-manual.js +110 -0
  133. package/src/checks/manual/page-title-patterns-manual.js +262 -0
  134. package/src/checks/manual/presentation-role-conflict-manual.js +159 -0
  135. package/src/checks/manual/region-manual.js +183 -0
  136. package/src/checks/manual/scope-attr-valid-manual.js +93 -0
  137. package/src/checks/manual/scrollable-region-focusable-manual.js +168 -0
  138. package/src/checks/manual/skip-link-manual.js +150 -0
  139. package/src/checks/manual/svg-text-alternative-quality-manual.js +209 -0
  140. package/src/checks/manual/tabindex-manual.js +94 -0
  141. package/src/checks/manual/table-duplicate-name-manual.js +99 -0
  142. package/src/checks/manual/table-fake-caption-manual.js +122 -0
  143. package/src/checks/manual/video-caption-manual.js +118 -0
  144. package/src/checks/manual-review.js +95 -0
  145. package/src/checks/rules-and-tags.full.csv +19 -0
  146. package/src/checks/rules-and-tags.full.json +259 -0
  147. package/src/core/aria-helpers.js +906 -0
  148. package/src/core/contrast-helpers.js +1147 -0
  149. package/src/core/dom-helpers.js +4085 -0
  150. package/src/core/dom-runner.js +627 -0
  151. package/src/core/frame-messaging.js +210 -0
  152. package/src/core/frame-scan.js +178 -0
  153. package/src/core/rollup-composites.js +135 -0
  154. package/src/core/rule-meta.js +140 -0
  155. package/src/core.js +79055 -0
  156. package/src/coverage/wcag-facets.js +1079 -0
  157. package/src/coverage/wcag-version-map.js +84 -0
  158. package/src/i18n/en.js +919 -0
  159. package/src/i18n/fr.js +527 -0
  160. package/src/index.js +4 -0
  161. package/src/policy/contracts.js +18 -0
  162. package/src/policy/resolvePolicy.js +55 -0
  163. package/src/policy/schemas/engine-options.schema.json +103 -0
  164. package/src/policy/schemas/policy-contract.schema.json +40 -0
@@ -0,0 +1,262 @@
1
+ 'use strict';
2
+
3
+ const id = 'page-title-patterns';
4
+
5
+ const meta = {
6
+ title: 'Page title patterns that may indicate low descriptiveness',
7
+ description:
8
+ 'Flags page titles that are likely too generic or templated as review signals (WCAG 2.2 SC 2.4.2). ' +
9
+ 'This rule is conservative and does not fail based on patterns alone.',
10
+ i18n: {
11
+ titleKey: 'pageTitlePatterns_title',
12
+ descriptionKey: 'pageTitlePatterns_description'
13
+ },
14
+ helpUrl: null,
15
+ tags: ['wcag2a', 'wcag242', 'titles', 'atomic', 'navigation', 'manual'],
16
+ wcagSc: ['2.4.2'],
17
+ normativeMappings: [
18
+ { standard: 'WCAG', version: '2.2', requirement: '2.4.2', title: 'Page Titled', conformanceLevel: 'A' }
19
+ ],
20
+ defaultSeverity: 'minor',
21
+ category: 'operable',
22
+ type: 'manual',
23
+ defaultConfidence: 'medium',
24
+ coverage: { facetsBySc: { '2.4.2': ['page-title-patterns'] } }
25
+ };
26
+
27
+ function runInPage(ctx) {
28
+ const { document, helpers, rule } = ctx;
29
+ const probes = ctx && ctx.inputs && ctx.inputs.probes && typeof ctx.inputs.probes === 'object'
30
+ ? ctx.inputs.probes
31
+ : null;
32
+
33
+ const pageTitlesProbe = probes && probes['crawl.pageTitles'] && typeof probes['crawl.pageTitles'] === 'object'
34
+ ? probes['crawl.pageTitles']
35
+ : null;
36
+
37
+ const occurrences = [];
38
+ let applicableCount = 1;
39
+
40
+ const titleEl = document.querySelector('head > title');
41
+ const rawTitle = document.title || '';
42
+ const titleText = rawTitle.replace(/\s+/g, ' ').trim();
43
+ const titleLc = titleText.toLowerCase();
44
+ // =========================
45
+ // Cross-page pattern analysis (preferred) if crawl.pageTitles probe is provided
46
+ // =========================
47
+ if (pageTitlesProbe && Array.isArray(pageTitlesProbe.pages)) {
48
+ const pages = pageTitlesProbe.pages.filter(p => p && typeof p === 'object');
49
+
50
+ // Require enough data to avoid noisy conclusions
51
+ const MIN_PAGES = 10;
52
+ const analyzable = pages
53
+ .map(p => ({
54
+ url: p.url ? String(p.url) : null,
55
+ title: typeof p.title === 'string' ? p.title.replace(/\s+/g, ' ').trim() : ''
56
+ }))
57
+ .filter(p => p.url && p.title);
58
+
59
+ if (analyzable.length >= MIN_PAGES) {
60
+ // Build normalized title groups (case-insensitive)
61
+ const groups = new Map(); // normTitle -> { title, urls: [] }
62
+ for (const p of analyzable) {
63
+ const norm = p.title.toLowerCase();
64
+ if (!groups.has(norm)) groups.set(norm, { title: p.title, urls: [] });
65
+ groups.get(norm).urls.push(p.url);
66
+ }
67
+
68
+ // Duplicate titles across distinct URLs is a strong "review" signal (not a guaranteed failure)
69
+ const dupGroups = Array.from(groups.values()).filter(g => g.urls.length >= 2);
70
+
71
+ // Boilerplate-ish: detect a long common suffix/prefix across most titles.
72
+ // Keep conservative: only flag if the common part is long and shared by many.
73
+ const titles = analyzable.map(p => p.title);
74
+ function commonPrefix(a, b) {
75
+ const n = Math.min(a.length, b.length);
76
+ let i = 0;
77
+ for (; i < n; i++) if (a[i] !== b[i]) break;
78
+ return a.slice(0, i);
79
+ }
80
+ function commonSuffix(a, b) {
81
+ const ra = a.split('').reverse().join('');
82
+ const rb = b.split('').reverse().join('');
83
+ return commonPrefix(ra, rb).split('').reverse().join('');
84
+ }
85
+
86
+ let sharedPrefix = titles[0] || '';
87
+ let sharedSuffix = titles[0] || '';
88
+ for (let i = 1; i < titles.length; i++) {
89
+ sharedPrefix = commonPrefix(sharedPrefix, titles[i]);
90
+ sharedSuffix = commonSuffix(sharedSuffix, titles[i]);
91
+ if (sharedPrefix.length === 0 && sharedSuffix.length === 0) break;
92
+ }
93
+
94
+ const prefixLen = sharedPrefix.trim().length;
95
+ const suffixLen = sharedSuffix.trim().length;
96
+
97
+ const hasStrongTemplateSignal =
98
+ (prefixLen >= 12 || suffixLen >= 12) &&
99
+ (prefixLen >= 12 ? sharedPrefix.trim().length : 0) + (suffixLen >= 12 ? sharedSuffix.trim().length : 0) >= 12;
100
+
101
+ // If any cross-page signal exists, emit cantTell occurrence(s)
102
+ if (dupGroups.length || hasStrongTemplateSignal) {
103
+ const reasonCode = dupGroups.length
104
+ ? 'duplicateTitlesAcrossPages'
105
+ : 'templatedTitlesAcrossPages';
106
+
107
+ // Deterministic example title for i18n params (lexicographic, case-insensitive)
108
+ const exampleTitle = dupGroups.length
109
+ ? dupGroups
110
+ .map(g => String(g.title || '').replace(/\s+/g, ' ').trim())
111
+ .filter(Boolean)
112
+ .sort((a, b) => a.localeCompare(b, undefined, { sensitivity: 'base' }))[0] || ''
113
+ : '';
114
+
115
+ const summaryKey = dupGroups.length
116
+ ? 'pageTitlePatterns_summary_cantTell_duplicateAcrossPages'
117
+ : 'pageTitlePatterns_summary_cantTell_templatedAcrossPages';
118
+
119
+ const i18nParams = dupGroups.length
120
+ ? {
121
+ reasonCode,
122
+ pagesAnalyzed: analyzable.length,
123
+ duplicateGroups: dupGroups.length,
124
+ exampleTitle
125
+ }
126
+ : {
127
+ reasonCode,
128
+ pagesAnalyzed: analyzable.length
129
+ };
130
+
131
+ const occBase = {
132
+ selector: 'head > title',
133
+ html: '',
134
+ summary: 'The set of page titles may not be descriptive enough to distinguish pages by topic or purpose.',
135
+ hint: 'Ensure each page title is sufficiently descriptive and helps users distinguish pages (avoid identical or overly templated titles across many pages).',
136
+ i18n: {
137
+ summaryKey,
138
+ hintKey: 'pageTitlePatterns_hint_cantTell',
139
+ params: i18nParams
140
+ },
141
+ data: {
142
+ details: {
143
+ reasonCode,
144
+ metrics: {
145
+ pagesAnalyzed: analyzable.length,
146
+ duplicateGroups: dupGroups.length,
147
+ largestDuplicateGroupSize: dupGroups.length ? Math.max(...dupGroups.map(g => g.urls.length)) : 0,
148
+ sharedPrefix: prefixLen >= 12 ? sharedPrefix.trim() : '',
149
+ sharedSuffix: suffixLen >= 12 ? sharedSuffix.trim() : ''
150
+ },
151
+ refs: {
152
+ exampleDuplicateTitles: dupGroups.slice(0, 3).map(g => ({
153
+ title: g.title,
154
+ urls: g.urls.slice(0, 5)
155
+ }))
156
+ }
157
+ },
158
+ visibilityFilter: { targetSet: 'acc', accEligible: null, reasons: [] }
159
+ }
160
+ };
161
+
162
+ if (titleEl && helpers && typeof helpers.reportOccurrence === 'function') {
163
+ occurrences.push(helpers.reportOccurrence(titleEl, occBase));
164
+ } else {
165
+ // No node available: keep deterministic fallback snippet (no helper calls).
166
+ occurrences.push({
167
+ ...occBase,
168
+ html: titleEl && titleEl.outerHTML ? String(titleEl.outerHTML).slice(0, 2000) : '<title>(unknown)</title>'
169
+ });
170
+ }
171
+
172
+ // Cross-page analysis is authoritative when present; do not also run single-page heuristics.
173
+ return { ruleId: rule.ruleId, outcome: 'cantTell', severity: rule.defaultSeverity || 'minor', occurrences };
174
+ }
175
+
176
+ // If we had enough pages and found no review signal, there is nothing
177
+ // to flag for manual review.
178
+ return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
179
+ }
180
+
181
+ // Not enough analyzable pages -> notApplicable for cross-page patterns, fall back to single-page logic.
182
+ }
183
+
184
+ // If there's no title (or empty), defer to the hard-fail rule.
185
+ if (!titleEl || titleText.length === 0) {
186
+ return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
187
+ }
188
+
189
+ const GENERIC_TITLES = new Set([
190
+ 'home',
191
+ 'homepage',
192
+ 'welcome',
193
+ 'untitled',
194
+ 'page',
195
+ 'document'
196
+ ]);
197
+
198
+ // Conservative signals:
199
+ // - very short title (likely non-descriptive)
200
+ // - title is one of a small set of generic titles
201
+ const isVeryShort = titleText.length > 0 && titleText.length < 8;
202
+ const isGeneric = GENERIC_TITLES.has(titleLc);
203
+
204
+ // Template-like: "Brand | Home" or "Home - Brand" where the page-specific part is a generic token.
205
+ const templateLike = /\b(home|homepage|welcome)\b\s*(\||-|—|:)\s*.+/i.test(titleText) ||
206
+ /.+\s*(\||-|—|:)\s*\b(home|homepage|welcome)\b/i.test(titleText);
207
+
208
+ if (isGeneric || isVeryShort || templateLike) {
209
+ const reasonCode = isGeneric
210
+ ? 'genericTitle'
211
+ : (isVeryShort ? 'veryShortTitle' : 'templateLikeTitle');
212
+
213
+ const summaryKey =
214
+ reasonCode === 'genericTitle'
215
+ ? 'pageTitlePatterns_summary_cantTell_generic'
216
+ : (reasonCode === 'veryShortTitle'
217
+ ? 'pageTitlePatterns_summary_cantTell_veryShort'
218
+ : 'pageTitlePatterns_summary_cantTell_templateLike');
219
+ const occBase = {
220
+ selector: 'head > title',
221
+ html: '',
222
+ summary:
223
+ 'The page title may not be descriptive enough to identify the page topic or purpose.',
224
+ hint:
225
+ 'Use a more specific title that identifies the page topic or purpose (for example, include the section name or task).',
226
+ i18n: {
227
+ summaryKey,
228
+ hintKey: 'pageTitlePatterns_hint_cantTell',
229
+ params: {}
230
+ },
231
+ data: {
232
+ details: {
233
+ reasonCode,
234
+ titleText
235
+ },
236
+ visibilityFilter: { targetSet: 'acc', accEligible: null, reasons: [] }
237
+ }
238
+ };
239
+
240
+ if (titleEl && helpers && typeof helpers.reportOccurrence === 'function') {
241
+ occurrences.push(helpers.reportOccurrence(titleEl, occBase));
242
+ } else {
243
+ occurrences.push({
244
+ ...occBase,
245
+ html: titleEl && titleEl.outerHTML ? String(titleEl.outerHTML).slice(0, 2000) : '<title>(unknown)</title>'
246
+ });
247
+ }
248
+ }
249
+
250
+ if (applicableCount === 0) {
251
+ return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
252
+ }
253
+
254
+ if (occurrences.length) {
255
+ // Patterns are review signals: cantTell rather than fail.
256
+ return { ruleId: rule.ruleId, outcome: 'cantTell', severity: rule.defaultSeverity || 'minor', occurrences };
257
+ }
258
+
259
+ return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
260
+ }
261
+
262
+ module.exports = { id, meta, runInPage };
@@ -0,0 +1,159 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * @check presentation-role-conflict
5
+ * @atomic true
6
+ * @summary role="presentation"/"none" must not be combined with a global ARIA naming attribute or focusability
7
+ * @standard Best Practices (a widely-used reference engine's classification; no formal WCAG Success Criterion — see ROADMAP.md Tier 1b)
8
+ * @applicability
9
+ * Applies to elements with an explicit role="presentation" or
10
+ * role="none", OR an <img alt=""> (empty alt gives an <img> an implicit
11
+ * presentation role per HTML-AAM, even with no explicit role attribute
12
+ * at all — verified against a widely-used reference engine's own selector
13
+ * for this exact check, `img[alt=''], [role="none"], [role="presentation"]`).
14
+ * @expectation
15
+ * The element does not also carry a WAI-ARIA *global* state/property
16
+ * (aria-label, aria-hidden, aria-describedby, aria-live, aria-current,
17
+ * ... — the full global-attribute set, not just the naming ones), AND
18
+ * is not focusable. Per the WAI-ARIA spec's Presentational Roles
19
+ * Conflict Resolution section, a presentational role is "restored" to
20
+ * the element's implicit semantic role when either condition holds —
21
+ * the presentation/none role silently stops working, contradicting the
22
+ * author's evident intent to hide the element from the accessibility
23
+ * tree.
24
+ * @implementation-notes
25
+ * - Not WCAG-normative — authored as an advisory, cantTell-capped
26
+ * `type: 'manual'` rule; see landmark-banner-is-top-level's
27
+ * header comment for the shared rationale/precedent.
28
+ * - The conflicting-attribute set matches a widely-used reference engine's
29
+ * own `none: ['is-element-focusable', 'has-global-aria-attribute']` condition
30
+ * exactly — `has-global-aria-attribute` checks against the full list of
31
+ * ARIA attributes marked `global: true` in that engine's own standards data
32
+ * (confirmed by reading `standards.ariaAttrs` directly at runtime, not
33
+ * guessed), which is much broader than the 3-attribute naming-only list
34
+ * this rule originally checked (added 2026-07-20, then widened
35
+ * 2026-07-20 after a real page — Slack's homepage has
36
+ * `<img alt="" aria-hidden="">`, both gaps at once: the img[alt=''] case
37
+ * wasn't in the applicability selector at all, and aria-hidden wasn't in
38
+ * the conflicting-attribute set even if it had been).
39
+ * - Deliberately NOT replicating that reference engine's
40
+ * `hasImplicitChromiumRoleMatches` applicability gate, which (per direct
41
+ * probing of the reference engine's runtime) makes its own check
42
+ * inapplicable to role="presentation" on elements with no
43
+ * native implicit role to suppress in the first place (e.g.
44
+ * `<div role="presentation" aria-hidden="true">` — a <div> has no native
45
+ * role, so there's nothing for the presentational role to "conflict"
46
+ * with per that engine's own scope decision). Replicating that would require a
47
+ * full native-implicit-role table and isn't evidenced by any real false
48
+ * positive in this corpus yet — surea11y stays broader/more cautious
49
+ * here rather than narrower, which is the safer direction to diverge in.
50
+ * - Focusability is computed via helpers.getFocusableInfo (native +
51
+ * tabindex), same helper aria-hidden-focus already relies on — a
52
+ * `:disabled` or otherwise non-focusable element is not flagged.
53
+ */
54
+
55
+ const id = 'presentation-role-conflict';
56
+
57
+ const meta = {
58
+ title: 'Presentational role must not conflict with a global ARIA attribute or focusability',
59
+ description: 'Checks that role="presentation"/"none" (including an <img alt=""> implicit presentation role) is not combined with a global ARIA attribute (aria-label, aria-hidden, aria-describedby, ...) or focusability (tabindex/native).',
60
+ i18n: {
61
+ titleKey: 'presentationRoleConflict_title',
62
+ descriptionKey: 'presentationRoleConflict_description'
63
+ },
64
+ helpUrl: null,
65
+ tags: ['best-practice', 'aria', 'structure', 'atomic', 'manual'],
66
+ wcagSc: [],
67
+ normativeMappings: [],
68
+ defaultSeverity: 'minor',
69
+ category: 'robust',
70
+ type: 'manual',
71
+ defaultConfidence: 'medium',
72
+ coverage: {}
73
+ };
74
+
75
+ function runInPage(ctx) {
76
+ const { document, root, helpers, rule } = ctx;
77
+ const safeRoot = root || document;
78
+
79
+ // The full set of ARIA attributes marked `global: true` per the WAI-ARIA
80
+ // spec (confirmed against a widely-used reference engine's own
81
+ // `standards.ariaAttrs` data at runtime, 2026-07-20) — any of these present on a presentational
82
+ // element restores its implicit role, not just the naming ones.
83
+ const CONFLICTING_ATTRS = [
84
+ 'aria-atomic', 'aria-braillelabel', 'aria-brailleroledescription', 'aria-busy',
85
+ 'aria-controls', 'aria-current', 'aria-describedby', 'aria-description', 'aria-details',
86
+ 'aria-disabled', 'aria-dropeffect', 'aria-errormessage', 'aria-flowto', 'aria-grabbed',
87
+ 'aria-haspopup', 'aria-hidden', 'aria-invalid', 'aria-keyshortcuts', 'aria-label',
88
+ 'aria-labelledby', 'aria-live', 'aria-owns', 'aria-relevant', 'aria-roledescription'
89
+ ];
90
+
91
+ const getFocusableInfo = helpers && typeof helpers.getFocusableInfo === 'function' ? helpers.getFocusableInfo : null;
92
+
93
+ const nodes = helpers.queryAllSmart
94
+ ? helpers.queryAllSmart('[role="presentation"], [role="none"], img[alt=""]', safeRoot)
95
+ : helpers.queryAll('[role="presentation"], [role="none"], img[alt=""]', safeRoot);
96
+
97
+ const occurrences = [];
98
+ let applicableCount = 0;
99
+
100
+ for (const el of nodes) {
101
+ if (!el || !el.getAttribute) continue;
102
+
103
+ applicableCount += 1;
104
+
105
+ // Presence, not value truthiness: the WAI-ARIA role-conflict-resolution
106
+ // rule triggers on a global ARIA attribute being SPECIFIED at all, even
107
+ // with an empty value — found on a real site, Slack's homepage has
108
+ // <img alt="" aria-hidden="">, where aria-hidden="" (empty string) is
109
+ // still a specified attribute. A truthy-value check would have missed
110
+ // this even after aria-hidden was added to CONFLICTING_ATTRS above.
111
+ const present = CONFLICTING_ATTRS.filter((attr) => (el.hasAttribute ? el.hasAttribute(attr) : el.getAttribute(attr) != null));
112
+
113
+ let isFocusable = false;
114
+ if (getFocusableInfo) {
115
+ try {
116
+ const fi = getFocusableInfo(el, ctx);
117
+ isFocusable = !!(fi && fi.focusable);
118
+ } catch {
119
+ isFocusable = false;
120
+ }
121
+ }
122
+
123
+ if (!present.length && !isFocusable) continue;
124
+
125
+ const parts = present.slice();
126
+ if (isFocusable) parts.push('focusable');
127
+
128
+ // No explicit role attribute means this matched via the img[alt=""]
129
+ // implicit-presentation case.
130
+ const role = String(el.getAttribute('role') || '').trim().toLowerCase() || 'presentation';
131
+ const stableSelector = helpers.buildSelector ? helpers.buildSelector(el) : 'html';
132
+ const html = helpers.getOuterHtmlSnippet ? helpers.getOuterHtmlSnippet(el) : (el.outerHTML || '');
133
+
134
+ occurrences.push({
135
+ selector: stableSelector,
136
+ html,
137
+ summary: `This role="${role}" element also has a conflicting condition (${parts.join(', ')}), which restores its implicit role and cancels the presentational intent.`,
138
+ hint: 'Remove the conflicting naming attribute(s) and/or focusability (tabindex/native) if the element should stay presentational, or remove role="presentation"/"none" if it should be exposed to assistive technology.',
139
+ i18n: {
140
+ summaryKey: 'presentationRoleConflict_summary_cantTell',
141
+ hintKey: 'presentationRoleConflict_hint_cantTell',
142
+ params: { role, attrs: parts.join(', ') }
143
+ },
144
+ data: {
145
+ details: { reasonCode: 'PRESENTATION_ROLE_CONFLICT', role, conflictingAttrs: present, focusable: isFocusable }
146
+ }
147
+ });
148
+ }
149
+
150
+ if (applicableCount === 0) {
151
+ return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
152
+ }
153
+ if (occurrences.length) {
154
+ return { ruleId: rule.ruleId, outcome: 'cantTell', severity: rule.defaultSeverity || 'minor', occurrences };
155
+ }
156
+ return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
157
+ }
158
+
159
+ module.exports = { id, meta, runInPage };
@@ -0,0 +1,183 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * @check region
5
+ * @atomic true
6
+ * @summary Page content should be contained within a landmark region
7
+ * @standard Best Practices (a widely-used reference engine's classification; no formal WCAG Success Criterion — see ROADMAP.md Tier 1b)
8
+ * @applicability
9
+ * Applies to direct children of <body> that carry visible text content
10
+ * and are not themselves a landmark.
11
+ * @expectation
12
+ * Every top-level piece of page content lives inside a landmark region
13
+ * (main, navigation, banner, contentinfo, complementary, region, form,
14
+ * search), so assistive technology users navigating by landmark do not
15
+ * miss content that was never placed inside one.
16
+ * @implementation-notes
17
+ * - Not WCAG-normative — authored as an advisory, cantTell-capped
18
+ * `type: 'manual'` rule; see landmark-banner-is-top-level's
19
+ * header comment for the shared rationale/precedent and the landmark-
20
+ * detection model.
21
+ * - Deliberately scoped to DIRECT children of <body> only (not a full
22
+ * recursive scan of every text node on the page) — a widely-used
23
+ * reference engine's own region rule is known to be noisy in practice when applied
24
+ * unconditionally; checking only the top level keeps false positives
25
+ * low while still catching the common case (a page section authored
26
+ * entirely outside any landmark).
27
+ */
28
+
29
+ const id = 'region';
30
+
31
+ const meta = {
32
+ title: 'Page content should be inside a landmark region',
33
+ description: 'Checks that direct children of <body> with visible text content are contained within a landmark region.',
34
+ i18n: {
35
+ titleKey: 'region_title',
36
+ descriptionKey: 'region_description'
37
+ },
38
+ helpUrl: null,
39
+ tags: ['best-practice', 'landmarks', 'structure', 'atomic', 'manual'],
40
+ wcagSc: [],
41
+ normativeMappings: [],
42
+ defaultSeverity: 'minor',
43
+ category: 'operable',
44
+ type: 'manual',
45
+ defaultConfidence: 'medium',
46
+ coverage: {}
47
+ };
48
+
49
+ function runInPage(ctx) {
50
+ const { document, helpers, rule } = ctx;
51
+
52
+ const body = document && document.body ? document.body : null;
53
+ if (!body) {
54
+ return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
55
+ }
56
+
57
+ function normalizeWs(s) {
58
+ return String(s || '').replace(/\s+/g, ' ').trim();
59
+ }
60
+
61
+ // Delegates to the shared helpers.getLandmarkNameInfo (aria-label -> aria-labelledby, via the
62
+ // target's own accessible name, not raw textContent -> title attribute fallback) rather than a
63
+ // local copy -- see that function's header comment in src/core/dom-helpers.js for the real bug
64
+ // (missing title fallback) this replaced across all 7 landmark rule files that had their own
65
+ // copy of this logic.
66
+ function getAccessibleLandmarkName(el) {
67
+ try {
68
+ if (helpers && typeof helpers.getLandmarkNameInfo === 'function') {
69
+ const info = helpers.getLandmarkNameInfo(el, ctx);
70
+ if (info && info.present && info.value) return normalizeWs(info.value);
71
+ }
72
+ } catch {}
73
+ return '';
74
+ }
75
+
76
+ function getExplicitRoleToken(el) {
77
+ const raw = normalizeWs(el.getAttribute && el.getAttribute('role'));
78
+ if (!raw) return '';
79
+ return raw.split(/\s+/)[0].toLowerCase();
80
+ }
81
+
82
+ const SECTIONING_ANCESTORS = new Set(['article', 'aside', 'main', 'nav', 'section']);
83
+
84
+ function isSuppressedBySectioningAncestor(el) {
85
+ let p = el.parentElement;
86
+ while (p) {
87
+ const tag = p.tagName ? p.tagName.toLowerCase() : '';
88
+ if (SECTIONING_ANCESTORS.has(tag)) return true;
89
+ p = p.parentElement;
90
+ }
91
+ return false;
92
+ }
93
+
94
+ function getImplicitLandmarkRole(el) {
95
+ const tag = el.tagName ? el.tagName.toLowerCase() : '';
96
+ if (tag === 'header') return isSuppressedBySectioningAncestor(el) ? '' : 'banner';
97
+ if (tag === 'footer') return isSuppressedBySectioningAncestor(el) ? '' : 'contentinfo';
98
+ if (tag === 'main') return 'main';
99
+ if (tag === 'nav') return 'navigation';
100
+ if (tag === 'aside') return isSuppressedBySectioningAncestor(el) ? '' : 'complementary';
101
+ if (tag === 'section') return getAccessibleLandmarkName(el) ? 'region' : '';
102
+ if (tag === 'form') return getAccessibleLandmarkName(el) ? 'form' : '';
103
+ return '';
104
+ }
105
+
106
+ const LANDMARK_ROLES = new Set(['banner', 'contentinfo', 'main', 'navigation', 'complementary', 'region', 'form', 'search']);
107
+
108
+ function isLandmark(el) {
109
+ if (!el || !el.getAttribute) return false;
110
+ const explicit = getExplicitRoleToken(el);
111
+ if (explicit) return LANDMARK_ROLES.has(explicit);
112
+ return !!getImplicitLandmarkRole(el);
113
+ }
114
+
115
+ const SKIP_TAGS = new Set(['script', 'style', 'template', 'noscript', 'link', 'meta', 'title']);
116
+
117
+ function isIneligible(el) {
118
+ if (el.getAttribute && el.getAttribute('aria-hidden') === 'true') return true;
119
+ if (el.hasAttribute && el.hasAttribute('hidden')) return true;
120
+ return false;
121
+ }
122
+
123
+ function getConservativeSubtreeText(container) {
124
+ const SHOW_TEXT = 4;
125
+ try {
126
+ const walker = document.createTreeWalker(container, SHOW_TEXT, null);
127
+ const parts = [];
128
+ let n = walker.nextNode();
129
+ while (n) {
130
+ const raw = normalizeWs(n.nodeValue || '');
131
+ if (raw) parts.push(raw);
132
+ n = walker.nextNode();
133
+ }
134
+ return normalizeWs(parts.join(' '));
135
+ } catch {
136
+ return normalizeWs(container.textContent);
137
+ }
138
+ }
139
+
140
+ const occurrences = [];
141
+ let applicableCount = 0;
142
+
143
+ for (const el of body.children || []) {
144
+ if (!el || !el.tagName) continue;
145
+ const tag = el.tagName.toLowerCase();
146
+ if (SKIP_TAGS.has(tag)) continue;
147
+ if (isIneligible(el)) continue;
148
+ if (isLandmark(el)) continue;
149
+
150
+ const text = getConservativeSubtreeText(el);
151
+ if (!text) continue;
152
+
153
+ applicableCount += 1;
154
+
155
+ const stableSelector = helpers.buildSelector ? helpers.buildSelector(el) : 'html';
156
+ const html = helpers.getOuterHtmlSnippet ? helpers.getOuterHtmlSnippet(el) : (el.outerHTML || '');
157
+
158
+ occurrences.push({
159
+ selector: stableSelector,
160
+ html,
161
+ summary: 'This content is not contained within a landmark region.',
162
+ hint: 'Move this content inside a landmark region (main, nav, aside, a labeled section, etc.).',
163
+ i18n: {
164
+ summaryKey: 'region_summary_cantTell',
165
+ hintKey: 'region_hint_cantTell',
166
+ params: { element: tag }
167
+ },
168
+ data: {
169
+ details: { reasonCode: 'CONTENT_OUTSIDE_LANDMARK', element: tag }
170
+ }
171
+ });
172
+ }
173
+
174
+ if (applicableCount === 0) {
175
+ return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
176
+ }
177
+ if (occurrences.length) {
178
+ return { ruleId: rule.ruleId, outcome: 'cantTell', severity: rule.defaultSeverity || 'minor', occurrences };
179
+ }
180
+ return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
181
+ }
182
+
183
+ module.exports = { id, meta, runInPage };