@surea11y/core 1.1.1 → 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 +29 -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-allowed-attr.js +2 -3
- package/src/checks/automatic/aria-allowed-role.js +2 -3
- package/src/checks/automatic/aria-braille-equivalent.js +3 -4
- package/src/checks/automatic/aria-conditional-attr.js +3 -4
- package/src/checks/automatic/aria-deprecated-role.js +2 -3
- package/src/checks/automatic/aria-hidden-body.js +9 -1
- package/src/checks/automatic/aria-prohibited-attr.js +2 -3
- package/src/checks/automatic/aria-prohibited-children.js +73 -17
- package/src/checks/automatic/aria-required-attr.js +2 -3
- package/src/checks/automatic/aria-required-children.js +2 -3
- package/src/checks/automatic/aria-required-parent.js +3 -4
- package/src/checks/automatic/aria-roles-valid.js +2 -3
- package/src/checks/automatic/aria-valid-attr-value.js +2 -3
- package/src/checks/automatic/aria-valid-attr.js +2 -3
- package/src/checks/automatic/autocomplete-valid.js +2 -3
- package/src/checks/automatic/avoid-inline-spacing.js +2 -3
- package/src/checks/automatic/binary-control-name-present.js +2 -3
- package/src/checks/automatic/button-name-present.js +14 -4
- package/src/checks/automatic/bypass-blocks-present.js +44 -8
- package/src/checks/automatic/combobox-name-present.js +2 -3
- package/src/checks/automatic/css-orientation-lock.js +9 -1
- package/src/checks/automatic/definition-list-children-valid.js +2 -3
- package/src/checks/automatic/deprecated-elements-not-used.js +2 -3
- package/src/checks/automatic/dialog-name-present.js +2 -3
- package/src/checks/automatic/dlitem-parent-valid.js +2 -3
- package/src/checks/automatic/form-control-single-label.js +2 -3
- package/src/checks/automatic/html-xml-lang-mismatch.js +9 -1
- package/src/checks/automatic/iframe-focusable-content.js +2 -3
- package/src/checks/automatic/iframe-name-present.js +2 -3
- package/src/checks/automatic/iframe-title-unique.js +2 -3
- package/src/checks/automatic/label-in-name.js +7 -8
- package/src/checks/automatic/language-page-present.js +9 -1
- package/src/checks/automatic/link-in-text-block.js +2 -3
- package/src/checks/automatic/link-name-present.js +16 -4
- package/src/checks/automatic/list-children-valid.js +2 -3
- package/src/checks/automatic/listbox-name-present.js +2 -3
- package/src/checks/automatic/listitem-parent-valid.js +2 -3
- package/src/checks/automatic/menuitem-name-present.js +2 -3
- 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/meter-name-present.js +2 -3
- package/src/checks/automatic/nested-interactive-controls-absent.js +2 -3
- package/src/checks/automatic/option-name-present.js +2 -3
- package/src/checks/automatic/page-title-present.js +9 -1
- package/src/checks/automatic/progressbar-name-present.js +2 -3
- package/src/checks/automatic/searchbox-name-present.js +2 -3
- package/src/checks/automatic/server-side-image-map-absent.js +2 -3
- package/src/checks/automatic/slider-name-present.js +2 -3
- package/src/checks/automatic/spinbutton-name-present.js +2 -3
- package/src/checks/automatic/summary-name-present.js +2 -3
- package/src/checks/automatic/tab-name-present.js +2 -3
- package/src/checks/automatic/table-headers-attr-valid.js +2 -3
- package/src/checks/automatic/table-th-has-data-cells.js +2 -3
- package/src/checks/automatic/td-has-header.js +2 -3
- package/src/checks/automatic/textbox-name-present.js +2 -3
- package/src/checks/automatic/tooltip-name-present.js +2 -3
- package/src/checks/automatic/treeitem-name-present.js +2 -3
- package/src/checks/automatic/valid-lang.js +2 -3
- package/src/checks/manual/accesskeys-manual.js +1 -7
- package/src/checks/manual/aria-checked-state-mismatch-manual.js +3 -4
- package/src/checks/manual/aria-text-manual.js +2 -3
- 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/focus-order-semantics-manual.js +2 -3
- package/src/checks/manual/heading-order-manual.js +1 -6
- package/src/checks/manual/identical-links-same-purpose-manual.js +2 -3
- package/src/checks/manual/image-redundant-alt-manual.js +2 -3
- package/src/checks/manual/label-title-only-manual.js +2 -3
- 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/link-name-quality-manual.js +2 -3
- package/src/checks/manual/meta-viewport-large-manual.js +9 -1
- package/src/checks/manual/mouse-only-event-handlers-manual.js +2 -3
- package/src/checks/manual/no-autoplay-audio-manual.js +2 -3
- package/src/checks/manual/p-as-heading-manual.js +2 -3
- package/src/checks/manual/page-has-heading-one-manual.js +40 -3
- package/src/checks/manual/page-title-patterns-manual.js +9 -1
- package/src/checks/manual/presentation-role-conflict-manual.js +3 -4
- package/src/checks/manual/region-manual.js +34 -14
- package/src/checks/manual/scope-attr-valid-manual.js +2 -7
- package/src/checks/manual/scrollable-region-focusable-manual.js +2 -3
- package/src/checks/manual/skip-link-manual.js +91 -9
- package/src/checks/manual/tabindex-manual.js +2 -7
- package/src/checks/manual/table-duplicate-name-manual.js +2 -3
- package/src/checks/manual/table-fake-caption-manual.js +2 -3
- package/src/checks/manual/video-caption-manual.js +2 -3
- package/src/core/aria-helpers.js +82 -18
- package/src/core/dom-helpers.js +88 -3
- package/src/core/dom-runner.js +23 -0
- package/src/core/rule-meta.js +19 -0
- package/src/core.js +2632 -885
- package/src/i18n/en.js +6 -2
- package/src/i18n/fr.js +6 -2
- package/src/report.js +482 -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/
|
|
@@ -1092,7 +1097,8 @@ function createDomHelpers(opts) {
|
|
|
1092
1097
|
'templateContent',
|
|
1093
1098
|
'nonRenderedElement',
|
|
1094
1099
|
'inputHidden',
|
|
1095
|
-
'visibilityHidden'
|
|
1100
|
+
'visibilityHidden',
|
|
1101
|
+
'contentVisibilityHidden'
|
|
1096
1102
|
]);
|
|
1097
1103
|
|
|
1098
1104
|
function queryAllSmart(sel) {
|
|
@@ -1109,6 +1115,23 @@ function createDomHelpers(opts) {
|
|
|
1109
1115
|
for (const r of reasons) {
|
|
1110
1116
|
if (HARD_HIDDEN_REASONS.has(r)) return false;
|
|
1111
1117
|
}
|
|
1118
|
+
|
|
1119
|
+
// `isAccTreeEligible` can short-circuit on an inert ancestor
|
|
1120
|
+
// before it reaches an outer hard-hidden ancestor (e.g.
|
|
1121
|
+
// display:none wrapper). In that case the node is still
|
|
1122
|
+
// structurally hidden and should be excluded by the default
|
|
1123
|
+
// hidden-content policy.
|
|
1124
|
+
if (reasons.includes('inert')) {
|
|
1125
|
+
const domVis = isDomVisibleEligible(el, null, {
|
|
1126
|
+
visibilityMode: 'styleOnly',
|
|
1127
|
+
disableGeometry: true,
|
|
1128
|
+
ignoreOpacity: true
|
|
1129
|
+
});
|
|
1130
|
+
const domReasons = Array.isArray(domVis && domVis.reasons) ? domVis.reasons : [];
|
|
1131
|
+
for (const r of domReasons) {
|
|
1132
|
+
if (HARD_HIDDEN_REASONS.has(r)) return false;
|
|
1133
|
+
}
|
|
1134
|
+
}
|
|
1112
1135
|
return true;
|
|
1113
1136
|
} catch {
|
|
1114
1137
|
return true;
|
|
@@ -3685,6 +3708,36 @@ function createDomHelpers(opts) {
|
|
|
3685
3708
|
let node = el;
|
|
3686
3709
|
let safety = 0;
|
|
3687
3710
|
|
|
3711
|
+
// Only apply the "stop climbing once we reach a contextSelector-
|
|
3712
|
+
// matched root" shortcut when there's a single (or no) matched
|
|
3713
|
+
// root -- resolveContextRoots() falls back to `[documentElement]`
|
|
3714
|
+
// when no contextSelector is given, so this is the overwhelmingly
|
|
3715
|
+
// common case and behaves exactly as before.
|
|
3716
|
+
//
|
|
3717
|
+
// With MULTIPLE matched roots (multi-region contextSelector
|
|
3718
|
+
// scans), stopping there without recording anything about which
|
|
3719
|
+
// root produced an ambiguous, non-unique selector string for two
|
|
3720
|
+
// structurally-identical regions -- a real, confirmed bug (found
|
|
3721
|
+
// 2026-07-29 via the cross-engine comparisons project): two
|
|
3722
|
+
// wrapper <div>s, each containing two identical ".widget"
|
|
3723
|
+
// sections scanned via `contextSelector: '.widget'`, produced the
|
|
3724
|
+
// *same* selector string ("section:nth-of-type(1) > div > div >
|
|
3725
|
+
// button") for the equivalent button in each wrapper --
|
|
3726
|
+
// resolving to 2 elements instead of 1 when queried, and pointing
|
|
3727
|
+
// at the wrong one for at least one of the two occurrences. The
|
|
3728
|
+
// existing `el.matches(candidate)` safety check below couldn't
|
|
3729
|
+
// catch this: it only verifies THIS element matches the string,
|
|
3730
|
+
// never that the string is unique document-wide.
|
|
3731
|
+
//
|
|
3732
|
+
// Fix: when multiple roots are in play, don't stop early --
|
|
3733
|
+
// keep climbing (same as the always-correct no-contextSelector
|
|
3734
|
+
// path) until finding a genuinely unique anchor or reaching the
|
|
3735
|
+
// true document root, which is always singular. That restores
|
|
3736
|
+
// the invariant the final safety-check comment below relies on,
|
|
3737
|
+
// rather than needing a separate (more expensive) document-wide
|
|
3738
|
+
// uniqueness re-check.
|
|
3739
|
+
const stopAtMatchedRoot = roots.length <= 1;
|
|
3740
|
+
|
|
3688
3741
|
while (node && node.nodeType === 1 && safety++ < 20) {
|
|
3689
3742
|
let anchor = null;
|
|
3690
3743
|
|
|
@@ -3724,7 +3777,7 @@ function createDomHelpers(opts) {
|
|
|
3724
3777
|
parts.unshift(nthOfType(node));
|
|
3725
3778
|
}
|
|
3726
3779
|
|
|
3727
|
-
if (!node.parentElement || roots.includes(node)) break;
|
|
3780
|
+
if (!node.parentElement || (stopAtMatchedRoot && roots.includes(node))) break;
|
|
3728
3781
|
node = node.parentElement;
|
|
3729
3782
|
}
|
|
3730
3783
|
|
|
@@ -3743,7 +3796,12 @@ function createDomHelpers(opts) {
|
|
|
3743
3796
|
// position relative to its own parent via `>` (child, not
|
|
3744
3797
|
// descendant) combinators, so a correctly-matching chain can
|
|
3745
3798
|
// only resolve to one element short of a malformed document
|
|
3746
|
-
// (e.g. two <html> roots)
|
|
3799
|
+
// (e.g. two <html> roots) -- true as long as the walk above
|
|
3800
|
+
// never stops short of a genuinely unique anchor/root, which is
|
|
3801
|
+
// exactly what `stopAtMatchedRoot` now guarantees (see its own
|
|
3802
|
+
// comment above; a multi-root contextSelector scan stopping
|
|
3803
|
+
// early used to violate this invariant silently). Re-deriving
|
|
3804
|
+
// that guarantee via a
|
|
3747
3805
|
// document-wide :nth-of-type scan was measured to cost O(total
|
|
3748
3806
|
// same-tag siblings) per call — pathological on pages with many
|
|
3749
3807
|
// flat, unidentified siblings (e.g. hundreds of unlabeled
|
|
@@ -4069,6 +4127,22 @@ function createDomHelpers(opts) {
|
|
|
4069
4127
|
{trim}
|
|
4070
4128
|
);
|
|
4071
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
|
+
|
|
4072
4146
|
return {
|
|
4073
4147
|
// Existing query/snippet utilities
|
|
4074
4148
|
queryAll,
|
|
@@ -4084,6 +4158,7 @@ function createDomHelpers(opts) {
|
|
|
4084
4158
|
isExcluded,
|
|
4085
4159
|
isAccTreeEligible,
|
|
4086
4160
|
isDomVisibleEligible,
|
|
4161
|
+
isWholeDocumentScope,
|
|
4087
4162
|
|
|
4088
4163
|
// Engine-internal: sets which rule's rule-scoped excludeSelectors
|
|
4089
4164
|
// (engineOptions.rules[ruleId].excludeSelectors) are currently in
|
|
@@ -4108,6 +4183,16 @@ function createDomHelpers(opts) {
|
|
|
4108
4183
|
// see getLandmarkNameInfo's own header comment for why this replaced 7 duplicated copies)
|
|
4109
4184
|
getLandmarkNameInfo,
|
|
4110
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
|
+
|
|
4111
4196
|
// Name / description
|
|
4112
4197
|
getAccessibleNameInfo,
|
|
4113
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;
|
|
@@ -61,6 +64,21 @@ function runCore(pageUrl, contextSelector, engineOptions, runOnly, CHECK_DEFS, R
|
|
|
61
64
|
? engineOptionsResolved.timestamp.trim()
|
|
62
65
|
: null;
|
|
63
66
|
|
|
67
|
+
// createDomHelpers()/createContrastHelpers() persist their element-keyed
|
|
68
|
+
// caches (outerHtmlCache, selectorCache, etc.) on window.__a11ycoreSharedCache
|
|
69
|
+
// so multiple helper instances created *within this run* can share them
|
|
70
|
+
// deterministically. But a window/document is frequently reused across
|
|
71
|
+
// SEPARATE runs -- e.g. Jest's jsdom environment creates one window per
|
|
72
|
+
// test file, and mutating document.body between it() blocks is standard.
|
|
73
|
+
// Those caches are keyed by element reference, not content, so a run that
|
|
74
|
+
// reuses an already-cached element (document.body never changes identity)
|
|
75
|
+
// would otherwise read stale data cached by an earlier, unrelated run on
|
|
76
|
+
// the same window. Clearing at the start of every run keeps sharing scoped
|
|
77
|
+
// to "this run" as intended, without leaking across runs.
|
|
78
|
+
try {
|
|
79
|
+
if (window && window.__a11ycoreSharedCache) window.__a11ycoreSharedCache = {};
|
|
80
|
+
} catch {}
|
|
81
|
+
|
|
64
82
|
const sharedHelpers = createDomHelpers({
|
|
65
83
|
document,
|
|
66
84
|
window,
|
|
@@ -68,6 +86,7 @@ function runCore(pageUrl, contextSelector, engineOptions, runOnly, CHECK_DEFS, R
|
|
|
68
86
|
includeShadowDom,
|
|
69
87
|
includeHiddenElements,
|
|
70
88
|
excludeSelectors,
|
|
89
|
+
fragment,
|
|
71
90
|
// Optional perf counters (bench/debug only). Deterministic and per-run.
|
|
72
91
|
perfStats: !!(engineOptionsResolved && engineOptionsResolved.perfStats)
|
|
73
92
|
});
|
|
@@ -204,6 +223,8 @@ function runCore(pageUrl, contextSelector, engineOptions, runOnly, CHECK_DEFS, R
|
|
|
204
223
|
ruleVersion: normalizedMeta.ruleVersion,
|
|
205
224
|
normative: normalizedMeta.normative,
|
|
206
225
|
atomic: normalizedMeta.atomic,
|
|
226
|
+
deprecated: normalizedMeta.deprecated,
|
|
227
|
+
deprecation: normalizedMeta.deprecation,
|
|
207
228
|
category: normalizedMeta.category,
|
|
208
229
|
standard: normalizedMeta.standard,
|
|
209
230
|
applicability: normalizedMeta.applicability,
|
|
@@ -463,6 +484,8 @@ function runCore(pageUrl, contextSelector, engineOptions, runOnly, CHECK_DEFS, R
|
|
|
463
484
|
ruleVersion: '0.0.0',
|
|
464
485
|
normative: true,
|
|
465
486
|
atomic: false,
|
|
487
|
+
deprecated: false,
|
|
488
|
+
deprecation: null,
|
|
466
489
|
category: null,
|
|
467
490
|
standard: null,
|
|
468
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,
|