@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.
Files changed (164) hide show
  1. package/CHANGELOG.md +49 -0
  2. package/LICENSE +21 -0
  3. package/README.md +145 -0
  4. package/bin/core.js +244 -0
  5. package/docs/BINDING_AUTHORS_GUIDE.md +41 -0
  6. package/docs/CLI.md +49 -0
  7. package/docs/ENGINE_OPTIONS.md +155 -0
  8. package/docs/I18N.md +47 -0
  9. package/docs/INTEGRATION.md +156 -0
  10. package/docs/LIMITATIONS.md +31 -0
  11. package/docs/OUTPUT_SCHEMA.md +237 -0
  12. package/docs/POLICY.md +71 -0
  13. package/docs/RULE_AUTHORING.md +375 -0
  14. package/docs/RULE_CATALOG.md +180 -0
  15. package/docs/RULE_TAXONOMY.md +145 -0
  16. package/docs/TROUBLESHOOTING.md +48 -0
  17. package/docs/WCAG_CONFORMANCE.md +49 -0
  18. package/package.json +60 -0
  19. package/src/catalogs/composites.wcag.js +490 -0
  20. package/src/checks/automatic/area-alt-present.js +225 -0
  21. package/src/checks/automatic/aria-allowed-attr.js +206 -0
  22. package/src/checks/automatic/aria-allowed-role.js +102 -0
  23. package/src/checks/automatic/aria-braille-equivalent.js +139 -0
  24. package/src/checks/automatic/aria-conditional-attr.js +110 -0
  25. package/src/checks/automatic/aria-deprecated-role.js +106 -0
  26. package/src/checks/automatic/aria-hidden-body.js +87 -0
  27. package/src/checks/automatic/aria-hidden-focus.js +480 -0
  28. package/src/checks/automatic/aria-prohibited-attr.js +156 -0
  29. package/src/checks/automatic/aria-prohibited-children.js +265 -0
  30. package/src/checks/automatic/aria-required-attr.js +154 -0
  31. package/src/checks/automatic/aria-required-children.js +274 -0
  32. package/src/checks/automatic/aria-required-parent.js +222 -0
  33. package/src/checks/automatic/aria-role-name-present.js +201 -0
  34. package/src/checks/automatic/aria-roles-valid.js +110 -0
  35. package/src/checks/automatic/aria-valid-attr-value.js +123 -0
  36. package/src/checks/automatic/aria-valid-attr.js +109 -0
  37. package/src/checks/automatic/autocomplete-valid.js +134 -0
  38. package/src/checks/automatic/avoid-inline-spacing.js +107 -0
  39. package/src/checks/automatic/binary-control-name-present.js +294 -0
  40. package/src/checks/automatic/button-name-present.js +146 -0
  41. package/src/checks/automatic/bypass-blocks-present.js +162 -0
  42. package/src/checks/automatic/canvas-text-alternative-present.js +140 -0
  43. package/src/checks/automatic/combobox-name-present.js +267 -0
  44. package/src/checks/automatic/contrast-computable.js +378 -0
  45. package/src/checks/automatic/contrast-enhanced.js +517 -0
  46. package/src/checks/automatic/contrast-minimum.js +512 -0
  47. package/src/checks/automatic/css-orientation-lock.js +206 -0
  48. package/src/checks/automatic/definition-list-children-valid.js +148 -0
  49. package/src/checks/automatic/deprecated-elements-not-used.js +91 -0
  50. package/src/checks/automatic/dialog-name-present.js +209 -0
  51. package/src/checks/automatic/dlitem-parent-valid.js +100 -0
  52. package/src/checks/automatic/duplicate-id-aria.js +126 -0
  53. package/src/checks/automatic/embed-text-alternative-present.js +190 -0
  54. package/src/checks/automatic/form-control-programmatic-label-present.js +409 -0
  55. package/src/checks/automatic/form-control-single-label.js +117 -0
  56. package/src/checks/automatic/html-xml-lang-mismatch.js +91 -0
  57. package/src/checks/automatic/iframe-focusable-content.js +141 -0
  58. package/src/checks/automatic/iframe-name-present.js +102 -0
  59. package/src/checks/automatic/iframe-title-unique.js +107 -0
  60. package/src/checks/automatic/img-alt-present.js +223 -0
  61. package/src/checks/automatic/input-image-alt-present.js +155 -0
  62. package/src/checks/automatic/label-in-name.js +326 -0
  63. package/src/checks/automatic/language-page-present.js +159 -0
  64. package/src/checks/automatic/link-in-text-block.js +218 -0
  65. package/src/checks/automatic/link-name-present.js +114 -0
  66. package/src/checks/automatic/list-children-valid.js +152 -0
  67. package/src/checks/automatic/listbox-name-present.js +236 -0
  68. package/src/checks/automatic/listitem-parent-valid.js +118 -0
  69. package/src/checks/automatic/menuitem-name-present.js +201 -0
  70. package/src/checks/automatic/meta-refresh-no-exceptions.js +105 -0
  71. package/src/checks/automatic/meta-refresh-timing-absent.js +107 -0
  72. package/src/checks/automatic/meta-viewport-zoom-enabled.js +118 -0
  73. package/src/checks/automatic/meter-name-present.js +160 -0
  74. package/src/checks/automatic/nested-interactive-controls-absent.js +135 -0
  75. package/src/checks/automatic/object-text-alternative-present.js +193 -0
  76. package/src/checks/automatic/option-name-present.js +157 -0
  77. package/src/checks/automatic/page-title-present.js +86 -0
  78. package/src/checks/automatic/progressbar-name-present.js +165 -0
  79. package/src/checks/automatic/role-img-alt-present.js +206 -0
  80. package/src/checks/automatic/searchbox-name-present.js +236 -0
  81. package/src/checks/automatic/server-side-image-map-absent.js +88 -0
  82. package/src/checks/automatic/slider-name-present.js +276 -0
  83. package/src/checks/automatic/spinbutton-name-present.js +236 -0
  84. package/src/checks/automatic/summary-name-present.js +153 -0
  85. package/src/checks/automatic/svg-image-text-alternative-present.js +220 -0
  86. package/src/checks/automatic/svg-text-alternative-present.js +298 -0
  87. package/src/checks/automatic/tab-name-present.js +200 -0
  88. package/src/checks/automatic/table-headers-attr-valid.js +122 -0
  89. package/src/checks/automatic/table-th-has-data-cells.js +117 -0
  90. package/src/checks/automatic/target-size-minimum.js +605 -0
  91. package/src/checks/automatic/td-has-header.js +151 -0
  92. package/src/checks/automatic/textbox-name-present.js +236 -0
  93. package/src/checks/automatic/tooltip-name-present.js +158 -0
  94. package/src/checks/automatic/treeitem-name-present.js +157 -0
  95. package/src/checks/automatic/valid-lang.js +100 -0
  96. package/src/checks/automatic/video-poster-text-alternative-present.js +193 -0
  97. package/src/checks/manual/accesskeys-manual.js +93 -0
  98. package/src/checks/manual/area-alt-decorative-manual.js +247 -0
  99. package/src/checks/manual/area-alt-quality-manual.js +204 -0
  100. package/src/checks/manual/aria-checked-state-mismatch-manual.js +141 -0
  101. package/src/checks/manual/aria-text-manual.js +109 -0
  102. package/src/checks/manual/canvas-text-alternative-quality-manual.js +170 -0
  103. package/src/checks/manual/css-hidden-focus.js +259 -0
  104. package/src/checks/manual/embed-text-alternative-quality-manual.js +204 -0
  105. package/src/checks/manual/empty-heading-manual.js +182 -0
  106. package/src/checks/manual/empty-table-header-manual.js +163 -0
  107. package/src/checks/manual/focus-order-semantics-manual.js +117 -0
  108. package/src/checks/manual/form-control-programmatic-label-quality-manual.js +291 -0
  109. package/src/checks/manual/heading-order-manual.js +130 -0
  110. package/src/checks/manual/identical-links-same-purpose-manual.js +142 -0
  111. package/src/checks/manual/image-redundant-alt-manual.js +118 -0
  112. package/src/checks/manual/img-alt-decorative-manual.js +148 -0
  113. package/src/checks/manual/img-alt-quality-manual.js +182 -0
  114. package/src/checks/manual/input-image-alt-decorative-manual.js +144 -0
  115. package/src/checks/manual/input-image-alt-quality-manual.js +144 -0
  116. package/src/checks/manual/label-title-only-manual.js +115 -0
  117. package/src/checks/manual/landmark-banner-is-top-level-manual.js +180 -0
  118. package/src/checks/manual/landmark-contentinfo-is-top-level-manual.js +169 -0
  119. package/src/checks/manual/landmark-main-is-top-level-manual.js +167 -0
  120. package/src/checks/manual/landmark-no-duplicate-banner-manual.js +177 -0
  121. package/src/checks/manual/landmark-no-duplicate-contentinfo-manual.js +169 -0
  122. package/src/checks/manual/landmark-no-duplicate-main-manual.js +132 -0
  123. package/src/checks/manual/landmark-one-main-manual.js +151 -0
  124. package/src/checks/manual/landmark-unique-manual.js +252 -0
  125. package/src/checks/manual/link-name-quality-manual.js +143 -0
  126. package/src/checks/manual/media-transcript-present-manual.js +373 -0
  127. package/src/checks/manual/meta-viewport-large-manual.js +119 -0
  128. package/src/checks/manual/mouse-only-event-handlers-manual.js +134 -0
  129. package/src/checks/manual/no-autoplay-audio-manual.js +116 -0
  130. package/src/checks/manual/object-text-alternative-quality-manual.js +194 -0
  131. package/src/checks/manual/p-as-heading-manual.js +163 -0
  132. package/src/checks/manual/page-has-heading-one-manual.js +110 -0
  133. package/src/checks/manual/page-title-patterns-manual.js +262 -0
  134. package/src/checks/manual/presentation-role-conflict-manual.js +159 -0
  135. package/src/checks/manual/region-manual.js +183 -0
  136. package/src/checks/manual/scope-attr-valid-manual.js +93 -0
  137. package/src/checks/manual/scrollable-region-focusable-manual.js +168 -0
  138. package/src/checks/manual/skip-link-manual.js +150 -0
  139. package/src/checks/manual/svg-text-alternative-quality-manual.js +209 -0
  140. package/src/checks/manual/tabindex-manual.js +94 -0
  141. package/src/checks/manual/table-duplicate-name-manual.js +99 -0
  142. package/src/checks/manual/table-fake-caption-manual.js +122 -0
  143. package/src/checks/manual/video-caption-manual.js +118 -0
  144. package/src/checks/manual-review.js +95 -0
  145. package/src/checks/rules-and-tags.full.csv +19 -0
  146. package/src/checks/rules-and-tags.full.json +259 -0
  147. package/src/core/aria-helpers.js +906 -0
  148. package/src/core/contrast-helpers.js +1147 -0
  149. package/src/core/dom-helpers.js +4085 -0
  150. package/src/core/dom-runner.js +627 -0
  151. package/src/core/frame-messaging.js +210 -0
  152. package/src/core/frame-scan.js +178 -0
  153. package/src/core/rollup-composites.js +135 -0
  154. package/src/core/rule-meta.js +140 -0
  155. package/src/core.js +79055 -0
  156. package/src/coverage/wcag-facets.js +1079 -0
  157. package/src/coverage/wcag-version-map.js +84 -0
  158. package/src/i18n/en.js +919 -0
  159. package/src/i18n/fr.js +527 -0
  160. package/src/index.js +4 -0
  161. package/src/policy/contracts.js +18 -0
  162. package/src/policy/resolvePolicy.js +55 -0
  163. package/src/policy/schemas/engine-options.schema.json +103 -0
  164. 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 };