@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,177 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * @check landmark-no-duplicate-banner
5
+ * @atomic true
6
+ * @summary A page must not have more than one banner landmark
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 whenever the page contains at least one banner landmark
10
+ * (explicit role="banner", or an implicit, non-nested <header> — see
11
+ * landmark-banner-is-top-level's implementation notes for the
12
+ * shared landmark-detection model).
13
+ * @expectation
14
+ * At most one banner landmark exists on the page. Per WAI-ARIA
15
+ * Authoring Practices, the banner landmark represents site-oriented
16
+ * content that identifies the page as a whole — having more than one
17
+ * is ambiguous for assistive technology users navigating by landmark.
18
+ * @implementation-notes
19
+ * - Not WCAG-normative — authored as an advisory, cantTell-capped
20
+ * `type: 'manual'` rule; see landmark-banner-is-top-level's
21
+ * header comment for the shared rationale/precedent.
22
+ * - Flags every banner instance (not just the "extra" ones) when more
23
+ * than one exists, since which instance is "correct" is ambiguous.
24
+ * - Only landmarks actually exposed to assistive technology can collide —
25
+ * matches a widely-used reference engine's own `page-no-duplicate` check (confirmed by reading
26
+ * its source directly: `query_selector_all_filter_default(..., elm =>
27
+ * _isVisibleToScreenReaders(elm))`). Without this, a responsive layout
28
+ * rendering both a visible and a CSS-hidden duplicate `<header>` (found
29
+ * on a real site — Trello's homepage, a desktop/mobile header pair) was
30
+ * wrongly flagged as a duplicate landmark; the hidden copy is never
31
+ * actually reachable by AT.
32
+ */
33
+
34
+ const id = 'landmark-no-duplicate-banner';
35
+
36
+ const meta = {
37
+ title: 'Page must not have more than one banner landmark',
38
+ description: 'Checks that at most one banner landmark (role="banner" or a non-nested <header>) exists on the page.',
39
+ i18n: {
40
+ titleKey: 'landmarkNoDuplicateBanner_title',
41
+ descriptionKey: 'landmarkNoDuplicateBanner_description'
42
+ },
43
+ helpUrl: null,
44
+ tags: ['best-practice', 'landmarks', 'structure', 'atomic', 'manual'],
45
+ wcagSc: [],
46
+ normativeMappings: [],
47
+ defaultSeverity: 'minor',
48
+ category: 'operable',
49
+ type: 'manual',
50
+ defaultConfidence: 'medium',
51
+ coverage: {}
52
+ };
53
+
54
+ function runInPage(ctx) {
55
+ const { document, helpers, rule } = ctx;
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 getLandmarkRole(el) {
109
+ if (!el || !el.getAttribute) return '';
110
+ const explicit = getExplicitRoleToken(el);
111
+ if (explicit) return LANDMARK_ROLES.has(explicit) ? explicit : '';
112
+ return getImplicitLandmarkRole(el);
113
+ }
114
+
115
+ // queryAllSmart (shadow-DOM-aware) instead of plain document.querySelectorAll -- see
116
+ // landmark-unique-manual.js's header comment for the real page (Airtable, 2026-07-23)
117
+ // that surfaced this gap: a third-party shadow-DOM-hosted widget's own landmark is
118
+ // invisible to a light-DOM-only query.
119
+ let nodes = [];
120
+ try {
121
+ nodes = helpers && typeof helpers.queryAllSmart === 'function'
122
+ ? helpers.queryAllSmart('header, footer, main, nav, aside, section, form, [role]')
123
+ : document.querySelectorAll('header, footer, main, nav, aside, section, form, [role]');
124
+ } catch {
125
+ nodes = [];
126
+ }
127
+
128
+ const isAccTreeEligible = helpers && typeof helpers.isAccTreeEligible === 'function' ? helpers.isAccTreeEligible : null;
129
+
130
+ function isExposedToAt(el) {
131
+ if (!isAccTreeEligible) return true;
132
+ try {
133
+ const r = isAccTreeEligible(el, ctx);
134
+ if (typeof r === 'boolean') return r;
135
+ return !!(r && r.eligible);
136
+ } catch {
137
+ return true;
138
+ }
139
+ }
140
+
141
+ const banners = [];
142
+ const seen = new Set();
143
+ for (const el of nodes) {
144
+ if (!el || seen.has(el)) continue;
145
+ seen.add(el);
146
+ if (!isExposedToAt(el)) continue;
147
+ if (getLandmarkRole(el) === 'banner') banners.push(el);
148
+ }
149
+
150
+ if (banners.length <= 1) {
151
+ return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
152
+ }
153
+
154
+ const occurrences = banners.map((el) => {
155
+ const stableSelector = helpers.buildSelector ? helpers.buildSelector(el) : 'html';
156
+ const html = helpers.getOuterHtmlSnippet ? helpers.getOuterHtmlSnippet(el) : (el.outerHTML || '');
157
+
158
+ return {
159
+ selector: stableSelector,
160
+ html,
161
+ summary: 'This page has more than one banner landmark.',
162
+ hint: 'Keep only one banner landmark (header/role="banner") per page.',
163
+ i18n: {
164
+ summaryKey: 'landmarkNoDuplicateBanner_summary_cantTell',
165
+ hintKey: 'landmarkNoDuplicateBanner_hint_cantTell',
166
+ params: { count: String(banners.length) }
167
+ },
168
+ data: {
169
+ details: { reasonCode: 'LANDMARK_DUPLICATE_BANNER', count: banners.length }
170
+ }
171
+ };
172
+ });
173
+
174
+ return { ruleId: rule.ruleId, outcome: 'cantTell', severity: rule.defaultSeverity || 'minor', occurrences };
175
+ }
176
+
177
+ module.exports = { id, meta, runInPage };
@@ -0,0 +1,169 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * @check landmark-no-duplicate-contentinfo
5
+ * @atomic true
6
+ * @summary A page must not have more than one contentinfo landmark
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 whenever the page contains at least one contentinfo landmark
10
+ * (explicit role="contentinfo", or an implicit, non-nested <footer>).
11
+ * @expectation
12
+ * At most one contentinfo landmark exists on the page — mirrors
13
+ * landmark-no-duplicate-banner's rationale for contentinfo.
14
+ * @implementation-notes
15
+ * - Not WCAG-normative — authored as an advisory, cantTell-capped
16
+ * `type: 'manual'` rule; see landmark-banner-is-top-level's
17
+ * header comment for the shared rationale/precedent.
18
+ * - Only landmarks actually exposed to assistive technology can collide —
19
+ * matches a widely-used reference engine's own `page-no-duplicate` check (confirmed by reading
20
+ * its source directly: `query_selector_all_filter_default(..., elm =>
21
+ * _isVisibleToScreenReaders(elm))`) — same fix applied to the sibling
22
+ * banner/main rules after finding real hidden-duplicate false positives
23
+ * on Trello and Zoom.
24
+ */
25
+
26
+ const id = 'landmark-no-duplicate-contentinfo';
27
+
28
+ const meta = {
29
+ title: 'Page must not have more than one contentinfo landmark',
30
+ description: 'Checks that at most one contentinfo landmark (role="contentinfo" or a non-nested <footer>) exists on the page.',
31
+ i18n: {
32
+ titleKey: 'landmarkNoDuplicateContentinfo_title',
33
+ descriptionKey: 'landmarkNoDuplicateContentinfo_description'
34
+ },
35
+ helpUrl: null,
36
+ tags: ['best-practice', 'landmarks', 'structure', 'atomic', 'manual'],
37
+ wcagSc: [],
38
+ normativeMappings: [],
39
+ defaultSeverity: 'minor',
40
+ category: 'operable',
41
+ type: 'manual',
42
+ defaultConfidence: 'medium',
43
+ coverage: {}
44
+ };
45
+
46
+ function runInPage(ctx) {
47
+ const { document, helpers, rule } = ctx;
48
+
49
+ function normalizeWs(s) {
50
+ return String(s || '').replace(/\s+/g, ' ').trim();
51
+ }
52
+
53
+ // Delegates to the shared helpers.getLandmarkNameInfo (aria-label -> aria-labelledby, via the
54
+ // target's own accessible name, not raw textContent -> title attribute fallback) rather than a
55
+ // local copy -- see that function's header comment in src/core/dom-helpers.js for the real bug
56
+ // (missing title fallback) this replaced across all 7 landmark rule files that had their own
57
+ // copy of this logic.
58
+ function getAccessibleLandmarkName(el) {
59
+ try {
60
+ if (helpers && typeof helpers.getLandmarkNameInfo === 'function') {
61
+ const info = helpers.getLandmarkNameInfo(el, ctx);
62
+ if (info && info.present && info.value) return normalizeWs(info.value);
63
+ }
64
+ } catch {}
65
+ return '';
66
+ }
67
+
68
+ function getExplicitRoleToken(el) {
69
+ const raw = normalizeWs(el.getAttribute && el.getAttribute('role'));
70
+ if (!raw) return '';
71
+ return raw.split(/\s+/)[0].toLowerCase();
72
+ }
73
+
74
+ const SECTIONING_ANCESTORS = new Set(['article', 'aside', 'main', 'nav', 'section']);
75
+
76
+ function isSuppressedBySectioningAncestor(el) {
77
+ let p = el.parentElement;
78
+ while (p) {
79
+ const tag = p.tagName ? p.tagName.toLowerCase() : '';
80
+ if (SECTIONING_ANCESTORS.has(tag)) return true;
81
+ p = p.parentElement;
82
+ }
83
+ return false;
84
+ }
85
+
86
+ function getImplicitLandmarkRole(el) {
87
+ const tag = el.tagName ? el.tagName.toLowerCase() : '';
88
+ if (tag === 'header') return isSuppressedBySectioningAncestor(el) ? '' : 'banner';
89
+ if (tag === 'footer') return isSuppressedBySectioningAncestor(el) ? '' : 'contentinfo';
90
+ if (tag === 'main') return 'main';
91
+ if (tag === 'nav') return 'navigation';
92
+ if (tag === 'aside') return isSuppressedBySectioningAncestor(el) ? '' : 'complementary';
93
+ if (tag === 'section') return getAccessibleLandmarkName(el) ? 'region' : '';
94
+ if (tag === 'form') return getAccessibleLandmarkName(el) ? 'form' : '';
95
+ return '';
96
+ }
97
+
98
+ const LANDMARK_ROLES = new Set(['banner', 'contentinfo', 'main', 'navigation', 'complementary', 'region', 'form', 'search']);
99
+
100
+ function getLandmarkRole(el) {
101
+ if (!el || !el.getAttribute) return '';
102
+ const explicit = getExplicitRoleToken(el);
103
+ if (explicit) return LANDMARK_ROLES.has(explicit) ? explicit : '';
104
+ return getImplicitLandmarkRole(el);
105
+ }
106
+
107
+ // queryAllSmart (shadow-DOM-aware) instead of plain document.querySelectorAll -- see
108
+ // landmark-unique-manual.js's header comment for the real page (Airtable, 2026-07-23)
109
+ // that surfaced this gap: a third-party shadow-DOM-hosted widget's own landmark is
110
+ // invisible to a light-DOM-only query.
111
+ let nodes = [];
112
+ try {
113
+ nodes = helpers && typeof helpers.queryAllSmart === 'function'
114
+ ? helpers.queryAllSmart('header, footer, main, nav, aside, section, form, [role]')
115
+ : document.querySelectorAll('header, footer, main, nav, aside, section, form, [role]');
116
+ } catch {
117
+ nodes = [];
118
+ }
119
+
120
+ const isAccTreeEligible = helpers && typeof helpers.isAccTreeEligible === 'function' ? helpers.isAccTreeEligible : null;
121
+
122
+ function isExposedToAt(el) {
123
+ if (!isAccTreeEligible) return true;
124
+ try {
125
+ const r = isAccTreeEligible(el, ctx);
126
+ if (typeof r === 'boolean') return r;
127
+ return !!(r && r.eligible);
128
+ } catch {
129
+ return true;
130
+ }
131
+ }
132
+
133
+ const contentinfos = [];
134
+ const seen = new Set();
135
+ for (const el of nodes) {
136
+ if (!el || seen.has(el)) continue;
137
+ seen.add(el);
138
+ if (!isExposedToAt(el)) continue;
139
+ if (getLandmarkRole(el) === 'contentinfo') contentinfos.push(el);
140
+ }
141
+
142
+ if (contentinfos.length <= 1) {
143
+ return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
144
+ }
145
+
146
+ const occurrences = contentinfos.map((el) => {
147
+ const stableSelector = helpers.buildSelector ? helpers.buildSelector(el) : 'html';
148
+ const html = helpers.getOuterHtmlSnippet ? helpers.getOuterHtmlSnippet(el) : (el.outerHTML || '');
149
+
150
+ return {
151
+ selector: stableSelector,
152
+ html,
153
+ summary: 'This page has more than one contentinfo landmark.',
154
+ hint: 'Keep only one contentinfo landmark (footer/role="contentinfo") per page.',
155
+ i18n: {
156
+ summaryKey: 'landmarkNoDuplicateContentinfo_summary_cantTell',
157
+ hintKey: 'landmarkNoDuplicateContentinfo_hint_cantTell',
158
+ params: { count: String(contentinfos.length) }
159
+ },
160
+ data: {
161
+ details: { reasonCode: 'LANDMARK_DUPLICATE_CONTENTINFO', count: contentinfos.length }
162
+ }
163
+ };
164
+ });
165
+
166
+ return { ruleId: rule.ruleId, outcome: 'cantTell', severity: rule.defaultSeverity || 'minor', occurrences };
167
+ }
168
+
169
+ module.exports = { id, meta, runInPage };
@@ -0,0 +1,132 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * @check landmark-no-duplicate-main
5
+ * @atomic true
6
+ * @summary A page must not have more than one main landmark
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 whenever the page contains at least one main landmark
10
+ * (explicit role="main", or an implicit <main>).
11
+ * @expectation
12
+ * At most one main landmark exists on the page. Distinct, atomic
13
+ * decision from landmark-one-main (that rule flags zero
14
+ * mains too; this one only flags more than one) — matches a widely-used
15
+ * reference engine shipping both as separate rules with some overlap by design.
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.
20
+ * - Only landmarks actually exposed to assistive technology can collide —
21
+ * matches a widely-used reference engine's own `page-no-duplicate` check (confirmed by reading
22
+ * its source directly: `query_selector_all_filter_default(..., elm =>
23
+ * _isVisibleToScreenReaders(elm))`). Without this, a responsive layout
24
+ * rendering both a visible and a CSS-hidden duplicate `<main>` (found on
25
+ * a real site — Zoom's homepage) was wrongly flagged as a duplicate
26
+ * landmark; the hidden copy is never actually reachable by AT.
27
+ */
28
+
29
+ const id = 'landmark-no-duplicate-main';
30
+
31
+ const meta = {
32
+ title: 'Page must not have more than one main landmark',
33
+ description: 'Checks that at most one main landmark (role="main" or <main>) exists on the page.',
34
+ i18n: {
35
+ titleKey: 'landmarkNoDuplicateMain_title',
36
+ descriptionKey: 'landmarkNoDuplicateMain_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
+ function normalizeWs(s) {
53
+ return String(s || '').replace(/\s+/g, ' ').trim();
54
+ }
55
+
56
+ function getExplicitRoleToken(el) {
57
+ const raw = normalizeWs(el.getAttribute && el.getAttribute('role'));
58
+ if (!raw) return '';
59
+ return raw.split(/\s+/)[0].toLowerCase();
60
+ }
61
+
62
+ function getLandmarkRole(el) {
63
+ if (!el || !el.getAttribute) return '';
64
+ const explicit = getExplicitRoleToken(el);
65
+ if (explicit) return explicit === 'main' ? 'main' : '';
66
+ const tag = el.tagName ? el.tagName.toLowerCase() : '';
67
+ return tag === 'main' ? 'main' : '';
68
+ }
69
+
70
+ // queryAllSmart (shadow-DOM-aware) instead of plain document.querySelectorAll -- see
71
+ // landmark-unique-manual.js's header comment for the real page (Airtable, 2026-07-23)
72
+ // that surfaced this gap: a third-party shadow-DOM-hosted widget's own landmark is
73
+ // invisible to a light-DOM-only query.
74
+ let nodes = [];
75
+ try {
76
+ nodes = helpers && typeof helpers.queryAllSmart === 'function'
77
+ ? helpers.queryAllSmart('main, [role]')
78
+ : document.querySelectorAll('main, [role]');
79
+ } catch {
80
+ nodes = [];
81
+ }
82
+
83
+ const isAccTreeEligible = helpers && typeof helpers.isAccTreeEligible === 'function' ? helpers.isAccTreeEligible : null;
84
+
85
+ function isExposedToAt(el) {
86
+ if (!isAccTreeEligible) return true;
87
+ try {
88
+ const r = isAccTreeEligible(el, ctx);
89
+ if (typeof r === 'boolean') return r;
90
+ return !!(r && r.eligible);
91
+ } catch {
92
+ return true;
93
+ }
94
+ }
95
+
96
+ const mains = [];
97
+ const seen = new Set();
98
+ for (const el of nodes) {
99
+ if (!el || seen.has(el)) continue;
100
+ seen.add(el);
101
+ if (!isExposedToAt(el)) continue;
102
+ if (getLandmarkRole(el) === 'main') mains.push(el);
103
+ }
104
+
105
+ if (mains.length <= 1) {
106
+ return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
107
+ }
108
+
109
+ const occurrences = mains.map((el) => {
110
+ const stableSelector = helpers.buildSelector ? helpers.buildSelector(el) : 'html';
111
+ const html = helpers.getOuterHtmlSnippet ? helpers.getOuterHtmlSnippet(el) : (el.outerHTML || '');
112
+
113
+ return {
114
+ selector: stableSelector,
115
+ html,
116
+ summary: 'This page has more than one main landmark.',
117
+ hint: 'Keep only one main landmark (<main>/role="main") per page.',
118
+ i18n: {
119
+ summaryKey: 'landmarkNoDuplicateMain_summary_cantTell',
120
+ hintKey: 'landmarkNoDuplicateMain_hint_cantTell',
121
+ params: { count: String(mains.length) }
122
+ },
123
+ data: {
124
+ details: { reasonCode: 'LANDMARK_DUPLICATE_MAIN', count: mains.length }
125
+ }
126
+ };
127
+ });
128
+
129
+ return { ruleId: rule.ruleId, outcome: 'cantTell', severity: rule.defaultSeverity || 'minor', occurrences };
130
+ }
131
+
132
+ module.exports = { id, meta, runInPage };
@@ -0,0 +1,151 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * @check landmark-one-main
5
+ * @atomic true
6
+ * @summary The page should have a main landmark
7
+ * @standard Best Practices (a widely-used reference engine's classification; no formal WCAG Success Criterion — see ROADMAP.md Tier 1b)
8
+ * @applicability
9
+ * Always applicable to any HTML document with a <body> element —
10
+ * "does the page have a main landmark" is a whole-page concern,
11
+ * matching bypass-blocks-present's pattern of evaluating the
12
+ * document directly.
13
+ * @expectation
14
+ * At least one main landmark (role="main" or <main>), exposed to
15
+ * assistive technology, exists on the page — a page with none gives
16
+ * AT users no landmark to jump straight to for the primary content.
17
+ * @implementation-notes
18
+ * - Not WCAG-normative — authored as an advisory, cantTell-capped
19
+ * `type: 'manual'` rule; see landmark-banner-is-top-level's
20
+ * header comment for the shared rationale/precedent.
21
+ * - Presence-only, matching a widely-used reference engine's real
22
+ * `landmark-one-main` scope exactly (confirmed 2026-07-22 by reading that
23
+ * engine's source directly: its `page-has-main` check is a plain
24
+ * descendant-exists test, `has-descendant-evaluate` — it does NOT flag
25
+ * more than one). This rule previously ALSO flagged "more than one main,"
26
+ * which was both a real scope mismatch against that reference engine (it
27
+ * ships that as a fully separate rule, `landmark-no-duplicate-main` /
28
+ * `page-no-duplicate-main`, already correctly implemented here as
29
+ * `landmark-no-duplicate-main`) and missing that sibling rule's
30
+ * accessibility-tree visibility filter, so it double-flagged cases the
31
+ * sibling rule already handles correctly — found via a real page
32
+ * (2026-07-22, live-DOM corpus): Resy's and DuckDuckGo's homepages each
33
+ * genuinely have two visible `<main>` elements, which that reference
34
+ * engine's `landmark-one-main` doesn't flag at all (out of its scope) but
35
+ * this rule wrongly did, disagreeing with it for a
36
+ * reason that wasn't a real coverage gap on either side — just a
37
+ * redundant, incorrectly-scoped extra branch here.
38
+ * - Filters candidates through `isAccTreeEligible` (hidden/aria-hidden/
39
+ * display:none/inert elements don't count as "a main landmark exists"),
40
+ * matching `landmark-no-duplicate-main`'s own precedent and
41
+ * that reference engine's own accessibility-tree-scoped matching.
42
+ */
43
+
44
+ const id = 'landmark-one-main';
45
+
46
+ const meta = {
47
+ title: 'Page should have a main landmark',
48
+ description: 'Checks that the page has at least one main landmark (role="main" or <main>).',
49
+ i18n: {
50
+ titleKey: 'landmarkOneMain_title',
51
+ descriptionKey: 'landmarkOneMain_description'
52
+ },
53
+ helpUrl: null,
54
+ tags: ['best-practice', 'landmarks', 'structure', 'atomic', 'manual'],
55
+ wcagSc: [],
56
+ normativeMappings: [],
57
+ defaultSeverity: 'minor',
58
+ category: 'operable',
59
+ type: 'manual',
60
+ defaultConfidence: 'medium',
61
+ coverage: {}
62
+ };
63
+
64
+ function runInPage(ctx) {
65
+ const { document, helpers, rule } = ctx;
66
+
67
+ const body = document && document.body ? document.body : null;
68
+ if (!body) {
69
+ return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
70
+ }
71
+
72
+ function getExplicitRoleToken(el) {
73
+ const raw = (el.getAttribute && el.getAttribute('role') || '').trim();
74
+ if (!raw) return '';
75
+ return raw.split(/\s+/)[0].toLowerCase();
76
+ }
77
+
78
+ function isMainLandmark(el) {
79
+ if (!el || !el.getAttribute) return false;
80
+ const explicit = getExplicitRoleToken(el);
81
+ if (explicit) return explicit === 'main';
82
+ const tag = el.tagName ? el.tagName.toLowerCase() : '';
83
+ return tag === 'main';
84
+ }
85
+
86
+ const isAccTreeEligible = helpers && typeof helpers.isAccTreeEligible === 'function' ? helpers.isAccTreeEligible : null;
87
+
88
+ function isExposedToAt(el) {
89
+ if (!isAccTreeEligible) return true;
90
+ try {
91
+ const r = isAccTreeEligible(el, ctx);
92
+ if (typeof r === 'boolean') return r;
93
+ return !!(r && r.eligible);
94
+ } catch {
95
+ return true;
96
+ }
97
+ }
98
+
99
+ // queryAllSmart (shadow-DOM-aware) instead of plain document.querySelectorAll -- see
100
+ // landmark-unique-manual.js's header comment for the real page (Airtable, 2026-07-23)
101
+ // that surfaced this gap: a third-party shadow-DOM-hosted widget's own landmark is
102
+ // invisible to a light-DOM-only query.
103
+ let nodes = [];
104
+ try {
105
+ nodes = helpers && typeof helpers.queryAllSmart === 'function'
106
+ ? helpers.queryAllSmart('main, [role]')
107
+ : document.querySelectorAll('main, [role]');
108
+ } catch {
109
+ nodes = [];
110
+ }
111
+
112
+ let hasMain = false;
113
+ const seen = new Set();
114
+ for (const el of nodes) {
115
+ if (!el || seen.has(el)) continue;
116
+ seen.add(el);
117
+ if (!isMainLandmark(el)) continue;
118
+ if (!isExposedToAt(el)) continue;
119
+ hasMain = true;
120
+ break;
121
+ }
122
+
123
+ if (hasMain) {
124
+ return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
125
+ }
126
+
127
+ const stableSelector = helpers.buildSelector ? helpers.buildSelector(body) : 'body';
128
+ const html = helpers.getOuterHtmlSnippet ? helpers.getOuterHtmlSnippet(body) : (body.outerHTML || '').slice(0, 200);
129
+
130
+ return {
131
+ ruleId: rule.ruleId,
132
+ outcome: 'cantTell',
133
+ severity: rule.defaultSeverity || 'minor',
134
+ occurrences: [{
135
+ selector: stableSelector,
136
+ html,
137
+ summary: 'This page has no main landmark.',
138
+ hint: 'Add a main landmark (<main> or role="main") around the page\'s primary content.',
139
+ i18n: {
140
+ summaryKey: 'landmarkOneMain_summary_cantTell_missing',
141
+ hintKey: 'landmarkOneMain_hint_cantTell_missing',
142
+ params: {}
143
+ },
144
+ data: {
145
+ details: { reasonCode: 'LANDMARK_MAIN_MISSING' }
146
+ }
147
+ }]
148
+ };
149
+ }
150
+
151
+ module.exports = { id, meta, runInPage };