@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,115 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * @check label-title-only
5
+ * @atomic true
6
+ * @summary Form controls should not rely on the title attribute as their only label
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 labelable form controls (input, excluding
10
+ * hidden/submit/reset/button/image; select; textarea) that have a
11
+ * non-empty title attribute.
12
+ * @expectation
13
+ * The control also has a real label — a wrapping/associated <label>,
14
+ * aria-label, or aria-labelledby — rather than depending on the title
15
+ * attribute alone. A title-only tooltip is not reliably exposed by all
16
+ * assistive technology and is not visible at all until hover/focus,
17
+ * unlike a persistent visible label.
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
+ * - Distinct from form-control-programmatic-label-present (that
23
+ * rule accepts title as one of several valid presence mechanisms;
24
+ * this rule flags the narrower case where title is the ONLY one).
25
+ */
26
+
27
+ const id = 'label-title-only';
28
+
29
+ const meta = {
30
+ title: 'Form controls should not use title as their only label',
31
+ description: 'Checks that a form control with a title attribute also has a real label (label element, aria-label, or aria-labelledby).',
32
+ i18n: {
33
+ titleKey: 'labelTitleOnly_title',
34
+ descriptionKey: 'labelTitleOnly_description'
35
+ },
36
+ helpUrl: null,
37
+ tags: ['best-practice', 'forms', 'structure', 'atomic', 'manual'],
38
+ wcagSc: [],
39
+ normativeMappings: [],
40
+ defaultSeverity: 'minor',
41
+ category: 'understandable',
42
+ type: 'manual',
43
+ defaultConfidence: 'medium',
44
+ coverage: {}
45
+ };
46
+
47
+ function runInPage(ctx) {
48
+ const { document, root, helpers, rule } = ctx;
49
+ const safeRoot = root || document;
50
+
51
+ const selector = 'input:not([type="hidden"]):not([type="submit"]):not([type="reset"]):not([type="button"]):not([type="image"]),select,textarea';
52
+ const nodes = helpers.queryAllSmart ? helpers.queryAllSmart(selector, safeRoot) : helpers.queryAll(selector, safeRoot);
53
+
54
+ const labelsByFor = new Map();
55
+ const allLabels = document.getElementsByTagName ? document.getElementsByTagName('label') : [];
56
+ for (const lab of allLabels) {
57
+ if (!lab || !lab.getAttribute) continue;
58
+ const forValue = String(lab.getAttribute('for') || '').trim();
59
+ if (!forValue) continue;
60
+ if (!labelsByFor.has(forValue)) labelsByFor.set(forValue, []);
61
+ labelsByFor.get(forValue).push(lab);
62
+ }
63
+
64
+ const occurrences = [];
65
+ let applicableCount = 0;
66
+
67
+ for (const el of nodes) {
68
+ if (!el || !el.getAttribute) continue;
69
+
70
+ const title = String(el.getAttribute('title') || '').trim();
71
+ if (!title) continue;
72
+
73
+ applicableCount += 1;
74
+
75
+ const ariaLabel = String(el.getAttribute('aria-label') || '').trim();
76
+ if (ariaLabel) continue;
77
+ const ariaLabelledby = String(el.getAttribute('aria-labelledby') || '').trim();
78
+ if (ariaLabelledby) continue;
79
+
80
+ const wrappingLabel = el.closest ? el.closest('label') : null;
81
+ if (wrappingLabel) continue;
82
+
83
+ const controlId = String(el.getAttribute('id') || '').trim();
84
+ if (controlId && labelsByFor.has(controlId)) continue;
85
+
86
+ const tag = el.tagName.toLowerCase();
87
+ const stableSelector = helpers.buildSelector ? helpers.buildSelector(el) : 'html';
88
+ const html = helpers.getOuterHtmlSnippet ? helpers.getOuterHtmlSnippet(el) : (el.outerHTML || '');
89
+
90
+ occurrences.push({
91
+ selector: stableSelector,
92
+ html,
93
+ summary: 'This form control relies on the title attribute as its only label.',
94
+ hint: 'Add a visible <label> (or aria-label/aria-labelledby) in addition to, or instead of, the title attribute.',
95
+ i18n: {
96
+ summaryKey: 'labelTitleOnly_summary_cantTell',
97
+ hintKey: 'labelTitleOnly_hint_cantTell',
98
+ params: { element: tag }
99
+ },
100
+ data: {
101
+ details: { reasonCode: 'LABEL_TITLE_ONLY', element: tag }
102
+ }
103
+ });
104
+ }
105
+
106
+ if (applicableCount === 0) {
107
+ return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
108
+ }
109
+ if (occurrences.length) {
110
+ return { ruleId: rule.ruleId, outcome: 'cantTell', severity: rule.defaultSeverity || 'minor', occurrences };
111
+ }
112
+ return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
113
+ }
114
+
115
+ module.exports = { id, meta, runInPage };
@@ -0,0 +1,180 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * @check landmark-banner-is-top-level
5
+ * @atomic true
6
+ * @summary The banner landmark must not be nested inside another 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 <header> that is not itself
11
+ * nested inside <article>/<aside>/<main>/<nav>/<section> — see
12
+ * implementation notes).
13
+ * @expectation
14
+ * No banner landmark has an ancestor that is itself any landmark
15
+ * region. A banner nested inside another landmark is not a top-level,
16
+ * whole-page banner and confuses landmark-based navigation for
17
+ * assistive technology users.
18
+ * @implementation-notes
19
+ * - Not WCAG-normative (a widely-used reference engine classifies this as "Best Practices," no SC
20
+ * tag) — authored as an advisory, cantTell-capped `type: 'manual'`
21
+ * rule per ROADMAP.md Tier 1b and the design doc's policy model
22
+ * ("Advisory / best-practice rules may exist, but must not produce
23
+ * `fail`"). Matches the existing `page-title-patterns-manual.js`
24
+ * precedent: deterministic DOM analysis, no human required, but
25
+ * capped at `cantTell`/`notApplicable` rather than `fail`/`pass`.
26
+ * - Landmark detection here models WAI-ARIA APG landmark roles and the
27
+ * HTML-AAM implicit-role mapping (header→banner, footer→contentinfo,
28
+ * main→main, nav→navigation, aside→complementary, section/form→
29
+ * region/form only when accessibly named), not a byte-for-byte port
30
+ * of a widely-used reference engine's internal algorithm — verify against upstream if exact
31
+ * parity is ever required.
32
+ */
33
+
34
+ const id = 'landmark-banner-is-top-level';
35
+
36
+ const meta = {
37
+ title: 'Banner landmark must be top-level',
38
+ description: 'Checks that the banner landmark (role="banner" or a non-nested <header>) is not nested inside another landmark region.',
39
+ i18n: {
40
+ titleKey: 'landmarkBannerIsTopLevel_title',
41
+ descriptionKey: 'landmarkBannerIsTopLevel_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
+ // Declared inside runInPage — see scripts/build-core.js header
58
+ // ("runInPage MUST be self-contained").
59
+ function normalizeWs(s) {
60
+ return String(s || '').replace(/\s+/g, ' ').trim();
61
+ }
62
+
63
+ // Delegates to the shared helpers.getLandmarkNameInfo (aria-label -> aria-labelledby, via the
64
+ // target's own accessible name, not raw textContent -> title attribute fallback) rather than a
65
+ // local copy -- see that function's header comment in src/core/dom-helpers.js for the real bug
66
+ // (missing title fallback) this replaced across all 7 landmark rule files that had their own
67
+ // copy of this logic.
68
+ function getAccessibleLandmarkName(el) {
69
+ try {
70
+ if (helpers && typeof helpers.getLandmarkNameInfo === 'function') {
71
+ const info = helpers.getLandmarkNameInfo(el, ctx);
72
+ if (info && info.present && info.value) return normalizeWs(info.value);
73
+ }
74
+ } catch {}
75
+ return '';
76
+ }
77
+
78
+ function getExplicitRoleToken(el) {
79
+ const raw = normalizeWs(el.getAttribute && el.getAttribute('role'));
80
+ if (!raw) return '';
81
+ return raw.split(/\s+/)[0].toLowerCase();
82
+ }
83
+
84
+ const SECTIONING_ANCESTORS = new Set(['article', 'aside', 'main', 'nav', 'section']);
85
+
86
+ function isSuppressedBySectioningAncestor(el) {
87
+ let p = el.parentElement;
88
+ while (p) {
89
+ const tag = p.tagName ? p.tagName.toLowerCase() : '';
90
+ if (SECTIONING_ANCESTORS.has(tag)) return true;
91
+ p = p.parentElement;
92
+ }
93
+ return false;
94
+ }
95
+
96
+ function getImplicitLandmarkRole(el) {
97
+ const tag = el.tagName ? el.tagName.toLowerCase() : '';
98
+ if (tag === 'header') return isSuppressedBySectioningAncestor(el) ? '' : 'banner';
99
+ if (tag === 'footer') return isSuppressedBySectioningAncestor(el) ? '' : 'contentinfo';
100
+ if (tag === 'main') return 'main';
101
+ if (tag === 'nav') return 'navigation';
102
+ if (tag === 'aside') return isSuppressedBySectioningAncestor(el) ? '' : 'complementary';
103
+ if (tag === 'section') return getAccessibleLandmarkName(el) ? 'region' : '';
104
+ if (tag === 'form') return getAccessibleLandmarkName(el) ? 'form' : '';
105
+ return '';
106
+ }
107
+
108
+ const LANDMARK_ROLES = new Set(['banner', 'contentinfo', 'main', 'navigation', 'complementary', 'region', 'form', 'search']);
109
+
110
+ function getLandmarkRole(el) {
111
+ if (!el || !el.getAttribute) return '';
112
+ const explicit = getExplicitRoleToken(el);
113
+ if (explicit) return LANDMARK_ROLES.has(explicit) ? explicit : '';
114
+ return getImplicitLandmarkRole(el);
115
+ }
116
+
117
+ function hasLandmarkAncestor(el) {
118
+ let p = el.parentElement;
119
+ while (p) {
120
+ if (getLandmarkRole(p)) return true;
121
+ p = p.parentElement;
122
+ }
123
+ return false;
124
+ }
125
+
126
+ // queryAllSmart (shadow-DOM-aware) instead of plain document.querySelectorAll -- see
127
+ // landmark-unique-manual.js's header comment for the real page (Airtable, 2026-07-23)
128
+ // that surfaced this gap: a third-party shadow-DOM-hosted widget's own landmark is
129
+ // invisible to a light-DOM-only query.
130
+ let nodes = [];
131
+ try {
132
+ nodes = helpers && typeof helpers.queryAllSmart === 'function'
133
+ ? helpers.queryAllSmart('header, footer, main, nav, aside, section, form, [role]')
134
+ : document.querySelectorAll('header, footer, main, nav, aside, section, form, [role]');
135
+ } catch {
136
+ nodes = [];
137
+ }
138
+
139
+ const banners = [];
140
+ const seen = new Set();
141
+ for (const el of nodes) {
142
+ if (!el || seen.has(el)) continue;
143
+ seen.add(el);
144
+ if (getLandmarkRole(el) === 'banner') banners.push(el);
145
+ }
146
+
147
+ if (banners.length === 0) {
148
+ return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
149
+ }
150
+
151
+ const occurrences = [];
152
+ for (const el of banners) {
153
+ if (!hasLandmarkAncestor(el)) continue;
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 banner landmark is nested inside another landmark region.',
162
+ 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.',
163
+ i18n: {
164
+ summaryKey: 'landmarkBannerIsTopLevel_summary_cantTell',
165
+ hintKey: 'landmarkBannerIsTopLevel_hint_cantTell',
166
+ params: {}
167
+ },
168
+ data: {
169
+ details: { reasonCode: 'LANDMARK_BANNER_NOT_TOP_LEVEL' }
170
+ }
171
+ });
172
+ }
173
+
174
+ if (occurrences.length) {
175
+ return { ruleId: rule.ruleId, outcome: 'cantTell', severity: rule.defaultSeverity || 'minor', occurrences };
176
+ }
177
+ return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
178
+ }
179
+
180
+ module.exports = { id, meta, runInPage };
@@ -0,0 +1,169 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * @check landmark-contentinfo-is-top-level
5
+ * @atomic true
6
+ * @summary The contentinfo landmark must not be nested inside another 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 <footer> that is not
11
+ * itself nested inside <article>/<aside>/<main>/<nav>/<section> — see
12
+ * implementation notes).
13
+ * @expectation
14
+ * No contentinfo landmark has an ancestor that is itself any landmark
15
+ * region. A contentinfo nested inside another landmark is not a
16
+ * top-level, whole-page footer region and confuses landmark-based
17
+ * navigation for assistive technology users.
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 (this rule mirrors
22
+ * its structure with contentinfo/footer in place of banner/header).
23
+ */
24
+
25
+ const id = 'landmark-contentinfo-is-top-level';
26
+
27
+ const meta = {
28
+ title: 'Contentinfo landmark must be top-level',
29
+ description: 'Checks that the contentinfo landmark (role="contentinfo" or a non-nested <footer>) is not nested inside another landmark region.',
30
+ i18n: {
31
+ titleKey: 'landmarkContentinfoIsTopLevel_title',
32
+ descriptionKey: 'landmarkContentinfoIsTopLevel_description'
33
+ },
34
+ helpUrl: null,
35
+ tags: ['best-practice', 'landmarks', 'structure', 'atomic', 'manual'],
36
+ wcagSc: [],
37
+ normativeMappings: [],
38
+ defaultSeverity: 'minor',
39
+ category: 'operable',
40
+ type: 'manual',
41
+ defaultConfidence: 'medium',
42
+ coverage: {}
43
+ };
44
+
45
+ function runInPage(ctx) {
46
+ const { document, helpers, rule } = ctx;
47
+
48
+ function normalizeWs(s) {
49
+ return String(s || '').replace(/\s+/g, ' ').trim();
50
+ }
51
+
52
+ // Delegates to the shared helpers.getLandmarkNameInfo (aria-label -> aria-labelledby, via the
53
+ // target's own accessible name, not raw textContent -> title attribute fallback) rather than a
54
+ // local copy -- see that function's header comment in src/core/dom-helpers.js for the real bug
55
+ // (missing title fallback) this replaced across all 7 landmark rule files that had their own
56
+ // copy of this logic.
57
+ function getAccessibleLandmarkName(el) {
58
+ try {
59
+ if (helpers && typeof helpers.getLandmarkNameInfo === 'function') {
60
+ const info = helpers.getLandmarkNameInfo(el, ctx);
61
+ if (info && info.present && info.value) return normalizeWs(info.value);
62
+ }
63
+ } catch {}
64
+ return '';
65
+ }
66
+
67
+ function getExplicitRoleToken(el) {
68
+ const raw = normalizeWs(el.getAttribute && el.getAttribute('role'));
69
+ if (!raw) return '';
70
+ return raw.split(/\s+/)[0].toLowerCase();
71
+ }
72
+
73
+ const SECTIONING_ANCESTORS = new Set(['article', 'aside', 'main', 'nav', 'section']);
74
+
75
+ function isSuppressedBySectioningAncestor(el) {
76
+ let p = el.parentElement;
77
+ while (p) {
78
+ const tag = p.tagName ? p.tagName.toLowerCase() : '';
79
+ if (SECTIONING_ANCESTORS.has(tag)) return true;
80
+ p = p.parentElement;
81
+ }
82
+ return false;
83
+ }
84
+
85
+ function getImplicitLandmarkRole(el) {
86
+ const tag = el.tagName ? el.tagName.toLowerCase() : '';
87
+ if (tag === 'header') return isSuppressedBySectioningAncestor(el) ? '' : 'banner';
88
+ if (tag === 'footer') return isSuppressedBySectioningAncestor(el) ? '' : 'contentinfo';
89
+ if (tag === 'main') return 'main';
90
+ if (tag === 'nav') return 'navigation';
91
+ if (tag === 'aside') return isSuppressedBySectioningAncestor(el) ? '' : 'complementary';
92
+ if (tag === 'section') return getAccessibleLandmarkName(el) ? 'region' : '';
93
+ if (tag === 'form') return getAccessibleLandmarkName(el) ? 'form' : '';
94
+ return '';
95
+ }
96
+
97
+ const LANDMARK_ROLES = new Set(['banner', 'contentinfo', 'main', 'navigation', 'complementary', 'region', 'form', 'search']);
98
+
99
+ function getLandmarkRole(el) {
100
+ if (!el || !el.getAttribute) return '';
101
+ const explicit = getExplicitRoleToken(el);
102
+ if (explicit) return LANDMARK_ROLES.has(explicit) ? explicit : '';
103
+ return getImplicitLandmarkRole(el);
104
+ }
105
+
106
+ function hasLandmarkAncestor(el) {
107
+ let p = el.parentElement;
108
+ while (p) {
109
+ if (getLandmarkRole(p)) return true;
110
+ p = p.parentElement;
111
+ }
112
+ return false;
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 contentinfos = [];
129
+ const seen = new Set();
130
+ for (const el of nodes) {
131
+ if (!el || seen.has(el)) continue;
132
+ seen.add(el);
133
+ if (getLandmarkRole(el) === 'contentinfo') contentinfos.push(el);
134
+ }
135
+
136
+ if (contentinfos.length === 0) {
137
+ return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
138
+ }
139
+
140
+ const occurrences = [];
141
+ for (const el of contentinfos) {
142
+ if (!hasLandmarkAncestor(el)) continue;
143
+
144
+ const stableSelector = helpers.buildSelector ? helpers.buildSelector(el) : 'html';
145
+ const html = helpers.getOuterHtmlSnippet ? helpers.getOuterHtmlSnippet(el) : (el.outerHTML || '');
146
+
147
+ occurrences.push({
148
+ selector: stableSelector,
149
+ html,
150
+ summary: 'This contentinfo landmark is nested inside another landmark region.',
151
+ 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.',
152
+ i18n: {
153
+ summaryKey: 'landmarkContentinfoIsTopLevel_summary_cantTell',
154
+ hintKey: 'landmarkContentinfoIsTopLevel_hint_cantTell',
155
+ params: {}
156
+ },
157
+ data: {
158
+ details: { reasonCode: 'LANDMARK_CONTENTINFO_NOT_TOP_LEVEL' }
159
+ }
160
+ });
161
+ }
162
+
163
+ if (occurrences.length) {
164
+ return { ruleId: rule.ruleId, outcome: 'cantTell', severity: rule.defaultSeverity || 'minor', occurrences };
165
+ }
166
+ return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
167
+ }
168
+
169
+ module.exports = { id, meta, runInPage };
@@ -0,0 +1,167 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * @check landmark-main-is-top-level
5
+ * @atomic true
6
+ * @summary The main landmark must not be nested inside another 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> element).
11
+ * @expectation
12
+ * No main landmark has an ancestor that is itself any landmark region.
13
+ * A main region nested inside another landmark is not a top-level,
14
+ * whole-page main content area and confuses landmark-based navigation
15
+ * for assistive technology users.
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 (this rule mirrors
20
+ * its structure with main in place of banner/header).
21
+ */
22
+
23
+ const id = 'landmark-main-is-top-level';
24
+
25
+ const meta = {
26
+ title: 'Main landmark must be top-level',
27
+ description: 'Checks that the main landmark (role="main" or <main>) is not nested inside another landmark region.',
28
+ i18n: {
29
+ titleKey: 'landmarkMainIsTopLevel_title',
30
+ descriptionKey: 'landmarkMainIsTopLevel_description'
31
+ },
32
+ helpUrl: null,
33
+ tags: ['best-practice', 'landmarks', 'structure', 'atomic', 'manual'],
34
+ wcagSc: [],
35
+ normativeMappings: [],
36
+ defaultSeverity: 'minor',
37
+ category: 'operable',
38
+ type: 'manual',
39
+ defaultConfidence: 'medium',
40
+ coverage: {}
41
+ };
42
+
43
+ function runInPage(ctx) {
44
+ const { document, helpers, rule } = ctx;
45
+
46
+ function normalizeWs(s) {
47
+ return String(s || '').replace(/\s+/g, ' ').trim();
48
+ }
49
+
50
+ // Delegates to the shared helpers.getLandmarkNameInfo (aria-label -> aria-labelledby, via the
51
+ // target's own accessible name, not raw textContent -> title attribute fallback) rather than a
52
+ // local copy -- see that function's header comment in src/core/dom-helpers.js for the real bug
53
+ // (missing title fallback) this replaced across all 7 landmark rule files that had their own
54
+ // copy of this logic.
55
+ function getAccessibleLandmarkName(el) {
56
+ try {
57
+ if (helpers && typeof helpers.getLandmarkNameInfo === 'function') {
58
+ const info = helpers.getLandmarkNameInfo(el, ctx);
59
+ if (info && info.present && info.value) return normalizeWs(info.value);
60
+ }
61
+ } catch {}
62
+ return '';
63
+ }
64
+
65
+ function getExplicitRoleToken(el) {
66
+ const raw = normalizeWs(el.getAttribute && el.getAttribute('role'));
67
+ if (!raw) return '';
68
+ return raw.split(/\s+/)[0].toLowerCase();
69
+ }
70
+
71
+ const SECTIONING_ANCESTORS = new Set(['article', 'aside', 'main', 'nav', 'section']);
72
+
73
+ function isSuppressedBySectioningAncestor(el) {
74
+ let p = el.parentElement;
75
+ while (p) {
76
+ const tag = p.tagName ? p.tagName.toLowerCase() : '';
77
+ if (SECTIONING_ANCESTORS.has(tag)) return true;
78
+ p = p.parentElement;
79
+ }
80
+ return false;
81
+ }
82
+
83
+ function getImplicitLandmarkRole(el) {
84
+ const tag = el.tagName ? el.tagName.toLowerCase() : '';
85
+ if (tag === 'header') return isSuppressedBySectioningAncestor(el) ? '' : 'banner';
86
+ if (tag === 'footer') return isSuppressedBySectioningAncestor(el) ? '' : 'contentinfo';
87
+ if (tag === 'main') return 'main';
88
+ if (tag === 'nav') return 'navigation';
89
+ if (tag === 'aside') return isSuppressedBySectioningAncestor(el) ? '' : 'complementary';
90
+ if (tag === 'section') return getAccessibleLandmarkName(el) ? 'region' : '';
91
+ if (tag === 'form') return getAccessibleLandmarkName(el) ? 'form' : '';
92
+ return '';
93
+ }
94
+
95
+ const LANDMARK_ROLES = new Set(['banner', 'contentinfo', 'main', 'navigation', 'complementary', 'region', 'form', 'search']);
96
+
97
+ function getLandmarkRole(el) {
98
+ if (!el || !el.getAttribute) return '';
99
+ const explicit = getExplicitRoleToken(el);
100
+ if (explicit) return LANDMARK_ROLES.has(explicit) ? explicit : '';
101
+ return getImplicitLandmarkRole(el);
102
+ }
103
+
104
+ function hasLandmarkAncestor(el) {
105
+ let p = el.parentElement;
106
+ while (p) {
107
+ if (getLandmarkRole(p)) return true;
108
+ p = p.parentElement;
109
+ }
110
+ return false;
111
+ }
112
+
113
+ // queryAllSmart (shadow-DOM-aware) instead of plain document.querySelectorAll -- see
114
+ // landmark-unique-manual.js's header comment for the real page (Airtable, 2026-07-23)
115
+ // that surfaced this gap: a third-party shadow-DOM-hosted widget's own landmark is
116
+ // invisible to a light-DOM-only query.
117
+ let nodes = [];
118
+ try {
119
+ nodes = helpers && typeof helpers.queryAllSmart === 'function'
120
+ ? helpers.queryAllSmart('header, footer, main, nav, aside, section, form, [role]')
121
+ : document.querySelectorAll('header, footer, main, nav, aside, section, form, [role]');
122
+ } catch {
123
+ nodes = [];
124
+ }
125
+
126
+ const mains = [];
127
+ const seen = new Set();
128
+ for (const el of nodes) {
129
+ if (!el || seen.has(el)) continue;
130
+ seen.add(el);
131
+ if (getLandmarkRole(el) === 'main') mains.push(el);
132
+ }
133
+
134
+ if (mains.length === 0) {
135
+ return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
136
+ }
137
+
138
+ const occurrences = [];
139
+ for (const el of mains) {
140
+ if (!hasLandmarkAncestor(el)) continue;
141
+
142
+ const stableSelector = helpers.buildSelector ? helpers.buildSelector(el) : 'html';
143
+ const html = helpers.getOuterHtmlSnippet ? helpers.getOuterHtmlSnippet(el) : (el.outerHTML || '');
144
+
145
+ occurrences.push({
146
+ selector: stableSelector,
147
+ html,
148
+ summary: 'This main landmark is nested inside another landmark region.',
149
+ 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.',
150
+ i18n: {
151
+ summaryKey: 'landmarkMainIsTopLevel_summary_cantTell',
152
+ hintKey: 'landmarkMainIsTopLevel_hint_cantTell',
153
+ params: {}
154
+ },
155
+ data: {
156
+ details: { reasonCode: 'LANDMARK_MAIN_NOT_TOP_LEVEL' }
157
+ }
158
+ });
159
+ }
160
+
161
+ if (occurrences.length) {
162
+ return { ruleId: rule.ruleId, outcome: 'cantTell', severity: rule.defaultSeverity || 'minor', occurrences };
163
+ }
164
+ return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
165
+ }
166
+
167
+ module.exports = { id, meta, runInPage };