@surea11y/core 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +49 -0
- package/LICENSE +21 -0
- package/README.md +145 -0
- package/bin/core.js +244 -0
- package/docs/BINDING_AUTHORS_GUIDE.md +41 -0
- package/docs/CLI.md +49 -0
- package/docs/ENGINE_OPTIONS.md +155 -0
- package/docs/I18N.md +47 -0
- package/docs/INTEGRATION.md +156 -0
- package/docs/LIMITATIONS.md +31 -0
- package/docs/OUTPUT_SCHEMA.md +237 -0
- package/docs/POLICY.md +71 -0
- package/docs/RULE_AUTHORING.md +375 -0
- package/docs/RULE_CATALOG.md +180 -0
- package/docs/RULE_TAXONOMY.md +145 -0
- package/docs/TROUBLESHOOTING.md +48 -0
- package/docs/WCAG_CONFORMANCE.md +49 -0
- package/package.json +60 -0
- package/src/catalogs/composites.wcag.js +490 -0
- package/src/checks/automatic/area-alt-present.js +225 -0
- package/src/checks/automatic/aria-allowed-attr.js +206 -0
- package/src/checks/automatic/aria-allowed-role.js +102 -0
- package/src/checks/automatic/aria-braille-equivalent.js +139 -0
- package/src/checks/automatic/aria-conditional-attr.js +110 -0
- package/src/checks/automatic/aria-deprecated-role.js +106 -0
- package/src/checks/automatic/aria-hidden-body.js +87 -0
- package/src/checks/automatic/aria-hidden-focus.js +480 -0
- package/src/checks/automatic/aria-prohibited-attr.js +156 -0
- package/src/checks/automatic/aria-prohibited-children.js +265 -0
- package/src/checks/automatic/aria-required-attr.js +154 -0
- package/src/checks/automatic/aria-required-children.js +274 -0
- package/src/checks/automatic/aria-required-parent.js +222 -0
- package/src/checks/automatic/aria-role-name-present.js +201 -0
- package/src/checks/automatic/aria-roles-valid.js +110 -0
- package/src/checks/automatic/aria-valid-attr-value.js +123 -0
- package/src/checks/automatic/aria-valid-attr.js +109 -0
- package/src/checks/automatic/autocomplete-valid.js +134 -0
- package/src/checks/automatic/avoid-inline-spacing.js +107 -0
- package/src/checks/automatic/binary-control-name-present.js +294 -0
- package/src/checks/automatic/button-name-present.js +146 -0
- package/src/checks/automatic/bypass-blocks-present.js +162 -0
- package/src/checks/automatic/canvas-text-alternative-present.js +140 -0
- package/src/checks/automatic/combobox-name-present.js +267 -0
- package/src/checks/automatic/contrast-computable.js +378 -0
- package/src/checks/automatic/contrast-enhanced.js +517 -0
- package/src/checks/automatic/contrast-minimum.js +512 -0
- package/src/checks/automatic/css-orientation-lock.js +206 -0
- package/src/checks/automatic/definition-list-children-valid.js +148 -0
- package/src/checks/automatic/deprecated-elements-not-used.js +91 -0
- package/src/checks/automatic/dialog-name-present.js +209 -0
- package/src/checks/automatic/dlitem-parent-valid.js +100 -0
- package/src/checks/automatic/duplicate-id-aria.js +126 -0
- package/src/checks/automatic/embed-text-alternative-present.js +190 -0
- package/src/checks/automatic/form-control-programmatic-label-present.js +409 -0
- package/src/checks/automatic/form-control-single-label.js +117 -0
- package/src/checks/automatic/html-xml-lang-mismatch.js +91 -0
- package/src/checks/automatic/iframe-focusable-content.js +141 -0
- package/src/checks/automatic/iframe-name-present.js +102 -0
- package/src/checks/automatic/iframe-title-unique.js +107 -0
- package/src/checks/automatic/img-alt-present.js +223 -0
- package/src/checks/automatic/input-image-alt-present.js +155 -0
- package/src/checks/automatic/label-in-name.js +326 -0
- package/src/checks/automatic/language-page-present.js +159 -0
- package/src/checks/automatic/link-in-text-block.js +218 -0
- package/src/checks/automatic/link-name-present.js +114 -0
- package/src/checks/automatic/list-children-valid.js +152 -0
- package/src/checks/automatic/listbox-name-present.js +236 -0
- package/src/checks/automatic/listitem-parent-valid.js +118 -0
- package/src/checks/automatic/menuitem-name-present.js +201 -0
- package/src/checks/automatic/meta-refresh-no-exceptions.js +105 -0
- package/src/checks/automatic/meta-refresh-timing-absent.js +107 -0
- package/src/checks/automatic/meta-viewport-zoom-enabled.js +118 -0
- package/src/checks/automatic/meter-name-present.js +160 -0
- package/src/checks/automatic/nested-interactive-controls-absent.js +135 -0
- package/src/checks/automatic/object-text-alternative-present.js +193 -0
- package/src/checks/automatic/option-name-present.js +157 -0
- package/src/checks/automatic/page-title-present.js +86 -0
- package/src/checks/automatic/progressbar-name-present.js +165 -0
- package/src/checks/automatic/role-img-alt-present.js +206 -0
- package/src/checks/automatic/searchbox-name-present.js +236 -0
- package/src/checks/automatic/server-side-image-map-absent.js +88 -0
- package/src/checks/automatic/slider-name-present.js +276 -0
- package/src/checks/automatic/spinbutton-name-present.js +236 -0
- package/src/checks/automatic/summary-name-present.js +153 -0
- package/src/checks/automatic/svg-image-text-alternative-present.js +220 -0
- package/src/checks/automatic/svg-text-alternative-present.js +298 -0
- package/src/checks/automatic/tab-name-present.js +200 -0
- package/src/checks/automatic/table-headers-attr-valid.js +122 -0
- package/src/checks/automatic/table-th-has-data-cells.js +117 -0
- package/src/checks/automatic/target-size-minimum.js +605 -0
- package/src/checks/automatic/td-has-header.js +151 -0
- package/src/checks/automatic/textbox-name-present.js +236 -0
- package/src/checks/automatic/tooltip-name-present.js +158 -0
- package/src/checks/automatic/treeitem-name-present.js +157 -0
- package/src/checks/automatic/valid-lang.js +100 -0
- package/src/checks/automatic/video-poster-text-alternative-present.js +193 -0
- package/src/checks/manual/accesskeys-manual.js +93 -0
- package/src/checks/manual/area-alt-decorative-manual.js +247 -0
- package/src/checks/manual/area-alt-quality-manual.js +204 -0
- package/src/checks/manual/aria-checked-state-mismatch-manual.js +141 -0
- package/src/checks/manual/aria-text-manual.js +109 -0
- package/src/checks/manual/canvas-text-alternative-quality-manual.js +170 -0
- package/src/checks/manual/css-hidden-focus.js +259 -0
- package/src/checks/manual/embed-text-alternative-quality-manual.js +204 -0
- package/src/checks/manual/empty-heading-manual.js +182 -0
- package/src/checks/manual/empty-table-header-manual.js +163 -0
- package/src/checks/manual/focus-order-semantics-manual.js +117 -0
- package/src/checks/manual/form-control-programmatic-label-quality-manual.js +291 -0
- package/src/checks/manual/heading-order-manual.js +130 -0
- package/src/checks/manual/identical-links-same-purpose-manual.js +142 -0
- package/src/checks/manual/image-redundant-alt-manual.js +118 -0
- package/src/checks/manual/img-alt-decorative-manual.js +148 -0
- package/src/checks/manual/img-alt-quality-manual.js +182 -0
- package/src/checks/manual/input-image-alt-decorative-manual.js +144 -0
- package/src/checks/manual/input-image-alt-quality-manual.js +144 -0
- package/src/checks/manual/label-title-only-manual.js +115 -0
- package/src/checks/manual/landmark-banner-is-top-level-manual.js +180 -0
- package/src/checks/manual/landmark-contentinfo-is-top-level-manual.js +169 -0
- package/src/checks/manual/landmark-main-is-top-level-manual.js +167 -0
- package/src/checks/manual/landmark-no-duplicate-banner-manual.js +177 -0
- package/src/checks/manual/landmark-no-duplicate-contentinfo-manual.js +169 -0
- package/src/checks/manual/landmark-no-duplicate-main-manual.js +132 -0
- package/src/checks/manual/landmark-one-main-manual.js +151 -0
- package/src/checks/manual/landmark-unique-manual.js +252 -0
- package/src/checks/manual/link-name-quality-manual.js +143 -0
- package/src/checks/manual/media-transcript-present-manual.js +373 -0
- package/src/checks/manual/meta-viewport-large-manual.js +119 -0
- package/src/checks/manual/mouse-only-event-handlers-manual.js +134 -0
- package/src/checks/manual/no-autoplay-audio-manual.js +116 -0
- package/src/checks/manual/object-text-alternative-quality-manual.js +194 -0
- package/src/checks/manual/p-as-heading-manual.js +163 -0
- package/src/checks/manual/page-has-heading-one-manual.js +110 -0
- package/src/checks/manual/page-title-patterns-manual.js +262 -0
- package/src/checks/manual/presentation-role-conflict-manual.js +159 -0
- package/src/checks/manual/region-manual.js +183 -0
- package/src/checks/manual/scope-attr-valid-manual.js +93 -0
- package/src/checks/manual/scrollable-region-focusable-manual.js +168 -0
- package/src/checks/manual/skip-link-manual.js +150 -0
- package/src/checks/manual/svg-text-alternative-quality-manual.js +209 -0
- package/src/checks/manual/tabindex-manual.js +94 -0
- package/src/checks/manual/table-duplicate-name-manual.js +99 -0
- package/src/checks/manual/table-fake-caption-manual.js +122 -0
- package/src/checks/manual/video-caption-manual.js +118 -0
- package/src/checks/manual-review.js +95 -0
- package/src/checks/rules-and-tags.full.csv +19 -0
- package/src/checks/rules-and-tags.full.json +259 -0
- package/src/core/aria-helpers.js +906 -0
- package/src/core/contrast-helpers.js +1147 -0
- package/src/core/dom-helpers.js +4085 -0
- package/src/core/dom-runner.js +627 -0
- package/src/core/frame-messaging.js +210 -0
- package/src/core/frame-scan.js +178 -0
- package/src/core/rollup-composites.js +135 -0
- package/src/core/rule-meta.js +140 -0
- package/src/core.js +79055 -0
- package/src/coverage/wcag-facets.js +1079 -0
- package/src/coverage/wcag-version-map.js +84 -0
- package/src/i18n/en.js +919 -0
- package/src/i18n/fr.js +527 -0
- package/src/index.js +4 -0
- package/src/policy/contracts.js +18 -0
- package/src/policy/resolvePolicy.js +55 -0
- package/src/policy/schemas/engine-options.schema.json +103 -0
- package/src/policy/schemas/policy-contract.schema.json +40 -0
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* @check landmark-no-duplicate-banner
|
|
5
|
+
* @atomic true
|
|
6
|
+
* @summary A page must not have more than one banner landmark
|
|
7
|
+
* @standard Best Practices (a widely-used reference engine's classification; no formal WCAG Success Criterion — see ROADMAP.md Tier 1b)
|
|
8
|
+
* @applicability
|
|
9
|
+
* Applies whenever the page contains at least one banner landmark
|
|
10
|
+
* (explicit role="banner", or an implicit, non-nested <header> — see
|
|
11
|
+
* landmark-banner-is-top-level's implementation notes for the
|
|
12
|
+
* shared landmark-detection model).
|
|
13
|
+
* @expectation
|
|
14
|
+
* At most one banner landmark exists on the page. Per WAI-ARIA
|
|
15
|
+
* Authoring Practices, the banner landmark represents site-oriented
|
|
16
|
+
* content that identifies the page as a whole — having more than one
|
|
17
|
+
* is ambiguous for assistive technology users navigating by landmark.
|
|
18
|
+
* @implementation-notes
|
|
19
|
+
* - Not WCAG-normative — authored as an advisory, cantTell-capped
|
|
20
|
+
* `type: 'manual'` rule; see landmark-banner-is-top-level's
|
|
21
|
+
* header comment for the shared rationale/precedent.
|
|
22
|
+
* - Flags every banner instance (not just the "extra" ones) when more
|
|
23
|
+
* than one exists, since which instance is "correct" is ambiguous.
|
|
24
|
+
* - Only landmarks actually exposed to assistive technology can collide —
|
|
25
|
+
* matches a widely-used reference engine's own `page-no-duplicate` check (confirmed by reading
|
|
26
|
+
* its source directly: `query_selector_all_filter_default(..., elm =>
|
|
27
|
+
* _isVisibleToScreenReaders(elm))`). Without this, a responsive layout
|
|
28
|
+
* rendering both a visible and a CSS-hidden duplicate `<header>` (found
|
|
29
|
+
* on a real site — Trello's homepage, a desktop/mobile header pair) was
|
|
30
|
+
* wrongly flagged as a duplicate landmark; the hidden copy is never
|
|
31
|
+
* actually reachable by AT.
|
|
32
|
+
*/
|
|
33
|
+
|
|
34
|
+
const id = 'landmark-no-duplicate-banner';
|
|
35
|
+
|
|
36
|
+
const meta = {
|
|
37
|
+
title: 'Page must not have more than one banner landmark',
|
|
38
|
+
description: 'Checks that at most one banner landmark (role="banner" or a non-nested <header>) exists on the page.',
|
|
39
|
+
i18n: {
|
|
40
|
+
titleKey: 'landmarkNoDuplicateBanner_title',
|
|
41
|
+
descriptionKey: 'landmarkNoDuplicateBanner_description'
|
|
42
|
+
},
|
|
43
|
+
helpUrl: null,
|
|
44
|
+
tags: ['best-practice', 'landmarks', 'structure', 'atomic', 'manual'],
|
|
45
|
+
wcagSc: [],
|
|
46
|
+
normativeMappings: [],
|
|
47
|
+
defaultSeverity: 'minor',
|
|
48
|
+
category: 'operable',
|
|
49
|
+
type: 'manual',
|
|
50
|
+
defaultConfidence: 'medium',
|
|
51
|
+
coverage: {}
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
function runInPage(ctx) {
|
|
55
|
+
const { document, helpers, rule } = ctx;
|
|
56
|
+
|
|
57
|
+
function normalizeWs(s) {
|
|
58
|
+
return String(s || '').replace(/\s+/g, ' ').trim();
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// Delegates to the shared helpers.getLandmarkNameInfo (aria-label -> aria-labelledby, via the
|
|
62
|
+
// target's own accessible name, not raw textContent -> title attribute fallback) rather than a
|
|
63
|
+
// local copy -- see that function's header comment in src/core/dom-helpers.js for the real bug
|
|
64
|
+
// (missing title fallback) this replaced across all 7 landmark rule files that had their own
|
|
65
|
+
// copy of this logic.
|
|
66
|
+
function getAccessibleLandmarkName(el) {
|
|
67
|
+
try {
|
|
68
|
+
if (helpers && typeof helpers.getLandmarkNameInfo === 'function') {
|
|
69
|
+
const info = helpers.getLandmarkNameInfo(el, ctx);
|
|
70
|
+
if (info && info.present && info.value) return normalizeWs(info.value);
|
|
71
|
+
}
|
|
72
|
+
} catch {}
|
|
73
|
+
return '';
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function getExplicitRoleToken(el) {
|
|
77
|
+
const raw = normalizeWs(el.getAttribute && el.getAttribute('role'));
|
|
78
|
+
if (!raw) return '';
|
|
79
|
+
return raw.split(/\s+/)[0].toLowerCase();
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const SECTIONING_ANCESTORS = new Set(['article', 'aside', 'main', 'nav', 'section']);
|
|
83
|
+
|
|
84
|
+
function isSuppressedBySectioningAncestor(el) {
|
|
85
|
+
let p = el.parentElement;
|
|
86
|
+
while (p) {
|
|
87
|
+
const tag = p.tagName ? p.tagName.toLowerCase() : '';
|
|
88
|
+
if (SECTIONING_ANCESTORS.has(tag)) return true;
|
|
89
|
+
p = p.parentElement;
|
|
90
|
+
}
|
|
91
|
+
return false;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function getImplicitLandmarkRole(el) {
|
|
95
|
+
const tag = el.tagName ? el.tagName.toLowerCase() : '';
|
|
96
|
+
if (tag === 'header') return isSuppressedBySectioningAncestor(el) ? '' : 'banner';
|
|
97
|
+
if (tag === 'footer') return isSuppressedBySectioningAncestor(el) ? '' : 'contentinfo';
|
|
98
|
+
if (tag === 'main') return 'main';
|
|
99
|
+
if (tag === 'nav') return 'navigation';
|
|
100
|
+
if (tag === 'aside') return isSuppressedBySectioningAncestor(el) ? '' : 'complementary';
|
|
101
|
+
if (tag === 'section') return getAccessibleLandmarkName(el) ? 'region' : '';
|
|
102
|
+
if (tag === 'form') return getAccessibleLandmarkName(el) ? 'form' : '';
|
|
103
|
+
return '';
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const LANDMARK_ROLES = new Set(['banner', 'contentinfo', 'main', 'navigation', 'complementary', 'region', 'form', 'search']);
|
|
107
|
+
|
|
108
|
+
function getLandmarkRole(el) {
|
|
109
|
+
if (!el || !el.getAttribute) return '';
|
|
110
|
+
const explicit = getExplicitRoleToken(el);
|
|
111
|
+
if (explicit) return LANDMARK_ROLES.has(explicit) ? explicit : '';
|
|
112
|
+
return getImplicitLandmarkRole(el);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// queryAllSmart (shadow-DOM-aware) instead of plain document.querySelectorAll -- see
|
|
116
|
+
// landmark-unique-manual.js's header comment for the real page (Airtable, 2026-07-23)
|
|
117
|
+
// that surfaced this gap: a third-party shadow-DOM-hosted widget's own landmark is
|
|
118
|
+
// invisible to a light-DOM-only query.
|
|
119
|
+
let nodes = [];
|
|
120
|
+
try {
|
|
121
|
+
nodes = helpers && typeof helpers.queryAllSmart === 'function'
|
|
122
|
+
? helpers.queryAllSmart('header, footer, main, nav, aside, section, form, [role]')
|
|
123
|
+
: document.querySelectorAll('header, footer, main, nav, aside, section, form, [role]');
|
|
124
|
+
} catch {
|
|
125
|
+
nodes = [];
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const isAccTreeEligible = helpers && typeof helpers.isAccTreeEligible === 'function' ? helpers.isAccTreeEligible : null;
|
|
129
|
+
|
|
130
|
+
function isExposedToAt(el) {
|
|
131
|
+
if (!isAccTreeEligible) return true;
|
|
132
|
+
try {
|
|
133
|
+
const r = isAccTreeEligible(el, ctx);
|
|
134
|
+
if (typeof r === 'boolean') return r;
|
|
135
|
+
return !!(r && r.eligible);
|
|
136
|
+
} catch {
|
|
137
|
+
return true;
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const banners = [];
|
|
142
|
+
const seen = new Set();
|
|
143
|
+
for (const el of nodes) {
|
|
144
|
+
if (!el || seen.has(el)) continue;
|
|
145
|
+
seen.add(el);
|
|
146
|
+
if (!isExposedToAt(el)) continue;
|
|
147
|
+
if (getLandmarkRole(el) === 'banner') banners.push(el);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
if (banners.length <= 1) {
|
|
151
|
+
return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
const occurrences = banners.map((el) => {
|
|
155
|
+
const stableSelector = helpers.buildSelector ? helpers.buildSelector(el) : 'html';
|
|
156
|
+
const html = helpers.getOuterHtmlSnippet ? helpers.getOuterHtmlSnippet(el) : (el.outerHTML || '');
|
|
157
|
+
|
|
158
|
+
return {
|
|
159
|
+
selector: stableSelector,
|
|
160
|
+
html,
|
|
161
|
+
summary: 'This page has more than one banner landmark.',
|
|
162
|
+
hint: 'Keep only one banner landmark (header/role="banner") per page.',
|
|
163
|
+
i18n: {
|
|
164
|
+
summaryKey: 'landmarkNoDuplicateBanner_summary_cantTell',
|
|
165
|
+
hintKey: 'landmarkNoDuplicateBanner_hint_cantTell',
|
|
166
|
+
params: { count: String(banners.length) }
|
|
167
|
+
},
|
|
168
|
+
data: {
|
|
169
|
+
details: { reasonCode: 'LANDMARK_DUPLICATE_BANNER', count: banners.length }
|
|
170
|
+
}
|
|
171
|
+
};
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
return { ruleId: rule.ruleId, outcome: 'cantTell', severity: rule.defaultSeverity || 'minor', occurrences };
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
module.exports = { id, meta, runInPage };
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* @check landmark-no-duplicate-contentinfo
|
|
5
|
+
* @atomic true
|
|
6
|
+
* @summary A page must not have more than one contentinfo landmark
|
|
7
|
+
* @standard Best Practices (a widely-used reference engine's classification; no formal WCAG Success Criterion — see ROADMAP.md Tier 1b)
|
|
8
|
+
* @applicability
|
|
9
|
+
* Applies whenever the page contains at least one contentinfo landmark
|
|
10
|
+
* (explicit role="contentinfo", or an implicit, non-nested <footer>).
|
|
11
|
+
* @expectation
|
|
12
|
+
* At most one contentinfo landmark exists on the page — mirrors
|
|
13
|
+
* landmark-no-duplicate-banner's rationale for contentinfo.
|
|
14
|
+
* @implementation-notes
|
|
15
|
+
* - Not WCAG-normative — authored as an advisory, cantTell-capped
|
|
16
|
+
* `type: 'manual'` rule; see landmark-banner-is-top-level's
|
|
17
|
+
* header comment for the shared rationale/precedent.
|
|
18
|
+
* - Only landmarks actually exposed to assistive technology can collide —
|
|
19
|
+
* matches a widely-used reference engine's own `page-no-duplicate` check (confirmed by reading
|
|
20
|
+
* its source directly: `query_selector_all_filter_default(..., elm =>
|
|
21
|
+
* _isVisibleToScreenReaders(elm))`) — same fix applied to the sibling
|
|
22
|
+
* banner/main rules after finding real hidden-duplicate false positives
|
|
23
|
+
* on Trello and Zoom.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
const id = 'landmark-no-duplicate-contentinfo';
|
|
27
|
+
|
|
28
|
+
const meta = {
|
|
29
|
+
title: 'Page must not have more than one contentinfo landmark',
|
|
30
|
+
description: 'Checks that at most one contentinfo landmark (role="contentinfo" or a non-nested <footer>) exists on the page.',
|
|
31
|
+
i18n: {
|
|
32
|
+
titleKey: 'landmarkNoDuplicateContentinfo_title',
|
|
33
|
+
descriptionKey: 'landmarkNoDuplicateContentinfo_description'
|
|
34
|
+
},
|
|
35
|
+
helpUrl: null,
|
|
36
|
+
tags: ['best-practice', 'landmarks', 'structure', 'atomic', 'manual'],
|
|
37
|
+
wcagSc: [],
|
|
38
|
+
normativeMappings: [],
|
|
39
|
+
defaultSeverity: 'minor',
|
|
40
|
+
category: 'operable',
|
|
41
|
+
type: 'manual',
|
|
42
|
+
defaultConfidence: 'medium',
|
|
43
|
+
coverage: {}
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
function runInPage(ctx) {
|
|
47
|
+
const { document, helpers, rule } = ctx;
|
|
48
|
+
|
|
49
|
+
function normalizeWs(s) {
|
|
50
|
+
return String(s || '').replace(/\s+/g, ' ').trim();
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// Delegates to the shared helpers.getLandmarkNameInfo (aria-label -> aria-labelledby, via the
|
|
54
|
+
// target's own accessible name, not raw textContent -> title attribute fallback) rather than a
|
|
55
|
+
// local copy -- see that function's header comment in src/core/dom-helpers.js for the real bug
|
|
56
|
+
// (missing title fallback) this replaced across all 7 landmark rule files that had their own
|
|
57
|
+
// copy of this logic.
|
|
58
|
+
function getAccessibleLandmarkName(el) {
|
|
59
|
+
try {
|
|
60
|
+
if (helpers && typeof helpers.getLandmarkNameInfo === 'function') {
|
|
61
|
+
const info = helpers.getLandmarkNameInfo(el, ctx);
|
|
62
|
+
if (info && info.present && info.value) return normalizeWs(info.value);
|
|
63
|
+
}
|
|
64
|
+
} catch {}
|
|
65
|
+
return '';
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function getExplicitRoleToken(el) {
|
|
69
|
+
const raw = normalizeWs(el.getAttribute && el.getAttribute('role'));
|
|
70
|
+
if (!raw) return '';
|
|
71
|
+
return raw.split(/\s+/)[0].toLowerCase();
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const SECTIONING_ANCESTORS = new Set(['article', 'aside', 'main', 'nav', 'section']);
|
|
75
|
+
|
|
76
|
+
function isSuppressedBySectioningAncestor(el) {
|
|
77
|
+
let p = el.parentElement;
|
|
78
|
+
while (p) {
|
|
79
|
+
const tag = p.tagName ? p.tagName.toLowerCase() : '';
|
|
80
|
+
if (SECTIONING_ANCESTORS.has(tag)) return true;
|
|
81
|
+
p = p.parentElement;
|
|
82
|
+
}
|
|
83
|
+
return false;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function getImplicitLandmarkRole(el) {
|
|
87
|
+
const tag = el.tagName ? el.tagName.toLowerCase() : '';
|
|
88
|
+
if (tag === 'header') return isSuppressedBySectioningAncestor(el) ? '' : 'banner';
|
|
89
|
+
if (tag === 'footer') return isSuppressedBySectioningAncestor(el) ? '' : 'contentinfo';
|
|
90
|
+
if (tag === 'main') return 'main';
|
|
91
|
+
if (tag === 'nav') return 'navigation';
|
|
92
|
+
if (tag === 'aside') return isSuppressedBySectioningAncestor(el) ? '' : 'complementary';
|
|
93
|
+
if (tag === 'section') return getAccessibleLandmarkName(el) ? 'region' : '';
|
|
94
|
+
if (tag === 'form') return getAccessibleLandmarkName(el) ? 'form' : '';
|
|
95
|
+
return '';
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const LANDMARK_ROLES = new Set(['banner', 'contentinfo', 'main', 'navigation', 'complementary', 'region', 'form', 'search']);
|
|
99
|
+
|
|
100
|
+
function getLandmarkRole(el) {
|
|
101
|
+
if (!el || !el.getAttribute) return '';
|
|
102
|
+
const explicit = getExplicitRoleToken(el);
|
|
103
|
+
if (explicit) return LANDMARK_ROLES.has(explicit) ? explicit : '';
|
|
104
|
+
return getImplicitLandmarkRole(el);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// queryAllSmart (shadow-DOM-aware) instead of plain document.querySelectorAll -- see
|
|
108
|
+
// landmark-unique-manual.js's header comment for the real page (Airtable, 2026-07-23)
|
|
109
|
+
// that surfaced this gap: a third-party shadow-DOM-hosted widget's own landmark is
|
|
110
|
+
// invisible to a light-DOM-only query.
|
|
111
|
+
let nodes = [];
|
|
112
|
+
try {
|
|
113
|
+
nodes = helpers && typeof helpers.queryAllSmart === 'function'
|
|
114
|
+
? helpers.queryAllSmart('header, footer, main, nav, aside, section, form, [role]')
|
|
115
|
+
: document.querySelectorAll('header, footer, main, nav, aside, section, form, [role]');
|
|
116
|
+
} catch {
|
|
117
|
+
nodes = [];
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const isAccTreeEligible = helpers && typeof helpers.isAccTreeEligible === 'function' ? helpers.isAccTreeEligible : null;
|
|
121
|
+
|
|
122
|
+
function isExposedToAt(el) {
|
|
123
|
+
if (!isAccTreeEligible) return true;
|
|
124
|
+
try {
|
|
125
|
+
const r = isAccTreeEligible(el, ctx);
|
|
126
|
+
if (typeof r === 'boolean') return r;
|
|
127
|
+
return !!(r && r.eligible);
|
|
128
|
+
} catch {
|
|
129
|
+
return true;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const contentinfos = [];
|
|
134
|
+
const seen = new Set();
|
|
135
|
+
for (const el of nodes) {
|
|
136
|
+
if (!el || seen.has(el)) continue;
|
|
137
|
+
seen.add(el);
|
|
138
|
+
if (!isExposedToAt(el)) continue;
|
|
139
|
+
if (getLandmarkRole(el) === 'contentinfo') contentinfos.push(el);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
if (contentinfos.length <= 1) {
|
|
143
|
+
return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const occurrences = contentinfos.map((el) => {
|
|
147
|
+
const stableSelector = helpers.buildSelector ? helpers.buildSelector(el) : 'html';
|
|
148
|
+
const html = helpers.getOuterHtmlSnippet ? helpers.getOuterHtmlSnippet(el) : (el.outerHTML || '');
|
|
149
|
+
|
|
150
|
+
return {
|
|
151
|
+
selector: stableSelector,
|
|
152
|
+
html,
|
|
153
|
+
summary: 'This page has more than one contentinfo landmark.',
|
|
154
|
+
hint: 'Keep only one contentinfo landmark (footer/role="contentinfo") per page.',
|
|
155
|
+
i18n: {
|
|
156
|
+
summaryKey: 'landmarkNoDuplicateContentinfo_summary_cantTell',
|
|
157
|
+
hintKey: 'landmarkNoDuplicateContentinfo_hint_cantTell',
|
|
158
|
+
params: { count: String(contentinfos.length) }
|
|
159
|
+
},
|
|
160
|
+
data: {
|
|
161
|
+
details: { reasonCode: 'LANDMARK_DUPLICATE_CONTENTINFO', count: contentinfos.length }
|
|
162
|
+
}
|
|
163
|
+
};
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
return { ruleId: rule.ruleId, outcome: 'cantTell', severity: rule.defaultSeverity || 'minor', occurrences };
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
module.exports = { id, meta, runInPage };
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* @check landmark-no-duplicate-main
|
|
5
|
+
* @atomic true
|
|
6
|
+
* @summary A page must not have more than one main landmark
|
|
7
|
+
* @standard Best Practices (a widely-used reference engine's classification; no formal WCAG Success Criterion — see ROADMAP.md Tier 1b)
|
|
8
|
+
* @applicability
|
|
9
|
+
* Applies whenever the page contains at least one main landmark
|
|
10
|
+
* (explicit role="main", or an implicit <main>).
|
|
11
|
+
* @expectation
|
|
12
|
+
* At most one main landmark exists on the page. Distinct, atomic
|
|
13
|
+
* decision from landmark-one-main (that rule flags zero
|
|
14
|
+
* mains too; this one only flags more than one) — matches a widely-used
|
|
15
|
+
* reference engine shipping both as separate rules with some overlap by design.
|
|
16
|
+
* @implementation-notes
|
|
17
|
+
* - Not WCAG-normative — authored as an advisory, cantTell-capped
|
|
18
|
+
* `type: 'manual'` rule; see landmark-banner-is-top-level's
|
|
19
|
+
* header comment for the shared rationale/precedent.
|
|
20
|
+
* - Only landmarks actually exposed to assistive technology can collide —
|
|
21
|
+
* matches a widely-used reference engine's own `page-no-duplicate` check (confirmed by reading
|
|
22
|
+
* its source directly: `query_selector_all_filter_default(..., elm =>
|
|
23
|
+
* _isVisibleToScreenReaders(elm))`). Without this, a responsive layout
|
|
24
|
+
* rendering both a visible and a CSS-hidden duplicate `<main>` (found on
|
|
25
|
+
* a real site — Zoom's homepage) was wrongly flagged as a duplicate
|
|
26
|
+
* landmark; the hidden copy is never actually reachable by AT.
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
const id = 'landmark-no-duplicate-main';
|
|
30
|
+
|
|
31
|
+
const meta = {
|
|
32
|
+
title: 'Page must not have more than one main landmark',
|
|
33
|
+
description: 'Checks that at most one main landmark (role="main" or <main>) exists on the page.',
|
|
34
|
+
i18n: {
|
|
35
|
+
titleKey: 'landmarkNoDuplicateMain_title',
|
|
36
|
+
descriptionKey: 'landmarkNoDuplicateMain_description'
|
|
37
|
+
},
|
|
38
|
+
helpUrl: null,
|
|
39
|
+
tags: ['best-practice', 'landmarks', 'structure', 'atomic', 'manual'],
|
|
40
|
+
wcagSc: [],
|
|
41
|
+
normativeMappings: [],
|
|
42
|
+
defaultSeverity: 'minor',
|
|
43
|
+
category: 'operable',
|
|
44
|
+
type: 'manual',
|
|
45
|
+
defaultConfidence: 'medium',
|
|
46
|
+
coverage: {}
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
function runInPage(ctx) {
|
|
50
|
+
const { document, helpers, rule } = ctx;
|
|
51
|
+
|
|
52
|
+
function normalizeWs(s) {
|
|
53
|
+
return String(s || '').replace(/\s+/g, ' ').trim();
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function getExplicitRoleToken(el) {
|
|
57
|
+
const raw = normalizeWs(el.getAttribute && el.getAttribute('role'));
|
|
58
|
+
if (!raw) return '';
|
|
59
|
+
return raw.split(/\s+/)[0].toLowerCase();
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function getLandmarkRole(el) {
|
|
63
|
+
if (!el || !el.getAttribute) return '';
|
|
64
|
+
const explicit = getExplicitRoleToken(el);
|
|
65
|
+
if (explicit) return explicit === 'main' ? 'main' : '';
|
|
66
|
+
const tag = el.tagName ? el.tagName.toLowerCase() : '';
|
|
67
|
+
return tag === 'main' ? 'main' : '';
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// queryAllSmart (shadow-DOM-aware) instead of plain document.querySelectorAll -- see
|
|
71
|
+
// landmark-unique-manual.js's header comment for the real page (Airtable, 2026-07-23)
|
|
72
|
+
// that surfaced this gap: a third-party shadow-DOM-hosted widget's own landmark is
|
|
73
|
+
// invisible to a light-DOM-only query.
|
|
74
|
+
let nodes = [];
|
|
75
|
+
try {
|
|
76
|
+
nodes = helpers && typeof helpers.queryAllSmart === 'function'
|
|
77
|
+
? helpers.queryAllSmart('main, [role]')
|
|
78
|
+
: document.querySelectorAll('main, [role]');
|
|
79
|
+
} catch {
|
|
80
|
+
nodes = [];
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const isAccTreeEligible = helpers && typeof helpers.isAccTreeEligible === 'function' ? helpers.isAccTreeEligible : null;
|
|
84
|
+
|
|
85
|
+
function isExposedToAt(el) {
|
|
86
|
+
if (!isAccTreeEligible) return true;
|
|
87
|
+
try {
|
|
88
|
+
const r = isAccTreeEligible(el, ctx);
|
|
89
|
+
if (typeof r === 'boolean') return r;
|
|
90
|
+
return !!(r && r.eligible);
|
|
91
|
+
} catch {
|
|
92
|
+
return true;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const mains = [];
|
|
97
|
+
const seen = new Set();
|
|
98
|
+
for (const el of nodes) {
|
|
99
|
+
if (!el || seen.has(el)) continue;
|
|
100
|
+
seen.add(el);
|
|
101
|
+
if (!isExposedToAt(el)) continue;
|
|
102
|
+
if (getLandmarkRole(el) === 'main') mains.push(el);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
if (mains.length <= 1) {
|
|
106
|
+
return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const occurrences = mains.map((el) => {
|
|
110
|
+
const stableSelector = helpers.buildSelector ? helpers.buildSelector(el) : 'html';
|
|
111
|
+
const html = helpers.getOuterHtmlSnippet ? helpers.getOuterHtmlSnippet(el) : (el.outerHTML || '');
|
|
112
|
+
|
|
113
|
+
return {
|
|
114
|
+
selector: stableSelector,
|
|
115
|
+
html,
|
|
116
|
+
summary: 'This page has more than one main landmark.',
|
|
117
|
+
hint: 'Keep only one main landmark (<main>/role="main") per page.',
|
|
118
|
+
i18n: {
|
|
119
|
+
summaryKey: 'landmarkNoDuplicateMain_summary_cantTell',
|
|
120
|
+
hintKey: 'landmarkNoDuplicateMain_hint_cantTell',
|
|
121
|
+
params: { count: String(mains.length) }
|
|
122
|
+
},
|
|
123
|
+
data: {
|
|
124
|
+
details: { reasonCode: 'LANDMARK_DUPLICATE_MAIN', count: mains.length }
|
|
125
|
+
}
|
|
126
|
+
};
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
return { ruleId: rule.ruleId, outcome: 'cantTell', severity: rule.defaultSeverity || 'minor', occurrences };
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
module.exports = { id, meta, runInPage };
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* @check landmark-one-main
|
|
5
|
+
* @atomic true
|
|
6
|
+
* @summary The page should have a main landmark
|
|
7
|
+
* @standard Best Practices (a widely-used reference engine's classification; no formal WCAG Success Criterion — see ROADMAP.md Tier 1b)
|
|
8
|
+
* @applicability
|
|
9
|
+
* Always applicable to any HTML document with a <body> element —
|
|
10
|
+
* "does the page have a main landmark" is a whole-page concern,
|
|
11
|
+
* matching bypass-blocks-present's pattern of evaluating the
|
|
12
|
+
* document directly.
|
|
13
|
+
* @expectation
|
|
14
|
+
* At least one main landmark (role="main" or <main>), exposed to
|
|
15
|
+
* assistive technology, exists on the page — a page with none gives
|
|
16
|
+
* AT users no landmark to jump straight to for the primary content.
|
|
17
|
+
* @implementation-notes
|
|
18
|
+
* - Not WCAG-normative — authored as an advisory, cantTell-capped
|
|
19
|
+
* `type: 'manual'` rule; see landmark-banner-is-top-level's
|
|
20
|
+
* header comment for the shared rationale/precedent.
|
|
21
|
+
* - Presence-only, matching a widely-used reference engine's real
|
|
22
|
+
* `landmark-one-main` scope exactly (confirmed 2026-07-22 by reading that
|
|
23
|
+
* engine's source directly: its `page-has-main` check is a plain
|
|
24
|
+
* descendant-exists test, `has-descendant-evaluate` — it does NOT flag
|
|
25
|
+
* more than one). This rule previously ALSO flagged "more than one main,"
|
|
26
|
+
* which was both a real scope mismatch against that reference engine (it
|
|
27
|
+
* ships that as a fully separate rule, `landmark-no-duplicate-main` /
|
|
28
|
+
* `page-no-duplicate-main`, already correctly implemented here as
|
|
29
|
+
* `landmark-no-duplicate-main`) and missing that sibling rule's
|
|
30
|
+
* accessibility-tree visibility filter, so it double-flagged cases the
|
|
31
|
+
* sibling rule already handles correctly — found via a real page
|
|
32
|
+
* (2026-07-22, live-DOM corpus): Resy's and DuckDuckGo's homepages each
|
|
33
|
+
* genuinely have two visible `<main>` elements, which that reference
|
|
34
|
+
* engine's `landmark-one-main` doesn't flag at all (out of its scope) but
|
|
35
|
+
* this rule wrongly did, disagreeing with it for a
|
|
36
|
+
* reason that wasn't a real coverage gap on either side — just a
|
|
37
|
+
* redundant, incorrectly-scoped extra branch here.
|
|
38
|
+
* - Filters candidates through `isAccTreeEligible` (hidden/aria-hidden/
|
|
39
|
+
* display:none/inert elements don't count as "a main landmark exists"),
|
|
40
|
+
* matching `landmark-no-duplicate-main`'s own precedent and
|
|
41
|
+
* that reference engine's own accessibility-tree-scoped matching.
|
|
42
|
+
*/
|
|
43
|
+
|
|
44
|
+
const id = 'landmark-one-main';
|
|
45
|
+
|
|
46
|
+
const meta = {
|
|
47
|
+
title: 'Page should have a main landmark',
|
|
48
|
+
description: 'Checks that the page has at least one main landmark (role="main" or <main>).',
|
|
49
|
+
i18n: {
|
|
50
|
+
titleKey: 'landmarkOneMain_title',
|
|
51
|
+
descriptionKey: 'landmarkOneMain_description'
|
|
52
|
+
},
|
|
53
|
+
helpUrl: null,
|
|
54
|
+
tags: ['best-practice', 'landmarks', 'structure', 'atomic', 'manual'],
|
|
55
|
+
wcagSc: [],
|
|
56
|
+
normativeMappings: [],
|
|
57
|
+
defaultSeverity: 'minor',
|
|
58
|
+
category: 'operable',
|
|
59
|
+
type: 'manual',
|
|
60
|
+
defaultConfidence: 'medium',
|
|
61
|
+
coverage: {}
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
function runInPage(ctx) {
|
|
65
|
+
const { document, helpers, rule } = ctx;
|
|
66
|
+
|
|
67
|
+
const body = document && document.body ? document.body : null;
|
|
68
|
+
if (!body) {
|
|
69
|
+
return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function getExplicitRoleToken(el) {
|
|
73
|
+
const raw = (el.getAttribute && el.getAttribute('role') || '').trim();
|
|
74
|
+
if (!raw) return '';
|
|
75
|
+
return raw.split(/\s+/)[0].toLowerCase();
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function isMainLandmark(el) {
|
|
79
|
+
if (!el || !el.getAttribute) return false;
|
|
80
|
+
const explicit = getExplicitRoleToken(el);
|
|
81
|
+
if (explicit) return explicit === 'main';
|
|
82
|
+
const tag = el.tagName ? el.tagName.toLowerCase() : '';
|
|
83
|
+
return tag === 'main';
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const isAccTreeEligible = helpers && typeof helpers.isAccTreeEligible === 'function' ? helpers.isAccTreeEligible : null;
|
|
87
|
+
|
|
88
|
+
function isExposedToAt(el) {
|
|
89
|
+
if (!isAccTreeEligible) return true;
|
|
90
|
+
try {
|
|
91
|
+
const r = isAccTreeEligible(el, ctx);
|
|
92
|
+
if (typeof r === 'boolean') return r;
|
|
93
|
+
return !!(r && r.eligible);
|
|
94
|
+
} catch {
|
|
95
|
+
return true;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// queryAllSmart (shadow-DOM-aware) instead of plain document.querySelectorAll -- see
|
|
100
|
+
// landmark-unique-manual.js's header comment for the real page (Airtable, 2026-07-23)
|
|
101
|
+
// that surfaced this gap: a third-party shadow-DOM-hosted widget's own landmark is
|
|
102
|
+
// invisible to a light-DOM-only query.
|
|
103
|
+
let nodes = [];
|
|
104
|
+
try {
|
|
105
|
+
nodes = helpers && typeof helpers.queryAllSmart === 'function'
|
|
106
|
+
? helpers.queryAllSmart('main, [role]')
|
|
107
|
+
: document.querySelectorAll('main, [role]');
|
|
108
|
+
} catch {
|
|
109
|
+
nodes = [];
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
let hasMain = false;
|
|
113
|
+
const seen = new Set();
|
|
114
|
+
for (const el of nodes) {
|
|
115
|
+
if (!el || seen.has(el)) continue;
|
|
116
|
+
seen.add(el);
|
|
117
|
+
if (!isMainLandmark(el)) continue;
|
|
118
|
+
if (!isExposedToAt(el)) continue;
|
|
119
|
+
hasMain = true;
|
|
120
|
+
break;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
if (hasMain) {
|
|
124
|
+
return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const stableSelector = helpers.buildSelector ? helpers.buildSelector(body) : 'body';
|
|
128
|
+
const html = helpers.getOuterHtmlSnippet ? helpers.getOuterHtmlSnippet(body) : (body.outerHTML || '').slice(0, 200);
|
|
129
|
+
|
|
130
|
+
return {
|
|
131
|
+
ruleId: rule.ruleId,
|
|
132
|
+
outcome: 'cantTell',
|
|
133
|
+
severity: rule.defaultSeverity || 'minor',
|
|
134
|
+
occurrences: [{
|
|
135
|
+
selector: stableSelector,
|
|
136
|
+
html,
|
|
137
|
+
summary: 'This page has no main landmark.',
|
|
138
|
+
hint: 'Add a main landmark (<main> or role="main") around the page\'s primary content.',
|
|
139
|
+
i18n: {
|
|
140
|
+
summaryKey: 'landmarkOneMain_summary_cantTell_missing',
|
|
141
|
+
hintKey: 'landmarkOneMain_hint_cantTell_missing',
|
|
142
|
+
params: {}
|
|
143
|
+
},
|
|
144
|
+
data: {
|
|
145
|
+
details: { reasonCode: 'LANDMARK_MAIN_MISSING' }
|
|
146
|
+
}
|
|
147
|
+
}]
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
module.exports = { id, meta, runInPage };
|