@surea11y/core 1.2.0 → 1.3.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.
- package/CHANGELOG.md +37 -7
- package/LICENSE +373 -21
- package/README.md +67 -1
- package/bin/core.js +144 -19
- package/docs/API_STABILITY.md +1 -1
- package/docs/BINDING_AUTHORS_GUIDE.md +9 -9
- package/docs/CI_INTEGRATIONS.md +103 -0
- package/docs/CLI.md +54 -1
- package/docs/ENGINE_OPTIONS.md +2 -0
- package/docs/INTEGRATION.md +18 -0
- package/docs/OUTPUT_SCHEMA.md +1 -1
- package/docs/RULE_CATALOG.md +1 -1
- package/docs/SARIF.md +59 -0
- package/package.json +15 -4
- package/src/baseline.js +0 -0
- package/src/catalogs/composites.wcag.js +414 -450
- package/src/checks/automatic/area-alt-present.js +59 -25
- package/src/checks/automatic/aria-allowed-attr.js +193 -33
- package/src/checks/automatic/aria-allowed-role.js +21 -7
- package/src/checks/automatic/aria-braille-equivalent.js +32 -10
- package/src/checks/automatic/aria-conditional-attr.js +24 -7
- package/src/checks/automatic/aria-deprecated-role.js +22 -8
- package/src/checks/automatic/aria-hidden-body.js +42 -19
- package/src/checks/automatic/aria-hidden-focus.js +408 -53
- package/src/checks/automatic/aria-prohibited-attr.js +296 -22
- package/src/checks/automatic/aria-prohibited-children.js +55 -16
- package/src/checks/automatic/aria-required-attr.js +23 -8
- package/src/checks/automatic/aria-required-children.js +37 -14
- package/src/checks/automatic/aria-required-parent.js +48 -14
- package/src/checks/automatic/aria-role-name-present.js +47 -21
- package/src/checks/automatic/aria-roles-valid.js +22 -12
- package/src/checks/automatic/aria-valid-attr-value.js +28 -7
- package/src/checks/automatic/aria-valid-attr.js +17 -5
- package/src/checks/automatic/autocomplete-valid.js +74 -16
- package/src/checks/automatic/avoid-inline-spacing.js +20 -6
- package/src/checks/automatic/binary-control-name-present.js +60 -50
- package/src/checks/automatic/button-name-present.js +48 -18
- package/src/checks/automatic/bypass-blocks-present.js +42 -25
- package/src/checks/automatic/canvas-text-alternative-present.js +57 -26
- package/src/checks/automatic/combobox-name-present.js +38 -45
- package/src/checks/automatic/contrast-computable.js +361 -341
- package/src/checks/automatic/contrast-enhanced.js +487 -466
- package/src/checks/automatic/contrast-minimum.js +486 -465
- package/src/checks/automatic/css-orientation-lock.js +30 -8
- package/src/checks/automatic/definition-list-children-valid.js +40 -19
- package/src/checks/automatic/deprecated-elements-not-used.js +21 -7
- package/src/checks/automatic/dialog-name-present.js +37 -75
- package/src/checks/automatic/dlitem-parent-valid.js +23 -8
- package/src/checks/automatic/duplicate-id-aria.js +24 -6
- package/src/checks/automatic/embed-text-alternative-present.js +86 -35
- package/src/checks/automatic/form-control-programmatic-label-present.js +79 -196
- package/src/checks/automatic/form-control-single-label.js +47 -10
- package/src/checks/automatic/html-xml-lang-mismatch.js +34 -18
- package/src/checks/automatic/iframe-focusable-content.js +26 -11
- package/src/checks/automatic/iframe-name-present.js +31 -9
- package/src/checks/automatic/iframe-title-unique.js +29 -8
- package/src/checks/automatic/img-alt-present.js +47 -43
- package/src/checks/automatic/input-image-alt-present.js +143 -112
- package/src/checks/automatic/label-in-name.js +50 -22
- package/src/checks/automatic/language-page-present.js +109 -109
- package/src/checks/automatic/link-in-text-block.js +59 -19
- package/src/checks/automatic/link-name-present.js +45 -14
- package/src/checks/automatic/list-children-valid.js +26 -9
- package/src/checks/automatic/listbox-name-present.js +39 -19
- package/src/checks/automatic/listitem-parent-valid.js +18 -6
- package/src/checks/automatic/menuitem-name-present.js +39 -61
- package/src/checks/automatic/meta-refresh-no-exceptions.js +28 -7
- package/src/checks/automatic/meta-refresh-timing-absent.js +20 -6
- package/src/checks/automatic/meta-viewport-zoom-enabled.js +24 -7
- package/src/checks/automatic/meter-name-present.js +36 -33
- package/src/checks/automatic/nested-interactive-controls-absent.js +31 -10
- package/src/checks/automatic/object-text-alternative-present.js +91 -39
- package/src/checks/automatic/option-name-present.js +38 -21
- package/src/checks/automatic/page-title-present.js +17 -6
- package/src/checks/automatic/progressbar-name-present.js +41 -34
- package/src/checks/automatic/role-img-alt-present.js +209 -157
- package/src/checks/automatic/searchbox-name-present.js +39 -19
- package/src/checks/automatic/server-side-image-map-absent.js +23 -8
- package/src/checks/automatic/slider-name-present.js +40 -47
- package/src/checks/automatic/spinbutton-name-present.js +39 -19
- package/src/checks/automatic/summary-name-present.js +37 -17
- package/src/checks/automatic/svg-image-text-alternative-present.js +114 -47
- package/src/checks/automatic/svg-text-alternative-present.js +246 -226
- package/src/checks/automatic/tab-name-present.js +37 -60
- package/src/checks/automatic/table-headers-attr-valid.js +24 -8
- package/src/checks/automatic/table-th-has-data-cells.js +22 -8
- package/src/checks/automatic/target-size-minimum.js +118 -48
- package/src/checks/automatic/td-has-header.js +29 -11
- package/src/checks/automatic/textbox-name-present.js +39 -19
- package/src/checks/automatic/tooltip-name-present.js +37 -18
- package/src/checks/automatic/treeitem-name-present.js +38 -21
- package/src/checks/automatic/valid-lang.js +20 -6
- package/src/checks/automatic/video-poster-text-alternative-present.js +79 -36
- package/src/checks/manual/accesskeys-manual.js +14 -5
- package/src/checks/manual/area-alt-decorative-manual.js +192 -193
- package/src/checks/manual/area-alt-quality-manual.js +182 -141
- package/src/checks/manual/aria-checked-state-mismatch-manual.js +34 -11
- package/src/checks/manual/aria-text-manual.js +14 -6
- package/src/checks/manual/canvas-text-alternative-quality-manual.js +149 -114
- package/src/checks/manual/css-hidden-focus.js +196 -165
- package/src/checks/manual/embed-text-alternative-quality-manual.js +171 -160
- package/src/checks/manual/empty-heading-manual.js +24 -7
- package/src/checks/manual/empty-table-header-manual.js +17 -6
- package/src/checks/manual/focus-order-semantics-manual.js +45 -10
- package/src/checks/manual/form-control-programmatic-label-quality-manual.js +207 -246
- package/src/checks/manual/heading-order-manual.js +22 -7
- package/src/checks/manual/identical-links-same-purpose-manual.js +34 -12
- package/src/checks/manual/image-redundant-alt-manual.js +17 -7
- package/src/checks/manual/img-alt-decorative-manual.js +131 -96
- package/src/checks/manual/img-alt-quality-manual.js +176 -127
- package/src/checks/manual/input-image-alt-decorative-manual.js +125 -92
- package/src/checks/manual/input-image-alt-quality-manual.js +125 -92
- package/src/checks/manual/label-title-only-manual.js +14 -5
- package/src/checks/manual/landmark-banner-is-top-level-manual.js +66 -16
- package/src/checks/manual/landmark-contentinfo-is-top-level-manual.js +53 -16
- package/src/checks/manual/landmark-main-is-top-level-manual.js +35 -10
- package/src/checks/manual/landmark-no-duplicate-banner-manual.js +29 -10
- package/src/checks/manual/landmark-no-duplicate-contentinfo-manual.js +29 -10
- package/src/checks/manual/landmark-no-duplicate-main-manual.js +17 -8
- package/src/checks/manual/landmark-one-main-manual.js +26 -20
- package/src/checks/manual/landmark-unique-manual.js +41 -15
- package/src/checks/manual/link-name-quality-manual.js +43 -12
- package/src/checks/manual/media-transcript-present-manual.js +35 -22
- package/src/checks/manual/meta-viewport-large-manual.js +16 -5
- package/src/checks/manual/mouse-only-event-handlers-manual.js +38 -11
- package/src/checks/manual/no-autoplay-audio-manual.js +20 -6
- package/src/checks/manual/object-text-alternative-quality-manual.js +175 -154
- package/src/checks/manual/p-as-heading-manual.js +22 -7
- package/src/checks/manual/page-has-heading-one-manual.js +31 -22
- package/src/checks/manual/page-title-patterns-manual.js +78 -50
- package/src/checks/manual/presentation-role-conflict-manual.js +59 -19
- package/src/checks/manual/region-manual.js +241 -48
- package/src/checks/manual/scope-attr-valid-manual.js +10 -3
- package/src/checks/manual/scrollable-region-focusable-manual.js +37 -11
- package/src/checks/manual/skip-link-manual.js +35 -12
- package/src/checks/manual/svg-text-alternative-quality-manual.js +206 -165
- package/src/checks/manual/tabindex-manual.js +10 -3
- package/src/checks/manual/table-duplicate-name-manual.js +17 -7
- package/src/checks/manual/table-fake-caption-manual.js +24 -7
- package/src/checks/manual/video-caption-manual.js +15 -4
- package/src/checks/manual-review.js +56 -12
- package/src/core/aria-helpers.js +1127 -886
- package/src/core/contrast-helpers.js +1217 -1062
- package/src/core/dom-helpers.js +4175 -3917
- package/src/core/dom-runner.js +720 -604
- package/src/core/frame-messaging.js +189 -138
- package/src/core/frame-scan.js +94 -82
- package/src/core/rollup-composites.js +94 -102
- package/src/core/rule-meta.js +58 -41
- package/src/core.js +36552 -29111
- package/src/i18n/en.js +1194 -889
- package/src/i18n/fr.js +1136 -795
- package/src/policy/contracts.js +13 -13
- package/src/policy/resolvePolicy.js +48 -44
- package/src/report.js +63 -43
- package/src/sarif.js +175 -0
- package/surea11y.browser.js +36042 -0
|
@@ -6,12 +6,11 @@
|
|
|
6
6
|
* @summary The banner landmark must not be nested inside another landmark
|
|
7
7
|
* @standard Best Practices (a widely-used reference engine's classification; no formal WCAG Success Criterion — see ROADMAP.md Tier 1b)
|
|
8
8
|
* @applicability
|
|
9
|
-
* Applies whenever the page contains at least one banner
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
* implementation notes).
|
|
9
|
+
* Applies whenever the page contains at least one banner candidate:
|
|
10
|
+
* explicit role="banner", OR a <header> with NO role attribute at all
|
|
11
|
+
* (regardless of nesting — see implementation notes' 2026-08-01 fix).
|
|
13
12
|
* @expectation
|
|
14
|
-
* No banner
|
|
13
|
+
* No banner candidate has an ancestor that is itself any landmark
|
|
15
14
|
* region. A banner nested inside another landmark is not a top-level,
|
|
16
15
|
* whole-page banner and confuses landmark-based navigation for
|
|
17
16
|
* assistive technology users.
|
|
@@ -29,13 +28,35 @@
|
|
|
29
28
|
* region/form only when accessibly named), not a byte-for-byte port
|
|
30
29
|
* of a widely-used reference engine's internal algorithm — verify against upstream if exact
|
|
31
30
|
* parity is ever required.
|
|
31
|
+
* - **Fixed 2026-08-01, a self-defeating applicability bug found via the
|
|
32
|
+
* cross-engine comparisons project (verified live on TurboTax's real
|
|
33
|
+
* homepage, `<header>` nested inside a `<div id="main" role="main">`
|
|
34
|
+
* two levels up):** candidate selection used to run the *same*
|
|
35
|
+
* HTML-AAM sectioning-ancestor suppression used for the violation
|
|
36
|
+
* check itself (`getImplicitLandmarkRole`'s `hasSectioningAncestor`
|
|
37
|
+
* gate) — so the moment a `<header>` was nested inside another
|
|
38
|
+
* landmark, that same nesting made it stop counting as a banner
|
|
39
|
+
* candidate in the first place, and the rule could never flag the one
|
|
40
|
+
* case it exists to catch. A widely-used reference engine's own
|
|
41
|
+
* `landmark-banner-is-top-level` avoids this: its selector
|
|
42
|
+
* (`header:not([role]), [role=banner]`) is unconditional — it doesn't
|
|
43
|
+
* care whether the header *currently* carries the banner role, only
|
|
44
|
+
* whether it's a `<header>`/`role="banner"` with a landmark ancestor
|
|
45
|
+
* above it (verified by reading that engine's real
|
|
46
|
+
* `landmark-is-top-level-evaluate` source, not guessed). Candidate
|
|
47
|
+
* selection (`isBannerCandidate` below) now matches that unconditional
|
|
48
|
+
* selector shape; the ancestor walk (`hasLandmarkAncestor`) still uses
|
|
49
|
+
* the full suppression-aware `getLandmarkRole` for each ancestor,
|
|
50
|
+
* which is correct and unchanged — an ancestor genuinely needs its own
|
|
51
|
+
* real role to count as blocking.
|
|
32
52
|
*/
|
|
33
53
|
|
|
34
54
|
const id = 'landmark-banner-is-top-level';
|
|
35
55
|
|
|
36
56
|
const meta = {
|
|
37
57
|
title: 'Banner landmark must be top-level',
|
|
38
|
-
description:
|
|
58
|
+
description:
|
|
59
|
+
'Checks that the banner landmark (role="banner" or a non-nested <header>) is not nested inside another landmark region.',
|
|
39
60
|
i18n: {
|
|
40
61
|
titleKey: 'landmarkBannerIsTopLevel_title',
|
|
41
62
|
descriptionKey: 'landmarkBannerIsTopLevel_description'
|
|
@@ -57,7 +78,9 @@ function runInPage(ctx) {
|
|
|
57
78
|
// Declared inside runInPage — see scripts/build-core.js header
|
|
58
79
|
// ("runInPage MUST be self-contained").
|
|
59
80
|
function normalizeWs(s) {
|
|
60
|
-
return String(s || '')
|
|
81
|
+
return String(s || '')
|
|
82
|
+
.replace(/\s+/g, ' ')
|
|
83
|
+
.trim();
|
|
61
84
|
}
|
|
62
85
|
|
|
63
86
|
// Delegates to the shared helpers.getLandmarkNameInfo (aria-label -> aria-labelledby, via the
|
|
@@ -117,7 +140,16 @@ function runInPage(ctx) {
|
|
|
117
140
|
return '';
|
|
118
141
|
}
|
|
119
142
|
|
|
120
|
-
const LANDMARK_ROLES = new Set([
|
|
143
|
+
const LANDMARK_ROLES = new Set([
|
|
144
|
+
'banner',
|
|
145
|
+
'contentinfo',
|
|
146
|
+
'main',
|
|
147
|
+
'navigation',
|
|
148
|
+
'complementary',
|
|
149
|
+
'region',
|
|
150
|
+
'form',
|
|
151
|
+
'search'
|
|
152
|
+
]);
|
|
121
153
|
|
|
122
154
|
function getLandmarkRole(el) {
|
|
123
155
|
if (!el || !el.getAttribute) return '';
|
|
@@ -126,8 +158,20 @@ function runInPage(ctx) {
|
|
|
126
158
|
return getImplicitLandmarkRole(el);
|
|
127
159
|
}
|
|
128
160
|
|
|
161
|
+
// Candidate selection is deliberately NOT the same as getLandmarkRole()
|
|
162
|
+
// === 'banner' — see the 2026-08-01 fix note above. A <header> is a
|
|
163
|
+
// candidate purely by tag + absence of any role attribute, independent
|
|
164
|
+
// of whether sectioning-ancestor nesting would currently suppress its
|
|
165
|
+
// implicit role; an explicit role="banner" is always a candidate too.
|
|
166
|
+
function isBannerCandidate(el) {
|
|
167
|
+
if (!el || !el.getAttribute) return false;
|
|
168
|
+
const explicit = getExplicitRoleToken(el);
|
|
169
|
+
if (explicit) return explicit === 'banner';
|
|
170
|
+
return !!(el.tagName && el.tagName.toLowerCase() === 'header');
|
|
171
|
+
}
|
|
172
|
+
|
|
129
173
|
function hasLandmarkAncestor(el) {
|
|
130
|
-
const scopeRoots = Array.isArray(root) ? root :
|
|
174
|
+
const scopeRoots = Array.isArray(root) ? root : root ? [root] : [];
|
|
131
175
|
let p = el.parentElement;
|
|
132
176
|
while (p) {
|
|
133
177
|
if (getLandmarkRole(p)) return true;
|
|
@@ -143,11 +187,12 @@ function runInPage(ctx) {
|
|
|
143
187
|
// landmark-unique-manual.js's header comment for the real page (Airtable, 2026-07-23)
|
|
144
188
|
// that surfaced this gap: a third-party shadow-DOM-hosted widget's own landmark is
|
|
145
189
|
// invisible to a light-DOM-only query.
|
|
146
|
-
let nodes
|
|
190
|
+
let nodes;
|
|
147
191
|
try {
|
|
148
|
-
nodes =
|
|
149
|
-
|
|
150
|
-
|
|
192
|
+
nodes =
|
|
193
|
+
helpers && typeof helpers.queryAllSmart === 'function'
|
|
194
|
+
? helpers.queryAllSmart('header, footer, main, nav, aside, section, form, [role]')
|
|
195
|
+
: document.querySelectorAll('header, footer, main, nav, aside, section, form, [role]');
|
|
151
196
|
} catch {
|
|
152
197
|
nodes = [];
|
|
153
198
|
}
|
|
@@ -157,7 +202,7 @@ function runInPage(ctx) {
|
|
|
157
202
|
for (const el of nodes) {
|
|
158
203
|
if (!el || seen.has(el)) continue;
|
|
159
204
|
seen.add(el);
|
|
160
|
-
if (
|
|
205
|
+
if (isBannerCandidate(el)) banners.push(el);
|
|
161
206
|
}
|
|
162
207
|
|
|
163
208
|
if (banners.length === 0) {
|
|
@@ -169,7 +214,7 @@ function runInPage(ctx) {
|
|
|
169
214
|
if (!hasLandmarkAncestor(el)) continue;
|
|
170
215
|
|
|
171
216
|
const stableSelector = helpers.buildSelector ? helpers.buildSelector(el) : 'html';
|
|
172
|
-
const html = helpers.getOuterHtmlSnippet ? helpers.getOuterHtmlSnippet(el) :
|
|
217
|
+
const html = helpers.getOuterHtmlSnippet ? helpers.getOuterHtmlSnippet(el) : el.outerHTML || '';
|
|
173
218
|
|
|
174
219
|
occurrences.push({
|
|
175
220
|
selector: stableSelector,
|
|
@@ -188,7 +233,12 @@ function runInPage(ctx) {
|
|
|
188
233
|
}
|
|
189
234
|
|
|
190
235
|
if (occurrences.length) {
|
|
191
|
-
return {
|
|
236
|
+
return {
|
|
237
|
+
ruleId: rule.ruleId,
|
|
238
|
+
outcome: 'cantTell',
|
|
239
|
+
severity: rule.defaultSeverity || 'minor',
|
|
240
|
+
occurrences
|
|
241
|
+
};
|
|
192
242
|
}
|
|
193
243
|
return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
|
|
194
244
|
}
|
|
@@ -6,12 +6,12 @@
|
|
|
6
6
|
* @summary The contentinfo landmark must not be nested inside another landmark
|
|
7
7
|
* @standard Best Practices (a widely-used reference engine's classification; no formal WCAG Success Criterion — see ROADMAP.md Tier 1b)
|
|
8
8
|
* @applicability
|
|
9
|
-
* Applies whenever the page contains at least one contentinfo
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
9
|
+
* Applies whenever the page contains at least one contentinfo
|
|
10
|
+
* candidate: explicit role="contentinfo", OR a <footer> with NO role
|
|
11
|
+
* attribute at all (regardless of nesting — see implementation notes'
|
|
12
|
+
* 2026-08-01 fix).
|
|
13
13
|
* @expectation
|
|
14
|
-
* No contentinfo
|
|
14
|
+
* No contentinfo candidate has an ancestor that is itself any landmark
|
|
15
15
|
* region. A contentinfo nested inside another landmark is not a
|
|
16
16
|
* top-level, whole-page footer region and confuses landmark-based
|
|
17
17
|
* navigation for assistive technology users.
|
|
@@ -20,13 +20,21 @@
|
|
|
20
20
|
* `type: 'manual'` rule; see landmark-banner-is-top-level's
|
|
21
21
|
* header comment for the shared rationale/precedent (this rule mirrors
|
|
22
22
|
* its structure with contentinfo/footer in place of banner/header).
|
|
23
|
+
* - **Fixed 2026-08-01, same self-defeating applicability bug as
|
|
24
|
+
* landmark-banner-is-top-level (see that file's header comment for the
|
|
25
|
+
* full root cause and the TurboTax evidence) — candidate selection
|
|
26
|
+
* (`isContentinfoCandidate` below) now matches a widely-used reference
|
|
27
|
+
* engine's unconditional `footer:not([role]), [role=contentinfo]`
|
|
28
|
+
* selector shape instead of reusing the sectioning-ancestor
|
|
29
|
+
* suppression that the violation check itself depends on.
|
|
23
30
|
*/
|
|
24
31
|
|
|
25
32
|
const id = 'landmark-contentinfo-is-top-level';
|
|
26
33
|
|
|
27
34
|
const meta = {
|
|
28
35
|
title: 'Contentinfo landmark must be top-level',
|
|
29
|
-
description:
|
|
36
|
+
description:
|
|
37
|
+
'Checks that the contentinfo landmark (role="contentinfo" or a non-nested <footer>) is not nested inside another landmark region.',
|
|
30
38
|
i18n: {
|
|
31
39
|
titleKey: 'landmarkContentinfoIsTopLevel_title',
|
|
32
40
|
descriptionKey: 'landmarkContentinfoIsTopLevel_description'
|
|
@@ -46,7 +54,9 @@ function runInPage(ctx) {
|
|
|
46
54
|
const { document, root, helpers, rule } = ctx;
|
|
47
55
|
|
|
48
56
|
function normalizeWs(s) {
|
|
49
|
-
return String(s || '')
|
|
57
|
+
return String(s || '')
|
|
58
|
+
.replace(/\s+/g, ' ')
|
|
59
|
+
.trim();
|
|
50
60
|
}
|
|
51
61
|
|
|
52
62
|
// Delegates to the shared helpers.getLandmarkNameInfo (aria-label -> aria-labelledby, via the
|
|
@@ -106,7 +116,16 @@ function runInPage(ctx) {
|
|
|
106
116
|
return '';
|
|
107
117
|
}
|
|
108
118
|
|
|
109
|
-
const LANDMARK_ROLES = new Set([
|
|
119
|
+
const LANDMARK_ROLES = new Set([
|
|
120
|
+
'banner',
|
|
121
|
+
'contentinfo',
|
|
122
|
+
'main',
|
|
123
|
+
'navigation',
|
|
124
|
+
'complementary',
|
|
125
|
+
'region',
|
|
126
|
+
'form',
|
|
127
|
+
'search'
|
|
128
|
+
]);
|
|
110
129
|
|
|
111
130
|
function getLandmarkRole(el) {
|
|
112
131
|
if (!el || !el.getAttribute) return '';
|
|
@@ -115,8 +134,20 @@ function runInPage(ctx) {
|
|
|
115
134
|
return getImplicitLandmarkRole(el);
|
|
116
135
|
}
|
|
117
136
|
|
|
137
|
+
// Candidate selection is deliberately NOT the same as getLandmarkRole()
|
|
138
|
+
// === 'contentinfo' — see the 2026-08-01 fix note above. A <footer> is
|
|
139
|
+
// a candidate purely by tag + absence of any role attribute, independent
|
|
140
|
+
// of whether sectioning-ancestor nesting would currently suppress its
|
|
141
|
+
// implicit role; an explicit role="contentinfo" is always a candidate too.
|
|
142
|
+
function isContentinfoCandidate(el) {
|
|
143
|
+
if (!el || !el.getAttribute) return false;
|
|
144
|
+
const explicit = getExplicitRoleToken(el);
|
|
145
|
+
if (explicit) return explicit === 'contentinfo';
|
|
146
|
+
return !!(el.tagName && el.tagName.toLowerCase() === 'footer');
|
|
147
|
+
}
|
|
148
|
+
|
|
118
149
|
function hasLandmarkAncestor(el) {
|
|
119
|
-
const scopeRoots = Array.isArray(root) ? root :
|
|
150
|
+
const scopeRoots = Array.isArray(root) ? root : root ? [root] : [];
|
|
120
151
|
let p = el.parentElement;
|
|
121
152
|
while (p) {
|
|
122
153
|
if (getLandmarkRole(p)) return true;
|
|
@@ -132,11 +163,12 @@ function runInPage(ctx) {
|
|
|
132
163
|
// landmark-unique-manual.js's header comment for the real page (Airtable, 2026-07-23)
|
|
133
164
|
// that surfaced this gap: a third-party shadow-DOM-hosted widget's own landmark is
|
|
134
165
|
// invisible to a light-DOM-only query.
|
|
135
|
-
let nodes
|
|
166
|
+
let nodes;
|
|
136
167
|
try {
|
|
137
|
-
nodes =
|
|
138
|
-
|
|
139
|
-
|
|
168
|
+
nodes =
|
|
169
|
+
helpers && typeof helpers.queryAllSmart === 'function'
|
|
170
|
+
? helpers.queryAllSmart('header, footer, main, nav, aside, section, form, [role]')
|
|
171
|
+
: document.querySelectorAll('header, footer, main, nav, aside, section, form, [role]');
|
|
140
172
|
} catch {
|
|
141
173
|
nodes = [];
|
|
142
174
|
}
|
|
@@ -146,7 +178,7 @@ function runInPage(ctx) {
|
|
|
146
178
|
for (const el of nodes) {
|
|
147
179
|
if (!el || seen.has(el)) continue;
|
|
148
180
|
seen.add(el);
|
|
149
|
-
if (
|
|
181
|
+
if (isContentinfoCandidate(el)) contentinfos.push(el);
|
|
150
182
|
}
|
|
151
183
|
|
|
152
184
|
if (contentinfos.length === 0) {
|
|
@@ -158,7 +190,7 @@ function runInPage(ctx) {
|
|
|
158
190
|
if (!hasLandmarkAncestor(el)) continue;
|
|
159
191
|
|
|
160
192
|
const stableSelector = helpers.buildSelector ? helpers.buildSelector(el) : 'html';
|
|
161
|
-
const html = helpers.getOuterHtmlSnippet ? helpers.getOuterHtmlSnippet(el) :
|
|
193
|
+
const html = helpers.getOuterHtmlSnippet ? helpers.getOuterHtmlSnippet(el) : el.outerHTML || '';
|
|
162
194
|
|
|
163
195
|
occurrences.push({
|
|
164
196
|
selector: stableSelector,
|
|
@@ -177,7 +209,12 @@ function runInPage(ctx) {
|
|
|
177
209
|
}
|
|
178
210
|
|
|
179
211
|
if (occurrences.length) {
|
|
180
|
-
return {
|
|
212
|
+
return {
|
|
213
|
+
ruleId: rule.ruleId,
|
|
214
|
+
outcome: 'cantTell',
|
|
215
|
+
severity: rule.defaultSeverity || 'minor',
|
|
216
|
+
occurrences
|
|
217
|
+
};
|
|
181
218
|
}
|
|
182
219
|
return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
|
|
183
220
|
}
|
|
@@ -18,13 +18,21 @@
|
|
|
18
18
|
* `type: 'manual'` rule; see landmark-banner-is-top-level's
|
|
19
19
|
* header comment for the shared rationale/precedent (this rule mirrors
|
|
20
20
|
* its structure with main in place of banner/header).
|
|
21
|
+
* - Did NOT need the 2026-08-01 fix applied to
|
|
22
|
+
* landmark-banner-is-top-level/landmark-contentinfo-is-top-level (see
|
|
23
|
+
* that file's header comment): `<main>`'s implicit role is
|
|
24
|
+
* unconditional per HTML-AAM — unlike `<header>`/`<footer>`, nesting
|
|
25
|
+
* never suppresses it — so `getImplicitLandmarkRole`'s `main` branch
|
|
26
|
+
* was never subject to the same self-defeating candidate-selection
|
|
27
|
+
* bug. Confirmed by inspection, not just by absence of a bug report.
|
|
21
28
|
*/
|
|
22
29
|
|
|
23
30
|
const id = 'landmark-main-is-top-level';
|
|
24
31
|
|
|
25
32
|
const meta = {
|
|
26
33
|
title: 'Main landmark must be top-level',
|
|
27
|
-
description:
|
|
34
|
+
description:
|
|
35
|
+
'Checks that the main landmark (role="main" or <main>) is not nested inside another landmark region.',
|
|
28
36
|
i18n: {
|
|
29
37
|
titleKey: 'landmarkMainIsTopLevel_title',
|
|
30
38
|
descriptionKey: 'landmarkMainIsTopLevel_description'
|
|
@@ -44,7 +52,9 @@ function runInPage(ctx) {
|
|
|
44
52
|
const { document, root, helpers, rule } = ctx;
|
|
45
53
|
|
|
46
54
|
function normalizeWs(s) {
|
|
47
|
-
return String(s || '')
|
|
55
|
+
return String(s || '')
|
|
56
|
+
.replace(/\s+/g, ' ')
|
|
57
|
+
.trim();
|
|
48
58
|
}
|
|
49
59
|
|
|
50
60
|
// Delegates to the shared helpers.getLandmarkNameInfo (aria-label -> aria-labelledby, via the
|
|
@@ -104,7 +114,16 @@ function runInPage(ctx) {
|
|
|
104
114
|
return '';
|
|
105
115
|
}
|
|
106
116
|
|
|
107
|
-
const LANDMARK_ROLES = new Set([
|
|
117
|
+
const LANDMARK_ROLES = new Set([
|
|
118
|
+
'banner',
|
|
119
|
+
'contentinfo',
|
|
120
|
+
'main',
|
|
121
|
+
'navigation',
|
|
122
|
+
'complementary',
|
|
123
|
+
'region',
|
|
124
|
+
'form',
|
|
125
|
+
'search'
|
|
126
|
+
]);
|
|
108
127
|
|
|
109
128
|
function getLandmarkRole(el) {
|
|
110
129
|
if (!el || !el.getAttribute) return '';
|
|
@@ -114,7 +133,7 @@ function runInPage(ctx) {
|
|
|
114
133
|
}
|
|
115
134
|
|
|
116
135
|
function hasLandmarkAncestor(el) {
|
|
117
|
-
const scopeRoots = Array.isArray(root) ? root :
|
|
136
|
+
const scopeRoots = Array.isArray(root) ? root : root ? [root] : [];
|
|
118
137
|
let p = el.parentElement;
|
|
119
138
|
while (p) {
|
|
120
139
|
if (getLandmarkRole(p)) return true;
|
|
@@ -130,11 +149,12 @@ function runInPage(ctx) {
|
|
|
130
149
|
// landmark-unique-manual.js's header comment for the real page (Airtable, 2026-07-23)
|
|
131
150
|
// that surfaced this gap: a third-party shadow-DOM-hosted widget's own landmark is
|
|
132
151
|
// invisible to a light-DOM-only query.
|
|
133
|
-
let nodes
|
|
152
|
+
let nodes;
|
|
134
153
|
try {
|
|
135
|
-
nodes =
|
|
136
|
-
|
|
137
|
-
|
|
154
|
+
nodes =
|
|
155
|
+
helpers && typeof helpers.queryAllSmart === 'function'
|
|
156
|
+
? helpers.queryAllSmart('header, footer, main, nav, aside, section, form, [role]')
|
|
157
|
+
: document.querySelectorAll('header, footer, main, nav, aside, section, form, [role]');
|
|
138
158
|
} catch {
|
|
139
159
|
nodes = [];
|
|
140
160
|
}
|
|
@@ -156,7 +176,7 @@ function runInPage(ctx) {
|
|
|
156
176
|
if (!hasLandmarkAncestor(el)) continue;
|
|
157
177
|
|
|
158
178
|
const stableSelector = helpers.buildSelector ? helpers.buildSelector(el) : 'html';
|
|
159
|
-
const html = helpers.getOuterHtmlSnippet ? helpers.getOuterHtmlSnippet(el) :
|
|
179
|
+
const html = helpers.getOuterHtmlSnippet ? helpers.getOuterHtmlSnippet(el) : el.outerHTML || '';
|
|
160
180
|
|
|
161
181
|
occurrences.push({
|
|
162
182
|
selector: stableSelector,
|
|
@@ -175,7 +195,12 @@ function runInPage(ctx) {
|
|
|
175
195
|
}
|
|
176
196
|
|
|
177
197
|
if (occurrences.length) {
|
|
178
|
-
return {
|
|
198
|
+
return {
|
|
199
|
+
ruleId: rule.ruleId,
|
|
200
|
+
outcome: 'cantTell',
|
|
201
|
+
severity: rule.defaultSeverity || 'minor',
|
|
202
|
+
occurrences
|
|
203
|
+
};
|
|
179
204
|
}
|
|
180
205
|
return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
|
|
181
206
|
}
|
|
@@ -35,7 +35,8 @@ const id = 'landmark-no-duplicate-banner';
|
|
|
35
35
|
|
|
36
36
|
const meta = {
|
|
37
37
|
title: 'Page must not have more than one banner landmark',
|
|
38
|
-
description:
|
|
38
|
+
description:
|
|
39
|
+
'Checks that at most one banner landmark (role="banner" or a non-nested <header>) exists on the page.',
|
|
39
40
|
i18n: {
|
|
40
41
|
titleKey: 'landmarkNoDuplicateBanner_title',
|
|
41
42
|
descriptionKey: 'landmarkNoDuplicateBanner_description'
|
|
@@ -55,7 +56,9 @@ function runInPage(ctx) {
|
|
|
55
56
|
const { document, helpers, rule } = ctx;
|
|
56
57
|
|
|
57
58
|
function normalizeWs(s) {
|
|
58
|
-
return String(s || '')
|
|
59
|
+
return String(s || '')
|
|
60
|
+
.replace(/\s+/g, ' ')
|
|
61
|
+
.trim();
|
|
59
62
|
}
|
|
60
63
|
|
|
61
64
|
// Delegates to the shared helpers.getLandmarkNameInfo (aria-label -> aria-labelledby, via the
|
|
@@ -115,7 +118,16 @@ function runInPage(ctx) {
|
|
|
115
118
|
return '';
|
|
116
119
|
}
|
|
117
120
|
|
|
118
|
-
const LANDMARK_ROLES = new Set([
|
|
121
|
+
const LANDMARK_ROLES = new Set([
|
|
122
|
+
'banner',
|
|
123
|
+
'contentinfo',
|
|
124
|
+
'main',
|
|
125
|
+
'navigation',
|
|
126
|
+
'complementary',
|
|
127
|
+
'region',
|
|
128
|
+
'form',
|
|
129
|
+
'search'
|
|
130
|
+
]);
|
|
119
131
|
|
|
120
132
|
function getLandmarkRole(el) {
|
|
121
133
|
if (!el || !el.getAttribute) return '';
|
|
@@ -128,16 +140,18 @@ function runInPage(ctx) {
|
|
|
128
140
|
// landmark-unique-manual.js's header comment for the real page (Airtable, 2026-07-23)
|
|
129
141
|
// that surfaced this gap: a third-party shadow-DOM-hosted widget's own landmark is
|
|
130
142
|
// invisible to a light-DOM-only query.
|
|
131
|
-
let nodes
|
|
143
|
+
let nodes;
|
|
132
144
|
try {
|
|
133
|
-
nodes =
|
|
134
|
-
|
|
135
|
-
|
|
145
|
+
nodes =
|
|
146
|
+
helpers && typeof helpers.queryAllSmart === 'function'
|
|
147
|
+
? helpers.queryAllSmart('header, footer, main, nav, aside, section, form, [role]')
|
|
148
|
+
: document.querySelectorAll('header, footer, main, nav, aside, section, form, [role]');
|
|
136
149
|
} catch {
|
|
137
150
|
nodes = [];
|
|
138
151
|
}
|
|
139
152
|
|
|
140
|
-
const isAccTreeEligible =
|
|
153
|
+
const isAccTreeEligible =
|
|
154
|
+
helpers && typeof helpers.isAccTreeEligible === 'function' ? helpers.isAccTreeEligible : null;
|
|
141
155
|
|
|
142
156
|
function isExposedToAt(el) {
|
|
143
157
|
if (!isAccTreeEligible) return true;
|
|
@@ -165,7 +179,7 @@ function runInPage(ctx) {
|
|
|
165
179
|
|
|
166
180
|
const occurrences = banners.map((el) => {
|
|
167
181
|
const stableSelector = helpers.buildSelector ? helpers.buildSelector(el) : 'html';
|
|
168
|
-
const html = helpers.getOuterHtmlSnippet ? helpers.getOuterHtmlSnippet(el) :
|
|
182
|
+
const html = helpers.getOuterHtmlSnippet ? helpers.getOuterHtmlSnippet(el) : el.outerHTML || '';
|
|
169
183
|
|
|
170
184
|
return {
|
|
171
185
|
selector: stableSelector,
|
|
@@ -183,7 +197,12 @@ function runInPage(ctx) {
|
|
|
183
197
|
};
|
|
184
198
|
});
|
|
185
199
|
|
|
186
|
-
return {
|
|
200
|
+
return {
|
|
201
|
+
ruleId: rule.ruleId,
|
|
202
|
+
outcome: 'cantTell',
|
|
203
|
+
severity: rule.defaultSeverity || 'minor',
|
|
204
|
+
occurrences
|
|
205
|
+
};
|
|
187
206
|
}
|
|
188
207
|
|
|
189
208
|
module.exports = { id, meta, runInPage };
|
|
@@ -27,7 +27,8 @@ const id = 'landmark-no-duplicate-contentinfo';
|
|
|
27
27
|
|
|
28
28
|
const meta = {
|
|
29
29
|
title: 'Page must not have more than one contentinfo landmark',
|
|
30
|
-
description:
|
|
30
|
+
description:
|
|
31
|
+
'Checks that at most one contentinfo landmark (role="contentinfo" or a non-nested <footer>) exists on the page.',
|
|
31
32
|
i18n: {
|
|
32
33
|
titleKey: 'landmarkNoDuplicateContentinfo_title',
|
|
33
34
|
descriptionKey: 'landmarkNoDuplicateContentinfo_description'
|
|
@@ -47,7 +48,9 @@ function runInPage(ctx) {
|
|
|
47
48
|
const { document, helpers, rule } = ctx;
|
|
48
49
|
|
|
49
50
|
function normalizeWs(s) {
|
|
50
|
-
return String(s || '')
|
|
51
|
+
return String(s || '')
|
|
52
|
+
.replace(/\s+/g, ' ')
|
|
53
|
+
.trim();
|
|
51
54
|
}
|
|
52
55
|
|
|
53
56
|
// Delegates to the shared helpers.getLandmarkNameInfo (aria-label -> aria-labelledby, via the
|
|
@@ -107,7 +110,16 @@ function runInPage(ctx) {
|
|
|
107
110
|
return '';
|
|
108
111
|
}
|
|
109
112
|
|
|
110
|
-
const LANDMARK_ROLES = new Set([
|
|
113
|
+
const LANDMARK_ROLES = new Set([
|
|
114
|
+
'banner',
|
|
115
|
+
'contentinfo',
|
|
116
|
+
'main',
|
|
117
|
+
'navigation',
|
|
118
|
+
'complementary',
|
|
119
|
+
'region',
|
|
120
|
+
'form',
|
|
121
|
+
'search'
|
|
122
|
+
]);
|
|
111
123
|
|
|
112
124
|
function getLandmarkRole(el) {
|
|
113
125
|
if (!el || !el.getAttribute) return '';
|
|
@@ -120,16 +132,18 @@ function runInPage(ctx) {
|
|
|
120
132
|
// landmark-unique-manual.js's header comment for the real page (Airtable, 2026-07-23)
|
|
121
133
|
// that surfaced this gap: a third-party shadow-DOM-hosted widget's own landmark is
|
|
122
134
|
// invisible to a light-DOM-only query.
|
|
123
|
-
let nodes
|
|
135
|
+
let nodes;
|
|
124
136
|
try {
|
|
125
|
-
nodes =
|
|
126
|
-
|
|
127
|
-
|
|
137
|
+
nodes =
|
|
138
|
+
helpers && typeof helpers.queryAllSmart === 'function'
|
|
139
|
+
? helpers.queryAllSmart('header, footer, main, nav, aside, section, form, [role]')
|
|
140
|
+
: document.querySelectorAll('header, footer, main, nav, aside, section, form, [role]');
|
|
128
141
|
} catch {
|
|
129
142
|
nodes = [];
|
|
130
143
|
}
|
|
131
144
|
|
|
132
|
-
const isAccTreeEligible =
|
|
145
|
+
const isAccTreeEligible =
|
|
146
|
+
helpers && typeof helpers.isAccTreeEligible === 'function' ? helpers.isAccTreeEligible : null;
|
|
133
147
|
|
|
134
148
|
function isExposedToAt(el) {
|
|
135
149
|
if (!isAccTreeEligible) return true;
|
|
@@ -157,7 +171,7 @@ function runInPage(ctx) {
|
|
|
157
171
|
|
|
158
172
|
const occurrences = contentinfos.map((el) => {
|
|
159
173
|
const stableSelector = helpers.buildSelector ? helpers.buildSelector(el) : 'html';
|
|
160
|
-
const html = helpers.getOuterHtmlSnippet ? helpers.getOuterHtmlSnippet(el) :
|
|
174
|
+
const html = helpers.getOuterHtmlSnippet ? helpers.getOuterHtmlSnippet(el) : el.outerHTML || '';
|
|
161
175
|
|
|
162
176
|
return {
|
|
163
177
|
selector: stableSelector,
|
|
@@ -175,7 +189,12 @@ function runInPage(ctx) {
|
|
|
175
189
|
};
|
|
176
190
|
});
|
|
177
191
|
|
|
178
|
-
return {
|
|
192
|
+
return {
|
|
193
|
+
ruleId: rule.ruleId,
|
|
194
|
+
outcome: 'cantTell',
|
|
195
|
+
severity: rule.defaultSeverity || 'minor',
|
|
196
|
+
occurrences
|
|
197
|
+
};
|
|
179
198
|
}
|
|
180
199
|
|
|
181
200
|
module.exports = { id, meta, runInPage };
|
|
@@ -50,7 +50,9 @@ function runInPage(ctx) {
|
|
|
50
50
|
const { document, helpers, rule } = ctx;
|
|
51
51
|
|
|
52
52
|
function normalizeWs(s) {
|
|
53
|
-
return String(s || '')
|
|
53
|
+
return String(s || '')
|
|
54
|
+
.replace(/\s+/g, ' ')
|
|
55
|
+
.trim();
|
|
54
56
|
}
|
|
55
57
|
|
|
56
58
|
function getExplicitRoleToken(el) {
|
|
@@ -71,16 +73,18 @@ function runInPage(ctx) {
|
|
|
71
73
|
// landmark-unique-manual.js's header comment for the real page (Airtable, 2026-07-23)
|
|
72
74
|
// that surfaced this gap: a third-party shadow-DOM-hosted widget's own landmark is
|
|
73
75
|
// invisible to a light-DOM-only query.
|
|
74
|
-
let nodes
|
|
76
|
+
let nodes;
|
|
75
77
|
try {
|
|
76
|
-
nodes =
|
|
77
|
-
|
|
78
|
-
|
|
78
|
+
nodes =
|
|
79
|
+
helpers && typeof helpers.queryAllSmart === 'function'
|
|
80
|
+
? helpers.queryAllSmart('main, [role]')
|
|
81
|
+
: document.querySelectorAll('main, [role]');
|
|
79
82
|
} catch {
|
|
80
83
|
nodes = [];
|
|
81
84
|
}
|
|
82
85
|
|
|
83
|
-
const isAccTreeEligible =
|
|
86
|
+
const isAccTreeEligible =
|
|
87
|
+
helpers && typeof helpers.isAccTreeEligible === 'function' ? helpers.isAccTreeEligible : null;
|
|
84
88
|
|
|
85
89
|
function isExposedToAt(el) {
|
|
86
90
|
if (!isAccTreeEligible) return true;
|
|
@@ -108,7 +112,7 @@ function runInPage(ctx) {
|
|
|
108
112
|
|
|
109
113
|
const occurrences = mains.map((el) => {
|
|
110
114
|
const stableSelector = helpers.buildSelector ? helpers.buildSelector(el) : 'html';
|
|
111
|
-
const html = helpers.getOuterHtmlSnippet ? helpers.getOuterHtmlSnippet(el) :
|
|
115
|
+
const html = helpers.getOuterHtmlSnippet ? helpers.getOuterHtmlSnippet(el) : el.outerHTML || '';
|
|
112
116
|
|
|
113
117
|
return {
|
|
114
118
|
selector: stableSelector,
|
|
@@ -126,7 +130,12 @@ function runInPage(ctx) {
|
|
|
126
130
|
};
|
|
127
131
|
});
|
|
128
132
|
|
|
129
|
-
return {
|
|
133
|
+
return {
|
|
134
|
+
ruleId: rule.ruleId,
|
|
135
|
+
outcome: 'cantTell',
|
|
136
|
+
severity: rule.defaultSeverity || 'minor',
|
|
137
|
+
occurrences
|
|
138
|
+
};
|
|
130
139
|
}
|
|
131
140
|
|
|
132
141
|
module.exports = { id, meta, runInPage };
|