@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,252 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* @check landmark-unique
|
|
5
|
+
* @atomic true
|
|
6
|
+
* @summary Landmarks sharing the same role must have unique accessible names
|
|
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 two or more landmark regions on the page share the
|
|
10
|
+
* same landmark role (banner, contentinfo, main, navigation,
|
|
11
|
+
* complementary, region, form, or search — see implementation notes
|
|
12
|
+
* for the detection model).
|
|
13
|
+
* @expectation
|
|
14
|
+
* Among landmarks sharing a role, each has a distinct accessible name
|
|
15
|
+
* (via aria-label/aria-labelledby — landmarks are not named from
|
|
16
|
+
* content). Two same-role landmarks with the same name (including two
|
|
17
|
+
* both left unnamed) are indistinguishable to assistive technology
|
|
18
|
+
* users navigating by landmark.
|
|
19
|
+
* @implementation-notes
|
|
20
|
+
* - Not WCAG-normative — authored as an advisory, cantTell-capped
|
|
21
|
+
* `type: 'manual'` rule; see landmark-banner-is-top-level's
|
|
22
|
+
* header comment for the shared rationale/precedent and the landmark-
|
|
23
|
+
* detection model (HTML-AAM implicit-role mapping + explicit role
|
|
24
|
+
* override).
|
|
25
|
+
* - Flags every element within a colliding-name cluster (two or more
|
|
26
|
+
* same-role landmarks sharing one normalized name), not just the
|
|
27
|
+
* "extra" ones.
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
const id = 'landmark-unique';
|
|
31
|
+
|
|
32
|
+
const meta = {
|
|
33
|
+
title: 'Landmarks with the same role must have unique names',
|
|
34
|
+
description: 'Checks that when two or more landmarks share the same role, each has a distinct accessible name.',
|
|
35
|
+
i18n: {
|
|
36
|
+
titleKey: 'landmarkUnique_title',
|
|
37
|
+
descriptionKey: 'landmarkUnique_description'
|
|
38
|
+
},
|
|
39
|
+
helpUrl: null,
|
|
40
|
+
tags: ['best-practice', 'landmarks', 'structure', 'atomic', 'manual'],
|
|
41
|
+
wcagSc: [],
|
|
42
|
+
normativeMappings: [],
|
|
43
|
+
defaultSeverity: 'minor',
|
|
44
|
+
category: 'operable',
|
|
45
|
+
type: 'manual',
|
|
46
|
+
defaultConfidence: 'medium',
|
|
47
|
+
coverage: {}
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
function runInPage(ctx) {
|
|
51
|
+
const { document, helpers, rule } = ctx;
|
|
52
|
+
|
|
53
|
+
function normalizeWs(s) {
|
|
54
|
+
return String(s || '').replace(/\s+/g, ' ').trim();
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// Delegates to the shared helpers.getLandmarkNameInfo (aria-label -> aria-labelledby, via the
|
|
58
|
+
// target's own accessible name, not raw textContent -> title attribute fallback) rather than a
|
|
59
|
+
// local copy -- see that function's header comment in src/core/dom-helpers.js for the real bug
|
|
60
|
+
// (missing title fallback) this replaced across all 7 landmark rule files that had their own
|
|
61
|
+
// copy of this logic.
|
|
62
|
+
function getAccessibleLandmarkName(el) {
|
|
63
|
+
try {
|
|
64
|
+
if (helpers && typeof helpers.getLandmarkNameInfo === 'function') {
|
|
65
|
+
const info = helpers.getLandmarkNameInfo(el, ctx);
|
|
66
|
+
if (info && info.present && info.value) return normalizeWs(info.value);
|
|
67
|
+
}
|
|
68
|
+
} catch {}
|
|
69
|
+
return '';
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function getExplicitRoleToken(el) {
|
|
73
|
+
const raw = normalizeWs(el.getAttribute && el.getAttribute('role'));
|
|
74
|
+
if (!raw) return '';
|
|
75
|
+
return raw.split(/\s+/)[0].toLowerCase();
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// Two distinct ancestor sets, verified 2026-07-20 against a widely-used
|
|
79
|
+
// reference engine's own implicit-role functions directly rather than
|
|
80
|
+
// assumed from one shared list: <header>/<footer> use "sectioning content
|
|
81
|
+
// PLUS <main>" (that engine's getSectioningContentPlusMainSelector) to decide
|
|
82
|
+
// banner/contentinfo suppression, but <aside> uses PLAIN sectioning
|
|
83
|
+
// content only (article/aside/nav/section — NOT main) to decide
|
|
84
|
+
// complementary suppression. The old single SECTIONING_ANCESTORS set
|
|
85
|
+
// (which included 'main') was correct for header/footer but wrong for
|
|
86
|
+
// aside — found via a real page: Know Your Meme's two unnamed
|
|
87
|
+
// <aside class="extra-large-only"> elements are direct children of
|
|
88
|
+
// <main>, which incorrectly suppressed their implicit "complementary"
|
|
89
|
+
// role entirely, hiding a real duplicate-landmark violation that
|
|
90
|
+
// reference engine correctly flags.
|
|
91
|
+
const SECTIONING_ANCESTORS_PLUS_MAIN = new Set(['article', 'aside', 'main', 'nav', 'section']);
|
|
92
|
+
const SECTIONING_ANCESTORS = new Set(['article', 'aside', 'nav', 'section']);
|
|
93
|
+
|
|
94
|
+
function hasSectioningAncestorFrom(el, set) {
|
|
95
|
+
let p = el.parentElement;
|
|
96
|
+
while (p) {
|
|
97
|
+
const tag = p.tagName ? p.tagName.toLowerCase() : '';
|
|
98
|
+
if (set.has(tag)) return true;
|
|
99
|
+
p = p.parentElement;
|
|
100
|
+
}
|
|
101
|
+
return false;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function getImplicitLandmarkRole(el) {
|
|
105
|
+
const tag = el.tagName ? el.tagName.toLowerCase() : '';
|
|
106
|
+
if (tag === 'header') return hasSectioningAncestorFrom(el, SECTIONING_ANCESTORS_PLUS_MAIN) ? '' : 'banner';
|
|
107
|
+
if (tag === 'footer') return hasSectioningAncestorFrom(el, SECTIONING_ANCESTORS_PLUS_MAIN) ? '' : 'contentinfo';
|
|
108
|
+
if (tag === 'main') return 'main';
|
|
109
|
+
if (tag === 'nav') return 'navigation';
|
|
110
|
+
if (tag === 'aside') {
|
|
111
|
+
// Per a widely-used reference engine's own `aside` implicit-role function: suppressed by a
|
|
112
|
+
// sectioning-content ancestor ONLY when the <aside> also has no
|
|
113
|
+
// accessible name — a named <aside> is never suppressed, even when
|
|
114
|
+
// nested. Not yet evidenced by a real page in this corpus, but
|
|
115
|
+
// implemented to match the verified source exactly rather than
|
|
116
|
+
// leaving a known partial fix in place.
|
|
117
|
+
if (!hasSectioningAncestorFrom(el, SECTIONING_ANCESTORS)) return 'complementary';
|
|
118
|
+
return getAccessibleLandmarkName(el) ? 'complementary' : '';
|
|
119
|
+
}
|
|
120
|
+
if (tag === 'section') return getAccessibleLandmarkName(el) ? 'region' : '';
|
|
121
|
+
if (tag === 'form') return getAccessibleLandmarkName(el) ? 'form' : '';
|
|
122
|
+
return '';
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const LANDMARK_ROLES = new Set(['banner', 'contentinfo', 'main', 'navigation', 'complementary', 'region', 'form', 'search']);
|
|
126
|
+
|
|
127
|
+
function getLandmarkRole(el) {
|
|
128
|
+
if (!el || !el.getAttribute) return '';
|
|
129
|
+
const explicit = getExplicitRoleToken(el);
|
|
130
|
+
if (explicit) {
|
|
131
|
+
if (!LANDMARK_ROLES.has(explicit)) return '';
|
|
132
|
+
// <form>/<section> only count as landmarks when they have an
|
|
133
|
+
// accessible name — a property of the ELEMENT, not of how the role
|
|
134
|
+
// got there. This applies whether the role is implicit (already
|
|
135
|
+
// handled in getImplicitLandmarkRole below) or explicit, but an
|
|
136
|
+
// explicit role bypassed the check entirely before this fix. Verified
|
|
137
|
+
// against a widely-used reference engine's isLandmarkVirtual (checks
|
|
138
|
+
// nodeName === 'section' || 'form' unconditionally, regardless of role source) and the W3C
|
|
139
|
+
// ARIA-in-HTML spec ("a form is not exposed as a landmark region
|
|
140
|
+
// unless it has been provided an accessible name"). Found via a real
|
|
141
|
+
// page: europa.eu's unnamed <form role="search"> nested inside an
|
|
142
|
+
// unnamed <div role="search"> was wrongly counted as a second
|
|
143
|
+
// distinct "search" landmark.
|
|
144
|
+
const tag = el.tagName ? el.tagName.toLowerCase() : '';
|
|
145
|
+
if (tag === 'form' || tag === 'section') {
|
|
146
|
+
return getAccessibleLandmarkName(el) ? explicit : '';
|
|
147
|
+
}
|
|
148
|
+
return explicit;
|
|
149
|
+
}
|
|
150
|
+
return getImplicitLandmarkRole(el);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const isAccTreeEligible = helpers && typeof helpers.isAccTreeEligible === 'function' ? helpers.isAccTreeEligible : null;
|
|
154
|
+
|
|
155
|
+
function isExposedToAt(el) {
|
|
156
|
+
if (!isAccTreeEligible) return true;
|
|
157
|
+
try {
|
|
158
|
+
const r = isAccTreeEligible(el, ctx);
|
|
159
|
+
if (typeof r === 'boolean') return r;
|
|
160
|
+
return !!(r && r.eligible);
|
|
161
|
+
} catch {
|
|
162
|
+
return true;
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// queryAllSmart (shadow-DOM-aware, includeShadowDom defaults true) instead of a plain
|
|
167
|
+
// document.querySelectorAll -- a real page (Airtable's homepage, 2026-07-23) has a
|
|
168
|
+
// third-party Transcend cookie-consent widget rendering its own unnamed <nav>/<footer>
|
|
169
|
+
// inside a shadow root (#transcend-shadow-root), which a widely-used reference engine's
|
|
170
|
+
// own landmark-unique (a real browser DOM, shadow roots included by design) correctly sees as colliding
|
|
171
|
+
// with the page's own unnamed header <nav>/page <footer> -- a real, confirmed surea11y
|
|
172
|
+
// false-negative miss, invisible to plain querySelectorAll's light-DOM-only reach.
|
|
173
|
+
let nodes = [];
|
|
174
|
+
try {
|
|
175
|
+
nodes = helpers && typeof helpers.queryAllSmart === 'function'
|
|
176
|
+
? helpers.queryAllSmart('header, footer, main, nav, aside, section, form, [role]')
|
|
177
|
+
: document.querySelectorAll('header, footer, main, nav, aside, section, form, [role]');
|
|
178
|
+
} catch {
|
|
179
|
+
nodes = [];
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// Only landmarks actually exposed to assistive technology can collide —
|
|
183
|
+
// matches a widely-used reference engine's own `landmarkUniqueMatches` gate
|
|
184
|
+
// (`_isVisibleToScreenReaders`), confirmed by reading its source
|
|
185
|
+
// directly. Without this, responsive layouts that render both a
|
|
186
|
+
// desktop and a mobile copy of the same named nav (one hidden via CSS
|
|
187
|
+
// at any given viewport — found on real sites: BuzzFeed, Kraken,
|
|
188
|
+
// weather.com) were wrongly flagged as duplicate landmarks, since the
|
|
189
|
+
// hidden copy is never actually reachable by AT and can't really
|
|
190
|
+
// collide with the visible one.
|
|
191
|
+
const byRole = new Map(); // role -> [{el, name}]
|
|
192
|
+
const seen = new Set();
|
|
193
|
+
for (const el of nodes) {
|
|
194
|
+
if (!el || seen.has(el)) continue;
|
|
195
|
+
seen.add(el);
|
|
196
|
+
if (!isExposedToAt(el)) continue;
|
|
197
|
+
const role = getLandmarkRole(el);
|
|
198
|
+
if (!role) continue;
|
|
199
|
+
const list = byRole.get(role) || [];
|
|
200
|
+
list.push({ el, name: getAccessibleLandmarkName(el) });
|
|
201
|
+
byRole.set(role, list);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
const occurrences = [];
|
|
205
|
+
|
|
206
|
+
for (const [role, entries] of byRole) {
|
|
207
|
+
if (entries.length <= 1) continue;
|
|
208
|
+
|
|
209
|
+
const byName = new Map(); // normalized name -> entries[]
|
|
210
|
+
for (const entry of entries) {
|
|
211
|
+
const key = entry.name.toLowerCase();
|
|
212
|
+
const list = byName.get(key) || [];
|
|
213
|
+
list.push(entry);
|
|
214
|
+
byName.set(key, list);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
for (const [normalizedName, group] of byName) {
|
|
218
|
+
if (group.length <= 1) continue;
|
|
219
|
+
|
|
220
|
+
for (const { el } of group) {
|
|
221
|
+
const stableSelector = helpers.buildSelector ? helpers.buildSelector(el) : 'html';
|
|
222
|
+
const html = helpers.getOuterHtmlSnippet ? helpers.getOuterHtmlSnippet(el) : (el.outerHTML || '');
|
|
223
|
+
|
|
224
|
+
occurrences.push({
|
|
225
|
+
selector: stableSelector,
|
|
226
|
+
html,
|
|
227
|
+
summary: normalizedName
|
|
228
|
+
? `This ${role} landmark shares its accessible name with another ${role} landmark.`
|
|
229
|
+
: `This ${role} landmark has no accessible name, and more than one unnamed ${role} landmark exists on this page.`,
|
|
230
|
+
hint: `Give each ${role} landmark a distinct name via aria-label or aria-labelledby.`,
|
|
231
|
+
i18n: {
|
|
232
|
+
summaryKey: normalizedName
|
|
233
|
+
? 'landmarkUnique_summary_cantTell_duplicateName'
|
|
234
|
+
: 'landmarkUnique_summary_cantTell_bothUnnamed',
|
|
235
|
+
hintKey: 'landmarkUnique_hint_cantTell',
|
|
236
|
+
params: { role }
|
|
237
|
+
},
|
|
238
|
+
data: {
|
|
239
|
+
details: { reasonCode: 'LANDMARK_NOT_UNIQUE', role, name: normalizedName, groupSize: group.length }
|
|
240
|
+
}
|
|
241
|
+
});
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
if (!occurrences.length) {
|
|
247
|
+
return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
|
|
248
|
+
}
|
|
249
|
+
return { ruleId: rule.ruleId, outcome: 'cantTell', severity: rule.defaultSeverity || 'minor', occurrences };
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
module.exports = { id, meta, runInPage };
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* @check link-name-quality
|
|
5
|
+
* @atomic true
|
|
6
|
+
* @summary Link text should not be a generic, non-descriptive phrase
|
|
7
|
+
* @standard WCAG 2.2
|
|
8
|
+
* @sc 2.4.4
|
|
9
|
+
* @applicability
|
|
10
|
+
* Elements matching `a[href]` with a non-empty computed accessible
|
|
11
|
+
* name (programmatic first, then "name from content" — same
|
|
12
|
+
* two-step resolution as `link-name-present`). Links with no name at
|
|
13
|
+
* all are `link-name-present`'s concern, not this rule's.
|
|
14
|
+
* @expectation
|
|
15
|
+
* The link's full accessible name, normalized (trimmed, case-folded,
|
|
16
|
+
* trailing punctuation stripped), is not an exact match for a known
|
|
17
|
+
* non-descriptive phrase ("click here", "read more", "more", "here",
|
|
18
|
+
* "details", "link", etc.) — WCAG technique F84's known failure
|
|
19
|
+
* pattern for SC 2.4.4.
|
|
20
|
+
* @implementation-notes
|
|
21
|
+
* - Deliberately EXACT match only, against a small, well-established
|
|
22
|
+
* phrase list — not a substring/contains check. "Read more about our
|
|
23
|
+
* privacy policy" does not match "read more"; only the bare phrase
|
|
24
|
+
* alone does. This keeps false positives near zero at the cost of
|
|
25
|
+
* not catching every possible non-descriptive phrasing (e.g. "click
|
|
26
|
+
* this", a legitimate but uncommon variant, is not in the list).
|
|
27
|
+
* - Authored as `type: 'manual'` (cantTell-capped, never fail): this
|
|
28
|
+
* check does not verify whether *surrounding context* (adjacent text,
|
|
29
|
+
* aria-describedby) makes the purpose clear, which is exactly what
|
|
30
|
+
* distinguishes a genuine 2.4.4 failure (context doesn't help) from
|
|
31
|
+
* a link that's fine in context despite generic-sounding text alone.
|
|
32
|
+
* Flagging is a signal for review, not a definitive violation.
|
|
33
|
+
* - Reuses the same accessible-name computation as `link-name-present`
|
|
34
|
+
* (`getAccessibleNameInfo` then `getContentNameInfo` as fallback), so
|
|
35
|
+
* this benefits from the same "name from content" recursion fix
|
|
36
|
+
* (img alt / nested role="img" aria-label count toward the name).
|
|
37
|
+
*/
|
|
38
|
+
|
|
39
|
+
const id = 'link-name-quality';
|
|
40
|
+
|
|
41
|
+
const meta = {
|
|
42
|
+
title: 'Link text should be descriptive, not generic',
|
|
43
|
+
description:
|
|
44
|
+
'Flags links whose full accessible name is a known non-descriptive phrase (e.g. "click here", "read more", "more"), for manual review of whether the purpose is clear without additional context.',
|
|
45
|
+
i18n: {
|
|
46
|
+
titleKey: 'linkNameQuality_title',
|
|
47
|
+
descriptionKey: 'linkNameQuality_description'
|
|
48
|
+
},
|
|
49
|
+
helpUrl: null,
|
|
50
|
+
tags: ['wcag2a', 'wcag244', 'navigation', 'quality', 'atomic', 'manual'],
|
|
51
|
+
wcagSc: ['2.4.4'],
|
|
52
|
+
normativeMappings: [
|
|
53
|
+
{ standard: 'WCAG', version: '2.2', requirement: '2.4.4', title: 'Link Purpose (In Context)', conformanceLevel: 'A' }
|
|
54
|
+
],
|
|
55
|
+
defaultSeverity: 'minor',
|
|
56
|
+
category: 'operable',
|
|
57
|
+
type: 'manual',
|
|
58
|
+
defaultConfidence: 'medium',
|
|
59
|
+
coverage: { facetsBySc: { '2.4.4': ['link-text-descriptive-evidence'] } }
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
function runInPage(ctx) {
|
|
63
|
+
const { document, root, helpers, rule } = ctx;
|
|
64
|
+
const safeRoot = root || document;
|
|
65
|
+
|
|
66
|
+
const GENERIC_LINK_TEXT = new Set([
|
|
67
|
+
'click here', 'here', 'click', 'more', 'more info', 'more information',
|
|
68
|
+
'read more', 'learn more', 'continue reading', 'continue', 'details',
|
|
69
|
+
'more details', 'link', 'this link', 'go', 'download', 'view more',
|
|
70
|
+
'see more', 'info'
|
|
71
|
+
]);
|
|
72
|
+
|
|
73
|
+
function normalize(s) {
|
|
74
|
+
return (s == null ? '' : String(s))
|
|
75
|
+
.replace(/\s+/g, ' ')
|
|
76
|
+
.trim()
|
|
77
|
+
.toLowerCase()
|
|
78
|
+
.replace(/[.,;:!?]+$/g, '')
|
|
79
|
+
.trim();
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const selector = 'a[href]';
|
|
83
|
+
const nodes = helpers.queryAllSmart ? helpers.queryAllSmart(selector, safeRoot) : helpers.queryAll(selector, safeRoot);
|
|
84
|
+
|
|
85
|
+
const occurrences = [];
|
|
86
|
+
let applicableCount = 0;
|
|
87
|
+
|
|
88
|
+
for (const el of nodes) {
|
|
89
|
+
if (!el || !el.getAttribute) continue;
|
|
90
|
+
|
|
91
|
+
const eligResult = helpers.isAccTreeEligible ? helpers.isAccTreeEligible(el, ctx) : true;
|
|
92
|
+
const eligible = typeof eligResult === 'boolean' ? eligResult : !!(eligResult && eligResult.eligible);
|
|
93
|
+
if (!eligible) continue;
|
|
94
|
+
|
|
95
|
+
const nameInfo = helpers.getAccessibleNameInfo ? helpers.getAccessibleNameInfo(el, ctx) : null;
|
|
96
|
+
const programmaticName = (nameInfo && typeof nameInfo.value === 'string') ? nameInfo.value : '';
|
|
97
|
+
|
|
98
|
+
let rawName = programmaticName;
|
|
99
|
+
if (!rawName.trim() && helpers.getContentNameInfo) {
|
|
100
|
+
const contentInfo = helpers.getContentNameInfo(el, ctx);
|
|
101
|
+
rawName = contentInfo && contentInfo.present ? contentInfo.value : '';
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const normalized = normalize(rawName);
|
|
105
|
+
if (!normalized) continue; // no name at all: link-name-present's concern, not this rule's.
|
|
106
|
+
|
|
107
|
+
applicableCount += 1;
|
|
108
|
+
|
|
109
|
+
if (!GENERIC_LINK_TEXT.has(normalized)) continue;
|
|
110
|
+
|
|
111
|
+
const stableSelector = helpers.buildSelector ? helpers.buildSelector(el) : 'html';
|
|
112
|
+
const html = helpers.getOuterHtmlSnippet ? helpers.getOuterHtmlSnippet(el) : (el.outerHTML || '');
|
|
113
|
+
const eligInfo = helpers.getEligibilityInfo ? helpers.getEligibilityInfo(el, ctx, { targetSet: 'acc' }) : null;
|
|
114
|
+
|
|
115
|
+
occurrences.push({
|
|
116
|
+
selector: stableSelector,
|
|
117
|
+
html,
|
|
118
|
+
summary: `This link's accessible name ("${rawName.trim()}") is a generic, non-descriptive phrase.`,
|
|
119
|
+
hint: 'Make the link text itself describe its destination/purpose (e.g. "Download the 2026 pricing guide" instead of "Download"), or confirm the surrounding context already makes the purpose clear.',
|
|
120
|
+
i18n: {
|
|
121
|
+
summaryKey: 'linkNameQuality_summary_cantTell',
|
|
122
|
+
hintKey: 'linkNameQuality_hint_cantTell',
|
|
123
|
+
params: { name: rawName.trim() }
|
|
124
|
+
},
|
|
125
|
+
data: {
|
|
126
|
+
details: { reasonCode: 'GENERIC_LINK_TEXT', normalizedName: normalized },
|
|
127
|
+
visibilityFilter: eligInfo || { targetSet: 'acc', accEligible: null, reasons: [] }
|
|
128
|
+
}
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
if (applicableCount === 0) {
|
|
133
|
+
return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
if (occurrences.length) {
|
|
137
|
+
return { ruleId: rule.ruleId, outcome: 'cantTell', severity: rule.defaultSeverity || 'minor', occurrences };
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
module.exports = { id, meta, runInPage };
|