@surea11y/core 1.1.2 → 1.2.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 +14 -0
- package/README.md +3 -0
- package/bin/core.js +107 -3
- package/docs/API_STABILITY.md +61 -0
- package/docs/BASELINE.md +66 -0
- package/docs/CLI.md +26 -0
- package/docs/ENGINE_OPTIONS.md +2 -0
- package/docs/INTEGRATION.md +1 -1
- package/docs/OUTPUT_SCHEMA.md +1 -1
- package/docs/REPORT.md +33 -0
- package/docs/RULE_AUTHORING.md +31 -0
- package/package.json +1 -1
- package/src/baseline.js +0 -0
- package/src/checks/automatic/aria-hidden-body.js +9 -1
- package/src/checks/automatic/aria-prohibited-children.js +71 -14
- package/src/checks/automatic/bypass-blocks-present.js +9 -1
- package/src/checks/automatic/css-orientation-lock.js +9 -1
- package/src/checks/automatic/html-xml-lang-mismatch.js +9 -1
- package/src/checks/automatic/language-page-present.js +9 -1
- package/src/checks/automatic/meta-refresh-no-exceptions.js +9 -1
- package/src/checks/automatic/meta-refresh-timing-absent.js +9 -1
- package/src/checks/automatic/meta-viewport-zoom-enabled.js +9 -1
- package/src/checks/automatic/page-title-present.js +9 -1
- package/src/checks/manual/empty-heading-manual.js +1 -6
- package/src/checks/manual/empty-table-header-manual.js +5 -9
- package/src/checks/manual/heading-order-manual.js +1 -6
- package/src/checks/manual/landmark-banner-is-top-level-manual.js +30 -14
- package/src/checks/manual/landmark-contentinfo-is-top-level-manual.js +30 -14
- package/src/checks/manual/landmark-main-is-top-level-manual.js +30 -14
- package/src/checks/manual/landmark-no-duplicate-banner-manual.js +25 -13
- package/src/checks/manual/landmark-no-duplicate-contentinfo-manual.js +25 -13
- package/src/checks/manual/landmark-one-main-manual.js +9 -1
- package/src/checks/manual/landmark-unique-manual.js +32 -30
- package/src/checks/manual/meta-viewport-large-manual.js +9 -1
- package/src/checks/manual/page-has-heading-one-manual.js +9 -1
- package/src/checks/manual/page-title-patterns-manual.js +9 -1
- package/src/checks/manual/region-manual.js +34 -14
- package/src/checks/manual/scope-attr-valid-manual.js +2 -7
- package/src/checks/manual/tabindex-manual.js +2 -7
- package/src/core/aria-helpers.js +82 -18
- package/src/core/dom-helpers.js +32 -0
- package/src/core/dom-runner.js +8 -0
- package/src/core/rule-meta.js +19 -0
- package/src/core.js +1781 -404
- package/src/i18n/en.js +2 -0
- package/src/i18n/fr.js +2 -0
- package/src/report.js +482 -0
|
@@ -71,25 +71,37 @@ function runInPage(ctx) {
|
|
|
71
71
|
return raw.split(/\s+/)[0].toLowerCase();
|
|
72
72
|
}
|
|
73
73
|
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
74
|
+
// Delegates to the shared helpers.hasLandmarkScopingAncestor for the
|
|
75
|
+
// question "does this element sit inside a sectioning-content/<main>
|
|
76
|
+
// ancestor that suppresses its conditional implicit role" — role-aware
|
|
77
|
+
// (an ancestor's bare TAG only counts when it carries no role attribute
|
|
78
|
+
// at all; an explicit role="dialog"-style override no longer suppresses)
|
|
79
|
+
// rather than a local tag-only copy. See that function's header comment
|
|
80
|
+
// in src/core/aria-helpers.js for the full algorithm and the real page
|
|
81
|
+
// (handsontable.com's docs-assistant side panel, an
|
|
82
|
+
// <aside role="dialog"> containing its own <header>) that surfaced this
|
|
83
|
+
// rule's own former tag-only copy as a false negative.
|
|
84
|
+
function hasSectioningAncestor(el, includeMain) {
|
|
85
|
+
return helpers && typeof helpers.hasLandmarkScopingAncestor === 'function'
|
|
86
|
+
? helpers.hasLandmarkScopingAncestor(el, { includeMain })
|
|
87
|
+
: false;
|
|
84
88
|
}
|
|
85
89
|
|
|
86
90
|
function getImplicitLandmarkRole(el) {
|
|
87
91
|
const tag = el.tagName ? el.tagName.toLowerCase() : '';
|
|
88
|
-
if (tag === 'header') return
|
|
89
|
-
if (tag === 'footer') return
|
|
92
|
+
if (tag === 'header') return hasSectioningAncestor(el, true) ? '' : 'banner';
|
|
93
|
+
if (tag === 'footer') return hasSectioningAncestor(el, true) ? '' : 'contentinfo';
|
|
90
94
|
if (tag === 'main') return 'main';
|
|
91
95
|
if (tag === 'nav') return 'navigation';
|
|
92
|
-
if (tag === 'aside')
|
|
96
|
+
if (tag === 'aside') {
|
|
97
|
+
// A named <aside> is never suppressed, even when nested — matches
|
|
98
|
+
// landmark-unique's own verified-against-reference-engine precedent
|
|
99
|
+
// (that engine's real `aside` implicit-role function keeps
|
|
100
|
+
// "complementary" when the element has an accessible name, even
|
|
101
|
+
// inside sectioning content); propagated here for consistency.
|
|
102
|
+
if (!hasSectioningAncestor(el, false)) return 'complementary';
|
|
103
|
+
return getAccessibleLandmarkName(el) ? 'complementary' : '';
|
|
104
|
+
}
|
|
93
105
|
if (tag === 'section') return getAccessibleLandmarkName(el) ? 'region' : '';
|
|
94
106
|
if (tag === 'form') return getAccessibleLandmarkName(el) ? 'form' : '';
|
|
95
107
|
return '';
|
|
@@ -61,6 +61,14 @@ const meta = {
|
|
|
61
61
|
coverage: {}
|
|
62
62
|
};
|
|
63
63
|
|
|
64
|
+
// This check is inherently whole-document (does the PAGE have this
|
|
65
|
+
// property?), not evaluable per-subtree -- notApplicable when contextSelector
|
|
66
|
+
// scoped this run narrower than the whole document, or when
|
|
67
|
+
// engineOptions.fragment:true was set (see helpers.isWholeDocumentScope).
|
|
68
|
+
function applicability(ctx) {
|
|
69
|
+
return ctx.helpers.isWholeDocumentScope ? ctx.helpers.isWholeDocumentScope() : true;
|
|
70
|
+
}
|
|
71
|
+
|
|
64
72
|
function runInPage(ctx) {
|
|
65
73
|
const { document, helpers, rule } = ctx;
|
|
66
74
|
|
|
@@ -148,4 +156,4 @@ function runInPage(ctx) {
|
|
|
148
156
|
};
|
|
149
157
|
}
|
|
150
158
|
|
|
151
|
-
module.exports = { id, meta, runInPage };
|
|
159
|
+
module.exports = { id, meta, runInPage, applicability };
|
|
@@ -75,46 +75,48 @@ function runInPage(ctx) {
|
|
|
75
75
|
return raw.split(/\s+/)[0].toLowerCase();
|
|
76
76
|
}
|
|
77
77
|
|
|
78
|
-
//
|
|
79
|
-
//
|
|
80
|
-
//
|
|
81
|
-
//
|
|
82
|
-
//
|
|
83
|
-
//
|
|
84
|
-
//
|
|
85
|
-
//
|
|
86
|
-
//
|
|
87
|
-
// <aside
|
|
88
|
-
//
|
|
89
|
-
//
|
|
90
|
-
//
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
78
|
+
// Delegates to the shared helpers.hasLandmarkScopingAncestor (role-aware:
|
|
79
|
+
// an ancestor's bare TAG only counts when it carries no role attribute at
|
|
80
|
+
// all; an explicit role="dialog"-style override no longer suppresses —
|
|
81
|
+
// see that function's header comment in src/core/aria-helpers.js) rather
|
|
82
|
+
// than the two local tag-only Sets this file used to carry. Two distinct
|
|
83
|
+
// ancestor scopes, verified 2026-07-20 against a widely-used reference
|
|
84
|
+
// engine's own implicit-role functions directly rather than assumed from
|
|
85
|
+
// one shared list: <header>/<footer> use "sectioning content PLUS <main>"
|
|
86
|
+
// (includeMain: true) to decide banner/contentinfo suppression, but
|
|
87
|
+
// <aside> uses PLAIN sectioning content only — NOT main (includeMain:
|
|
88
|
+
// false) — to decide complementary suppression. The old single
|
|
89
|
+
// SECTIONING_ANCESTORS set (which included 'main') was correct for
|
|
90
|
+
// header/footer but wrong for aside — found via a real page: Know Your
|
|
91
|
+
// Meme's two unnamed <aside class="extra-large-only"> elements are direct
|
|
92
|
+
// children of <main>, which incorrectly suppressed their implicit
|
|
93
|
+
// "complementary" role entirely, hiding a real duplicate-landmark
|
|
94
|
+
// violation that reference engine correctly flags. The tag-only
|
|
95
|
+
// (non-role-aware) half of this bug was separately found and fixed
|
|
96
|
+
// 2026-07-30 via the cross-engine comparisons project, on
|
|
97
|
+
// handsontable.com's docs-assistant side panel: an <aside role="dialog">
|
|
98
|
+
// containing its own <header> — role="dialog" isn't one of the four
|
|
99
|
+
// scoping roles, so the nested <header> keeps "banner" per spec, but a
|
|
100
|
+
// tag-only check unconditionally suppressed it just because the ancestor
|
|
101
|
+
// TAG was <aside>.
|
|
102
|
+
function hasSectioningAncestor(el, includeMain) {
|
|
103
|
+
return helpers && typeof helpers.hasLandmarkScopingAncestor === 'function'
|
|
104
|
+
? helpers.hasLandmarkScopingAncestor(el, { includeMain })
|
|
105
|
+
: false;
|
|
102
106
|
}
|
|
103
107
|
|
|
104
108
|
function getImplicitLandmarkRole(el) {
|
|
105
109
|
const tag = el.tagName ? el.tagName.toLowerCase() : '';
|
|
106
|
-
if (tag === 'header') return
|
|
107
|
-
if (tag === 'footer') return
|
|
110
|
+
if (tag === 'header') return hasSectioningAncestor(el, true) ? '' : 'banner';
|
|
111
|
+
if (tag === 'footer') return hasSectioningAncestor(el, true) ? '' : 'contentinfo';
|
|
108
112
|
if (tag === 'main') return 'main';
|
|
109
113
|
if (tag === 'nav') return 'navigation';
|
|
110
114
|
if (tag === 'aside') {
|
|
111
115
|
// Per a widely-used reference engine's own `aside` implicit-role function: suppressed by a
|
|
112
116
|
// sectioning-content ancestor ONLY when the <aside> also has no
|
|
113
117
|
// accessible name — a named <aside> is never suppressed, even when
|
|
114
|
-
// nested.
|
|
115
|
-
|
|
116
|
-
// leaving a known partial fix in place.
|
|
117
|
-
if (!hasSectioningAncestorFrom(el, SECTIONING_ANCESTORS)) return 'complementary';
|
|
118
|
+
// nested.
|
|
119
|
+
if (!hasSectioningAncestor(el, false)) return 'complementary';
|
|
118
120
|
return getAccessibleLandmarkName(el) ? 'complementary' : '';
|
|
119
121
|
}
|
|
120
122
|
if (tag === 'section') return getAccessibleLandmarkName(el) ? 'region' : '';
|
|
@@ -43,6 +43,14 @@ const meta = {
|
|
|
43
43
|
coverage: {}
|
|
44
44
|
};
|
|
45
45
|
|
|
46
|
+
// This check is inherently whole-document (does the PAGE have this
|
|
47
|
+
// property?), not evaluable per-subtree -- notApplicable when contextSelector
|
|
48
|
+
// scoped this run narrower than the whole document, or when
|
|
49
|
+
// engineOptions.fragment:true was set (see helpers.isWholeDocumentScope).
|
|
50
|
+
function applicability(ctx) {
|
|
51
|
+
return ctx.helpers.isWholeDocumentScope ? ctx.helpers.isWholeDocumentScope() : true;
|
|
52
|
+
}
|
|
53
|
+
|
|
46
54
|
function runInPage(ctx) {
|
|
47
55
|
const { document, helpers, rule } = ctx;
|
|
48
56
|
|
|
@@ -116,4 +124,4 @@ function runInPage(ctx) {
|
|
|
116
124
|
return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
|
|
117
125
|
}
|
|
118
126
|
|
|
119
|
-
module.exports = { id, meta, runInPage };
|
|
127
|
+
module.exports = { id, meta, runInPage, applicability };
|
|
@@ -53,6 +53,14 @@ const meta = {
|
|
|
53
53
|
coverage: {}
|
|
54
54
|
};
|
|
55
55
|
|
|
56
|
+
// This check is inherently whole-document (does the PAGE have this
|
|
57
|
+
// property?), not evaluable per-subtree -- notApplicable when contextSelector
|
|
58
|
+
// scoped this run narrower than the whole document, or when
|
|
59
|
+
// engineOptions.fragment:true was set (see helpers.isWholeDocumentScope).
|
|
60
|
+
function applicability(ctx) {
|
|
61
|
+
return ctx.helpers.isWholeDocumentScope ? ctx.helpers.isWholeDocumentScope() : true;
|
|
62
|
+
}
|
|
63
|
+
|
|
56
64
|
function runInPage(ctx) {
|
|
57
65
|
const { document, helpers, rule } = ctx;
|
|
58
66
|
|
|
@@ -136,4 +144,4 @@ function runInPage(ctx) {
|
|
|
136
144
|
};
|
|
137
145
|
}
|
|
138
146
|
|
|
139
|
-
module.exports = { id, meta, runInPage };
|
|
147
|
+
module.exports = { id, meta, runInPage, applicability };
|
|
@@ -24,6 +24,14 @@ const meta = {
|
|
|
24
24
|
coverage: { facetsBySc: { '2.4.2': ['page-title-patterns'] } }
|
|
25
25
|
};
|
|
26
26
|
|
|
27
|
+
// This check is inherently whole-document (does the PAGE have this
|
|
28
|
+
// property?), not evaluable per-subtree -- notApplicable when contextSelector
|
|
29
|
+
// scoped this run narrower than the whole document, or when
|
|
30
|
+
// engineOptions.fragment:true was set (see helpers.isWholeDocumentScope).
|
|
31
|
+
function applicability(ctx) {
|
|
32
|
+
return ctx.helpers.isWholeDocumentScope ? ctx.helpers.isWholeDocumentScope() : true;
|
|
33
|
+
}
|
|
34
|
+
|
|
27
35
|
function runInPage(ctx) {
|
|
28
36
|
const { document, helpers, rule } = ctx;
|
|
29
37
|
const probes = ctx && ctx.inputs && ctx.inputs.probes && typeof ctx.inputs.probes === 'object'
|
|
@@ -259,4 +267,4 @@ function runInPage(ctx) {
|
|
|
259
267
|
return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
|
|
260
268
|
}
|
|
261
269
|
|
|
262
|
-
module.exports = { id, meta, runInPage };
|
|
270
|
+
module.exports = { id, meta, runInPage, applicability };
|
|
@@ -46,6 +46,14 @@ const meta = {
|
|
|
46
46
|
coverage: {}
|
|
47
47
|
};
|
|
48
48
|
|
|
49
|
+
// This check is inherently whole-document (does the PAGE have this
|
|
50
|
+
// property?), not evaluable per-subtree -- notApplicable when contextSelector
|
|
51
|
+
// scoped this run narrower than the whole document, or when
|
|
52
|
+
// engineOptions.fragment:true was set (see helpers.isWholeDocumentScope).
|
|
53
|
+
function applicability(ctx) {
|
|
54
|
+
return ctx.helpers.isWholeDocumentScope ? ctx.helpers.isWholeDocumentScope() : true;
|
|
55
|
+
}
|
|
56
|
+
|
|
49
57
|
function runInPage(ctx) {
|
|
50
58
|
const { document, helpers, rule } = ctx;
|
|
51
59
|
|
|
@@ -79,25 +87,37 @@ function runInPage(ctx) {
|
|
|
79
87
|
return raw.split(/\s+/)[0].toLowerCase();
|
|
80
88
|
}
|
|
81
89
|
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
90
|
+
// Delegates to the shared helpers.hasLandmarkScopingAncestor for the
|
|
91
|
+
// question "does this element sit inside a sectioning-content/<main>
|
|
92
|
+
// ancestor that suppresses its conditional implicit role" — role-aware
|
|
93
|
+
// (an ancestor's bare TAG only counts when it carries no role attribute
|
|
94
|
+
// at all; an explicit role="dialog"-style override no longer suppresses)
|
|
95
|
+
// rather than a local tag-only copy. See that function's header comment
|
|
96
|
+
// in src/core/aria-helpers.js for the full algorithm and the real page
|
|
97
|
+
// (handsontable.com's docs-assistant side panel, an
|
|
98
|
+
// <aside role="dialog"> containing its own <header>) that surfaced this
|
|
99
|
+
// rule's own former tag-only copy as a false negative.
|
|
100
|
+
function hasSectioningAncestor(el, includeMain) {
|
|
101
|
+
return helpers && typeof helpers.hasLandmarkScopingAncestor === 'function'
|
|
102
|
+
? helpers.hasLandmarkScopingAncestor(el, { includeMain })
|
|
103
|
+
: false;
|
|
92
104
|
}
|
|
93
105
|
|
|
94
106
|
function getImplicitLandmarkRole(el) {
|
|
95
107
|
const tag = el.tagName ? el.tagName.toLowerCase() : '';
|
|
96
|
-
if (tag === 'header') return
|
|
97
|
-
if (tag === 'footer') return
|
|
108
|
+
if (tag === 'header') return hasSectioningAncestor(el, true) ? '' : 'banner';
|
|
109
|
+
if (tag === 'footer') return hasSectioningAncestor(el, true) ? '' : 'contentinfo';
|
|
98
110
|
if (tag === 'main') return 'main';
|
|
99
111
|
if (tag === 'nav') return 'navigation';
|
|
100
|
-
if (tag === 'aside')
|
|
112
|
+
if (tag === 'aside') {
|
|
113
|
+
// A named <aside> is never suppressed, even when nested — matches
|
|
114
|
+
// landmark-unique's own verified-against-reference-engine precedent
|
|
115
|
+
// (that engine's real `aside` implicit-role function keeps
|
|
116
|
+
// "complementary" when the element has an accessible name, even
|
|
117
|
+
// inside sectioning content); propagated here for consistency.
|
|
118
|
+
if (!hasSectioningAncestor(el, false)) return 'complementary';
|
|
119
|
+
return getAccessibleLandmarkName(el) ? 'complementary' : '';
|
|
120
|
+
}
|
|
101
121
|
if (tag === 'section') return getAccessibleLandmarkName(el) ? 'region' : '';
|
|
102
122
|
if (tag === 'form') return getAccessibleLandmarkName(el) ? 'form' : '';
|
|
103
123
|
return '';
|
|
@@ -180,4 +200,4 @@ function runInPage(ctx) {
|
|
|
180
200
|
return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
|
|
181
201
|
}
|
|
182
202
|
|
|
183
|
-
module.exports = { id, meta, runInPage };
|
|
203
|
+
module.exports = { id, meta, runInPage, applicability };
|
|
@@ -39,16 +39,11 @@ const meta = {
|
|
|
39
39
|
};
|
|
40
40
|
|
|
41
41
|
function runInPage(ctx) {
|
|
42
|
-
const {
|
|
42
|
+
const { helpers, rule } = ctx;
|
|
43
43
|
|
|
44
44
|
const VALID_SCOPES = new Set(['row', 'col', 'rowgroup', 'colgroup']);
|
|
45
45
|
|
|
46
|
-
|
|
47
|
-
try {
|
|
48
|
-
nodes = document.querySelectorAll('[scope]');
|
|
49
|
-
} catch {
|
|
50
|
-
nodes = [];
|
|
51
|
-
}
|
|
46
|
+
const nodes = helpers.queryAllSmart ? helpers.queryAllSmart('[scope]') : helpers.queryAll('[scope]');
|
|
52
47
|
|
|
53
48
|
const occurrences = [];
|
|
54
49
|
let applicableCount = 0;
|
|
@@ -40,14 +40,9 @@ const meta = {
|
|
|
40
40
|
};
|
|
41
41
|
|
|
42
42
|
function runInPage(ctx) {
|
|
43
|
-
const {
|
|
43
|
+
const { helpers, rule } = ctx;
|
|
44
44
|
|
|
45
|
-
|
|
46
|
-
try {
|
|
47
|
-
nodes = document.querySelectorAll('[tabindex]');
|
|
48
|
-
} catch {
|
|
49
|
-
nodes = [];
|
|
50
|
-
}
|
|
45
|
+
const nodes = helpers.queryAllSmart ? helpers.queryAllSmart('[tabindex]') : helpers.queryAll('[tabindex]');
|
|
51
46
|
|
|
52
47
|
const occurrences = [];
|
|
53
48
|
let applicableCount = 0;
|
package/src/core/aria-helpers.js
CHANGED
|
@@ -38,6 +38,16 @@ function createAriaHelpers(opts, shared) {
|
|
|
38
38
|
const trim = (shared && shared.trim) || ((v) => (v == null ? '' : String(v)).trim());
|
|
39
39
|
const lower = (v) => trim(v).toLowerCase();
|
|
40
40
|
const ariaDocument = opts && opts.document;
|
|
41
|
+
// Same normalization as createDomHelpers's `roots` (src/core/dom-helpers.js)
|
|
42
|
+
// -- opts.root accepts a single element or an array (multi-region
|
|
43
|
+
// contextSelector support). Used to bound hasLandmarkScopingAncestor's
|
|
44
|
+
// ancestor walk to the scanned scope; see that function below.
|
|
45
|
+
const ariaRoots = (() => {
|
|
46
|
+
const r = opts && opts.root;
|
|
47
|
+
if (Array.isArray(r)) return r.filter((x) => x && typeof x === 'object');
|
|
48
|
+
if (r && typeof r === 'object') return [r];
|
|
49
|
+
return [];
|
|
50
|
+
})();
|
|
41
51
|
|
|
42
52
|
// Existence check for a single ID token — never throws, returns false
|
|
43
53
|
// (not "unknown") when the document isn't available so callers degrade
|
|
@@ -75,25 +85,70 @@ function createAriaHelpers(opts, shared) {
|
|
|
75
85
|
return false;
|
|
76
86
|
}
|
|
77
87
|
|
|
78
|
-
//
|
|
79
|
-
//
|
|
80
|
-
//
|
|
81
|
-
//
|
|
82
|
-
//
|
|
83
|
-
//
|
|
84
|
-
//
|
|
85
|
-
//
|
|
86
|
-
//
|
|
87
|
-
//
|
|
88
|
-
//
|
|
89
|
-
//
|
|
90
|
-
|
|
88
|
+
// Shared "does this element have a landmark-scoping ancestor" primitive
|
|
89
|
+
// — <header>'s "banner", <footer>'s "contentinfo", and <aside>'s
|
|
90
|
+
// "complementary" implicit roles are all conditioned on the same W3C
|
|
91
|
+
// ARIA-in-HTML exclusion: suppressed when nested inside sectioning
|
|
92
|
+
// content (article/aside/nav/section), and for header/footer only,
|
|
93
|
+
// also suppressed when nested inside <main> (pass includeMain: true).
|
|
94
|
+
// <aside> itself omits <main> from its own exclusion list — see the
|
|
95
|
+
// `aside` case in getElementRoleKey below — so callers must pass the
|
|
96
|
+
// right includeMain for the role they're computing.
|
|
97
|
+
//
|
|
98
|
+
// ROLE-AWARE, not tag-only: an ancestor's bare TAG only counts when it
|
|
99
|
+
// has NO role attribute at all; once ANY role attribute is present,
|
|
100
|
+
// only that attribute's own (first-token) value decides membership —
|
|
101
|
+
// matching a widely-used reference engine's real
|
|
102
|
+
// getSectioningContentSelector/getSectioningContentPlusMainSelector,
|
|
103
|
+
// verified 2026-07-30 by reading that engine's source directly:
|
|
104
|
+
// `${tag}:not([role])` for the tag-based branch, OR'd with a wholly
|
|
105
|
+
// separate ` [role=article], [role=complementary], [role=navigation],
|
|
106
|
+
// [role=region]` branch (plus `, main:not([role]), [role=main]` for
|
|
107
|
+
// the plus-main variant) — never a tag-AND-role intersection.
|
|
108
|
+
//
|
|
109
|
+
// This replaced a tag-name-only ancestor walk (used only for <header>,
|
|
110
|
+
// and separately re-duplicated with the same tag-only bug across 6
|
|
111
|
+
// manual landmark-check files — see each file's own delegation to
|
|
112
|
+
// helpers.hasLandmarkScopingAncestor now) that missed a real page:
|
|
113
|
+
// handsontable.com's docs-assistant side panel is an
|
|
114
|
+
// <aside role="dialog"> containing its own <header>. role="dialog" is
|
|
115
|
+
// not one of the four scoping roles, so per spec the nested <header>
|
|
116
|
+
// DOES keep its implicit "banner" role (confirmed against that
|
|
117
|
+
// reference engine's real output via a minimal repro) — but the old
|
|
118
|
+
// tag-only check unconditionally suppressed it purely because the
|
|
119
|
+
// ancestor TAG was <aside>, regardless of its role override. A real
|
|
120
|
+
// false negative in landmark-no-duplicate-banner/landmark-unique,
|
|
121
|
+
// found via the cross-engine comparisons project 2026-07-30.
|
|
122
|
+
const LANDMARK_SCOPING_TAGS = new Set(['article', 'aside', 'nav', 'section']);
|
|
123
|
+
const LANDMARK_SCOPING_ROLE_TOKENS = new Set(['article', 'complementary', 'navigation', 'region']);
|
|
124
|
+
|
|
125
|
+
function isLandmarkScopingAncestorElement(el, includeMain) {
|
|
126
|
+
const tag = lower(el.tagName || '');
|
|
127
|
+
const roleAttr = getAttr(el, 'role');
|
|
128
|
+
if (roleAttr == null) {
|
|
129
|
+
// No role attribute at all: falls back to the plain HTML tag.
|
|
130
|
+
if (LANDMARK_SCOPING_TAGS.has(tag)) return true;
|
|
131
|
+
return includeMain && tag === 'main';
|
|
132
|
+
}
|
|
133
|
+
// A role attribute is present (even empty/invalid) — the element's
|
|
134
|
+
// bare TAG no longer counts; only an explicit, scoping-relevant
|
|
135
|
+
// role value does.
|
|
136
|
+
const token = trim(roleAttr).split(/\s+/)[0].toLowerCase();
|
|
137
|
+
if (LANDMARK_SCOPING_ROLE_TOKENS.has(token)) return true;
|
|
138
|
+
return includeMain && token === 'main';
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function hasLandmarkScopingAncestor(el, opts) {
|
|
91
142
|
if (!isElement(el)) return false;
|
|
143
|
+
const includeMain = !!(opts && opts.includeMain);
|
|
92
144
|
let cur = el.parentElement;
|
|
93
145
|
let guard = 0;
|
|
94
146
|
while (cur && guard++ < 200) {
|
|
95
|
-
|
|
96
|
-
|
|
147
|
+
if (isLandmarkScopingAncestorElement(cur, includeMain)) return true;
|
|
148
|
+
// Don't climb past the scanned scope -- a contextSelector-scoped
|
|
149
|
+
// (or fragment) scan should never let ancestry OUTSIDE the
|
|
150
|
+
// analyzed subtree affect a role computed WITHIN it.
|
|
151
|
+
if (ariaRoots.includes(cur)) break;
|
|
97
152
|
cur = cur.parentElement;
|
|
98
153
|
}
|
|
99
154
|
return false;
|
|
@@ -749,14 +804,16 @@ function createAriaHelpers(opts, shared) {
|
|
|
749
804
|
if (tag === 'header') {
|
|
750
805
|
// <header>'s own implicit role is conditional: "banner" when
|
|
751
806
|
// top-level (not nested inside sectioning content/<main>),
|
|
752
|
-
// generic/null when nested — see
|
|
807
|
+
// generic/null when nested — see hasLandmarkScopingAncestor
|
|
808
|
+
// above (includeMain: true, matching <header>'s real exclusion
|
|
809
|
+
// list, which does include <main>).
|
|
753
810
|
// role="banner" restated is only a no-op restatement of the
|
|
754
811
|
// native role (and therefore permitted) at the top level; a
|
|
755
812
|
// widely-used reference engine's own allowedRoles array for
|
|
756
813
|
// <header> doesn't include 'banner'
|
|
757
814
|
// at all (reached only via the native-role-match branch), same
|
|
758
815
|
// shape as <section>'s 'region'.
|
|
759
|
-
return
|
|
816
|
+
return hasLandmarkScopingAncestor(el, { includeMain: true }) ? 'header' : 'header[toplevel]';
|
|
760
817
|
}
|
|
761
818
|
|
|
762
819
|
if (tag === 'label') {
|
|
@@ -899,7 +956,14 @@ function createAriaHelpers(opts, shared) {
|
|
|
899
956
|
getRequiredOwnedRoles,
|
|
900
957
|
getRequiredContextRoles,
|
|
901
958
|
isRoleAllowedOnElement,
|
|
902
|
-
getContainmentRole
|
|
959
|
+
getContainmentRole,
|
|
960
|
+
|
|
961
|
+
// Shared "does this element have a landmark-scoping ancestor"
|
|
962
|
+
// primitive — see its own header comment above. Re-exported at
|
|
963
|
+
// helpers' top level too (src/core/dom-helpers.js), matching
|
|
964
|
+
// getLandmarkNameInfo's precedent, for the manual landmark-check
|
|
965
|
+
// files that used to each carry their own (buggy, tag-only) copy.
|
|
966
|
+
hasLandmarkScopingAncestor
|
|
903
967
|
};
|
|
904
968
|
}
|
|
905
969
|
|
package/src/core/dom-helpers.js
CHANGED
|
@@ -120,6 +120,11 @@ function createDomHelpers(opts) {
|
|
|
120
120
|
// opt out with includeHiddenElements:true.
|
|
121
121
|
const includeHiddenElements = !!(opts && opts.includeHiddenElements === true);
|
|
122
122
|
const excludeSelectors = Array.isArray(opts && opts.excludeSelectors) ? opts.excludeSelectors : [];
|
|
123
|
+
// Default off: explicit opt-in for "this scan target was never meant to
|
|
124
|
+
// represent a real page" (e.g. a raw component fragment parsed on its
|
|
125
|
+
// own), regardless of whether document.documentElement happens to be in
|
|
126
|
+
// scope. See isWholeDocumentScope() below.
|
|
127
|
+
const fragment = !!(opts && opts.fragment === true);
|
|
123
128
|
|
|
124
129
|
// Rule-scoped excludes (engineOptions.rules[ruleId].excludeSelectors), set
|
|
125
130
|
// by dom-runner.js immediately before invoking each rule's applicability/
|
|
@@ -4122,6 +4127,22 @@ function createDomHelpers(opts) {
|
|
|
4122
4127
|
{trim}
|
|
4123
4128
|
);
|
|
4124
4129
|
|
|
4130
|
+
// For rules whose check is inherently about the WHOLE page (does the
|
|
4131
|
+
// page have a title? a declared language? a way to skip repeated
|
|
4132
|
+
// blocks?) rather than about elements found within a scanned subtree --
|
|
4133
|
+
// these can't be answered correctly by scoping via queryAllSmart/ctx.root
|
|
4134
|
+
// the way per-element checks can, since a subtree that never had (and
|
|
4135
|
+
// was never meant to have) e.g. its own <title> shouldn't be faulted for
|
|
4136
|
+
// lacking one. `false` when `fragment:true` was explicitly set, or when
|
|
4137
|
+
// `contextSelector` scoped this run narrower than the whole document
|
|
4138
|
+
// (roots doesn't include document.documentElement); `true` in the
|
|
4139
|
+
// default/unscoped case, so this is a no-op for the overwhelming
|
|
4140
|
+
// majority of existing (whole-page) scans.
|
|
4141
|
+
function isWholeDocumentScope() {
|
|
4142
|
+
if (fragment) return false;
|
|
4143
|
+
return roots.includes(document.documentElement);
|
|
4144
|
+
}
|
|
4145
|
+
|
|
4125
4146
|
return {
|
|
4126
4147
|
// Existing query/snippet utilities
|
|
4127
4148
|
queryAll,
|
|
@@ -4137,6 +4158,7 @@ function createDomHelpers(opts) {
|
|
|
4137
4158
|
isExcluded,
|
|
4138
4159
|
isAccTreeEligible,
|
|
4139
4160
|
isDomVisibleEligible,
|
|
4161
|
+
isWholeDocumentScope,
|
|
4140
4162
|
|
|
4141
4163
|
// Engine-internal: sets which rule's rule-scoped excludeSelectors
|
|
4142
4164
|
// (engineOptions.rules[ruleId].excludeSelectors) are currently in
|
|
@@ -4161,6 +4183,16 @@ function createDomHelpers(opts) {
|
|
|
4161
4183
|
// see getLandmarkNameInfo's own header comment for why this replaced 7 duplicated copies)
|
|
4162
4184
|
getLandmarkNameInfo,
|
|
4163
4185
|
|
|
4186
|
+
// "Does this element have a landmark-scoping ancestor" (role-aware
|
|
4187
|
+
// sectioning-content/<main> check backing <header>/<footer>/<aside>'s
|
|
4188
|
+
// conditional implicit roles) -- re-exported from aria helpers at
|
|
4189
|
+
// this top level, matching getLandmarkNameInfo just above, so the
|
|
4190
|
+
// manual landmark-check files that used to each carry their own
|
|
4191
|
+
// (buggy, tag-only) copy can call helpers.hasLandmarkScopingAncestor
|
|
4192
|
+
// directly. See aria.hasLandmarkScopingAncestor's own header comment
|
|
4193
|
+
// in src/core/aria-helpers.js for the full algorithm and rationale.
|
|
4194
|
+
hasLandmarkScopingAncestor: aria.hasLandmarkScopingAncestor,
|
|
4195
|
+
|
|
4164
4196
|
// Name / description
|
|
4165
4197
|
getAccessibleNameInfo,
|
|
4166
4198
|
getAccessibleDescriptionInfo,
|
package/src/core/dom-runner.js
CHANGED
|
@@ -52,6 +52,9 @@ function runCore(pageUrl, contextSelector, engineOptions, runOnly, CHECK_DEFS, R
|
|
|
52
52
|
// unless the caller explicitly opts in.
|
|
53
53
|
const includeHiddenElements = !!(engineOptionsResolved && engineOptionsResolved.includeHiddenElements === true);
|
|
54
54
|
const excludeSelectors = normalizeSelectorList(engineOptionsResolved && engineOptionsResolved.excludeSelectors);
|
|
55
|
+
// Default off: explicit opt-in for "this scan target was never meant to
|
|
56
|
+
// represent a real page" -- see helpers.isWholeDocumentScope().
|
|
57
|
+
const fragment = !!(engineOptionsResolved && engineOptionsResolved.fragment === true);
|
|
55
58
|
|
|
56
59
|
const url = pageUrl || (document.location && document.location.href) || null;
|
|
57
60
|
const title = document.title || null;
|
|
@@ -83,6 +86,7 @@ function runCore(pageUrl, contextSelector, engineOptions, runOnly, CHECK_DEFS, R
|
|
|
83
86
|
includeShadowDom,
|
|
84
87
|
includeHiddenElements,
|
|
85
88
|
excludeSelectors,
|
|
89
|
+
fragment,
|
|
86
90
|
// Optional perf counters (bench/debug only). Deterministic and per-run.
|
|
87
91
|
perfStats: !!(engineOptionsResolved && engineOptionsResolved.perfStats)
|
|
88
92
|
});
|
|
@@ -219,6 +223,8 @@ function runCore(pageUrl, contextSelector, engineOptions, runOnly, CHECK_DEFS, R
|
|
|
219
223
|
ruleVersion: normalizedMeta.ruleVersion,
|
|
220
224
|
normative: normalizedMeta.normative,
|
|
221
225
|
atomic: normalizedMeta.atomic,
|
|
226
|
+
deprecated: normalizedMeta.deprecated,
|
|
227
|
+
deprecation: normalizedMeta.deprecation,
|
|
222
228
|
category: normalizedMeta.category,
|
|
223
229
|
standard: normalizedMeta.standard,
|
|
224
230
|
applicability: normalizedMeta.applicability,
|
|
@@ -478,6 +484,8 @@ function runCore(pageUrl, contextSelector, engineOptions, runOnly, CHECK_DEFS, R
|
|
|
478
484
|
ruleVersion: '0.0.0',
|
|
479
485
|
normative: true,
|
|
480
486
|
atomic: false,
|
|
487
|
+
deprecated: false,
|
|
488
|
+
deprecation: null,
|
|
481
489
|
category: null,
|
|
482
490
|
standard: null,
|
|
483
491
|
applicability: '',
|
package/src/core/rule-meta.js
CHANGED
|
@@ -80,6 +80,23 @@ function normalizeRuleMeta(ruleId, id, meta, engineTag) {
|
|
|
80
80
|
const normative = (typeof m.normative === 'boolean') ? m.normative : true;
|
|
81
81
|
const atomic = (typeof m.atomic === 'boolean') ? m.atomic : true;
|
|
82
82
|
|
|
83
|
+
// Deprecation signal for the rule catalog (see docs/API_STABILITY.md).
|
|
84
|
+
// Purely informational -- a deprecated rule still runs and produces
|
|
85
|
+
// results completely normally; this is a catalog-level migration signal
|
|
86
|
+
// for integrators, not an automatic exclusion.
|
|
87
|
+
const deprecated = (typeof m.deprecated === 'boolean') ? m.deprecated : false;
|
|
88
|
+
const deprecation = (deprecated && m.deprecation && typeof m.deprecation === 'object' && !Array.isArray(m.deprecation))
|
|
89
|
+
? {
|
|
90
|
+
replacedBy: (typeof m.deprecation.replacedBy === 'string' && m.deprecation.replacedBy.trim()) ? m.deprecation.replacedBy.trim() : null,
|
|
91
|
+
reason: (typeof m.deprecation.reason === 'string') ? m.deprecation.reason.trim() : '',
|
|
92
|
+
sinceVersion: (typeof m.deprecation.sinceVersion === 'string') ? m.deprecation.sinceVersion.trim() : ''
|
|
93
|
+
}
|
|
94
|
+
: null;
|
|
95
|
+
|
|
96
|
+
if (deprecated && (!deprecation || !deprecation.reason || !deprecation.sinceVersion)) {
|
|
97
|
+
throw new Error(`Rule ${ruleId}: meta.deprecated:true requires meta.deprecation.reason and meta.deprecation.sinceVersion`);
|
|
98
|
+
}
|
|
99
|
+
|
|
83
100
|
const category = (typeof m.category === 'string' && m.category.trim()) ? m.category.trim() : null;
|
|
84
101
|
const standard = (typeof m.standard === 'string' && m.standard.trim()) ? m.standard.trim() : null;
|
|
85
102
|
|
|
@@ -127,6 +144,8 @@ function normalizeRuleMeta(ruleId, id, meta, engineTag) {
|
|
|
127
144
|
ruleVersion,
|
|
128
145
|
normative,
|
|
129
146
|
atomic,
|
|
147
|
+
deprecated,
|
|
148
|
+
deprecation,
|
|
130
149
|
category,
|
|
131
150
|
standard,
|
|
132
151
|
applicability,
|