@surea11y/core 1.6.0 → 1.8.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 (120) hide show
  1. package/CHANGELOG.md +140 -0
  2. package/README.md +179 -90
  3. package/docs/ACT_RULE_MAPPING.md +10 -8
  4. package/docs/API_STABILITY.md +67 -6
  5. package/docs/BINDING_AUTHORS_GUIDE.md +104 -2
  6. package/docs/CI_INTEGRATIONS.md +43 -0
  7. package/docs/DESIGN_CHALLENGES.md +162 -2
  8. package/docs/EARL.md +100 -0
  9. package/docs/ENGINE_OPTIONS.md +109 -5
  10. package/docs/I18N.md +62 -20
  11. package/docs/INTEGRATION.md +4 -2
  12. package/docs/JUNIT.md +73 -0
  13. package/docs/LIMITATIONS.md +4 -1
  14. package/docs/OUTPUT_SCHEMA.md +62 -11
  15. package/docs/POLICY.md +1 -1
  16. package/docs/REPORT.md +7 -2
  17. package/docs/RULE_AUTHORING.md +83 -17
  18. package/docs/RULE_CATALOG.md +212 -139
  19. package/docs/RULE_EXAMPLES.md +2189 -0
  20. package/docs/RULE_HELPERS.md +390 -0
  21. package/docs/RULE_TAXONOMY.md +27 -6
  22. package/docs/SARIF.md +23 -3
  23. package/docs/WCAG_CONFORMANCE.md +64 -3
  24. package/package.json +41 -12
  25. package/profiles/index.js +14 -0
  26. package/src/checks/automatic/area-alt-present.js +87 -31
  27. package/src/checks/automatic/aria-allowed-attr.js +6 -0
  28. package/src/checks/automatic/aria-allowed-role.js +32 -23
  29. package/src/checks/automatic/aria-braille-equivalent.js +43 -17
  30. package/src/checks/automatic/aria-conditional-attr.js +17 -10
  31. package/src/checks/automatic/aria-deprecated-role.js +12 -0
  32. package/src/checks/automatic/aria-hidden-body.js +1 -1
  33. package/src/checks/automatic/aria-hidden-focus.js +74 -18
  34. package/src/checks/automatic/aria-prohibited-attr.js +22 -4
  35. package/src/checks/automatic/aria-prohibited-children.js +6 -6
  36. package/src/checks/automatic/aria-required-attr.js +88 -12
  37. package/src/checks/automatic/aria-required-children.js +33 -16
  38. package/src/checks/automatic/aria-required-parent.js +32 -6
  39. package/src/checks/automatic/aria-role-name-present.js +20 -3
  40. package/src/checks/automatic/aria-roles-valid.js +52 -21
  41. package/src/checks/automatic/aria-valid-attr-value.js +89 -24
  42. package/src/checks/automatic/aria-valid-attr.js +14 -9
  43. package/src/checks/automatic/autocomplete-valid.js +152 -26
  44. package/src/checks/automatic/avoid-inline-spacing.js +207 -15
  45. package/src/checks/automatic/button-name-present.js +2 -1
  46. package/src/checks/automatic/canvas-text-alternative-present.js +105 -14
  47. package/src/checks/automatic/combobox-name-present.js +34 -51
  48. package/src/checks/automatic/contrast-computable.js +45 -4
  49. package/src/checks/automatic/contrast-enhanced.js +16 -4
  50. package/src/checks/automatic/contrast-minimum.js +57 -11
  51. package/src/checks/automatic/css-orientation-lock.js +171 -12
  52. package/src/checks/automatic/definition-list-children-valid.js +67 -23
  53. package/src/checks/automatic/deprecated-elements-not-used.js +43 -38
  54. package/src/checks/automatic/dialog-name-present.js +28 -9
  55. package/src/checks/automatic/duplicate-id-aria.js +5 -0
  56. package/src/checks/automatic/duplicate-id.js +19 -10
  57. package/src/checks/automatic/form-control-single-label.js +9 -0
  58. package/src/checks/automatic/identical-iframes-same-purpose.js +229 -0
  59. package/src/checks/automatic/iframe-focusable-content.js +12 -4
  60. package/src/checks/automatic/iframe-title-unique.js +36 -81
  61. package/src/checks/automatic/input-image-alt-present.js +32 -20
  62. package/src/checks/automatic/label-in-name.js +78 -69
  63. package/src/checks/automatic/language-page-present.js +12 -6
  64. package/src/checks/automatic/link-in-text-block.js +512 -44
  65. package/src/checks/automatic/link-name-present.js +13 -5
  66. package/src/checks/automatic/list-children-valid.js +18 -1
  67. package/src/checks/automatic/listbox-name-present.js +19 -49
  68. package/src/checks/automatic/listitem-parent-valid.js +4 -3
  69. package/src/checks/automatic/meta-refresh-no-exceptions.js +3 -3
  70. package/src/checks/automatic/page-title-present.js +16 -4
  71. package/src/checks/automatic/progressbar-name-present.js +11 -1
  72. package/src/checks/automatic/{role-img-alt-present.js → role-img-text-alternative-present.js} +9 -5
  73. package/src/checks/automatic/searchbox-name-present.js +32 -49
  74. package/src/checks/automatic/server-side-image-map-absent.js +48 -28
  75. package/src/checks/automatic/slider-name-present.js +38 -52
  76. package/src/checks/automatic/spinbutton-name-present.js +32 -49
  77. package/src/checks/automatic/target-size-minimum.js +84 -16
  78. package/src/checks/automatic/td-has-header.js +60 -23
  79. package/src/checks/automatic/text-spacing-content-loss.js +548 -0
  80. package/src/checks/automatic/textbox-name-present.js +32 -49
  81. package/src/checks/automatic/valid-lang.js +15 -10
  82. package/src/checks/manual/area-alt-quality-manual.js +113 -31
  83. package/src/checks/manual/canvas-text-alternative-quality-manual.js +0 -2
  84. package/src/checks/manual/css-focus-indicator-suppressed-manual.js +23 -10
  85. package/src/checks/manual/css-hidden-focus.js +215 -7
  86. package/src/checks/manual/form-control-label-quality-manual.js +243 -29
  87. package/src/checks/manual/heading-order-manual.js +9 -1
  88. package/src/checks/manual/heading-quality-manual.js +143 -9
  89. package/src/checks/manual/img-alt-decorative-manual.js +6 -3
  90. package/src/checks/manual/input-image-alt-quality-manual.js +81 -13
  91. package/src/checks/manual/landmark-complementary-is-top-level-manual.js +231 -0
  92. package/src/checks/manual/link-name-quality-manual.js +130 -4
  93. package/src/checks/manual/media-transcript-present-manual.js +65 -8
  94. package/src/checks/manual/mouse-only-event-handlers-manual.js +66 -5
  95. package/src/checks/manual/no-autoplay-audio-manual.js +93 -6
  96. package/src/checks/manual/p-as-heading-manual.js +89 -44
  97. package/src/checks/manual/page-title-patterns-manual.js +77 -8
  98. package/src/checks/manual/password-paste-enabled-manual.js +255 -0
  99. package/src/checks/manual/skip-link-manual.js +42 -14
  100. package/src/checks/manual/table-fake-caption-manual.js +32 -1
  101. package/src/checks/manual/video-caption-manual.js +47 -24
  102. package/src/checks/manual-review.js +0 -4
  103. package/src/core.js +18061 -46194
  104. package/src/coverage/en301549-map.js +187 -0
  105. package/src/coverage/standards.js +279 -0
  106. package/src/coverage/wcag-facets.js +1119 -0
  107. package/src/coverage/wcag-version-map.js +101 -0
  108. package/src/earl.js +144 -0
  109. package/src/en301549.js +33 -0
  110. package/src/junit.js +321 -0
  111. package/src/profile-kit.js +163 -0
  112. package/src/report.js +343 -74
  113. package/src/sarif.js +56 -5
  114. package/src/wcag.js +105 -0
  115. package/surea11y.browser.js +11 -41039
  116. package/surea11y.i18n.de.js +2 -21
  117. package/surea11y.i18n.es.js +2 -21
  118. package/surea11y.i18n.fr.js +2 -21
  119. package/surea11y.i18n.ja.js +3 -0
  120. package/src/checks/manual/area-alt-decorative-manual.js +0 -255
@@ -10,20 +10,25 @@
10
10
  * @sc 1.1.1
11
11
  * @type manual
12
12
  * @applicability
13
- * Applies to <input type="image"> elements whose alt attribute is present
14
- * and non-empty: an image button whose alt is its label. The element must
15
- * be included in the accessibility tree, and role="presentation"/"none"
16
- * takes it out of scope unless it is focusable, which restores its role.
13
+ * Applies to <input type="image"> elements that get a non-empty text
14
+ * alternative from any source: aria-labelledby (resolving to text),
15
+ * aria-label, alt or title. An element whose alt is present but empty is
16
+ * left to input-image-alt-decorative, which asks about that case. The
17
+ * element must be included in the accessibility tree, and
18
+ * role="presentation"/"none" takes it out of scope unless it is
19
+ * focusable, which restores its role.
17
20
  * @expectation
18
- * Human review is required to confirm that the provided text alternative is accurate and appropriate.
21
+ * Human review is required to confirm that the provided text alternative is
22
+ * accurate and appropriate. Each occurrence lists every source present
23
+ * (data.details.sources), so the reviewer checks each one.
19
24
  */
20
25
 
21
26
  const id = 'input-image-alt-quality';
22
27
 
23
28
  const meta = {
24
- title: '<input type="image"> alt text must be appropriate (manual review)',
29
+ title: '<input type="image"> text alternative must be appropriate (manual review)',
25
30
  description:
26
- 'Flags <input type="image"> elements with non-empty alt text for human review of appropriateness.',
31
+ 'Flags <input type="image"> elements with a non-empty text alternative (alt, aria-label, aria-labelledby or title) for human review of appropriateness.',
27
32
  i18n: {
28
33
  titleKey: 'inputImage_altQuality_title',
29
34
  descriptionKey: 'inputImage_altQuality_description'
@@ -112,6 +117,53 @@ function runInPage(ctx) {
112
117
  return !focusable;
113
118
  }
114
119
 
120
+ const getAriaNameInfo =
121
+ helpers && typeof helpers.getAriaNameInfo === 'function' ? helpers.getAriaNameInfo : null;
122
+
123
+ // Every non-empty text-alternative source on the element, in accessible-name
124
+ // order: aria-labelledby (when it resolves to text), aria-label, alt, title.
125
+ // aria-labelledby wins over aria-label in the name, but a present aria-label
126
+ // is still listed, so each attribute present is asked about.
127
+ function collectTextAlternativeSources(el) {
128
+ const attr = (name) => {
129
+ try {
130
+ const v = el.getAttribute(name);
131
+ return v == null ? '' : String(v).trim();
132
+ } catch {
133
+ return '';
134
+ }
135
+ };
136
+ const sources = [];
137
+ let name = '';
138
+ let aria = null;
139
+ if (getAriaNameInfo) {
140
+ try {
141
+ aria = getAriaNameInfo(el, ctx);
142
+ } catch {
143
+ aria = null;
144
+ }
145
+ }
146
+ if (aria && aria.present && aria.value) {
147
+ name = String(aria.value).trim();
148
+ sources.push(aria.mechanism);
149
+ if (aria.mechanism === 'aria-labelledby' && attr('aria-label')) sources.push('aria-label');
150
+ } else if (!getAriaNameInfo && attr('aria-label')) {
151
+ name = attr('aria-label');
152
+ sources.push('aria-label');
153
+ }
154
+ const altText = attr('alt');
155
+ if (altText) {
156
+ sources.push('alt');
157
+ if (!name) name = altText;
158
+ }
159
+ const titleText = attr('title');
160
+ if (titleText) {
161
+ sources.push('title');
162
+ if (!name) name = titleText;
163
+ }
164
+ return { sources, name, alt: altText };
165
+ }
166
+
115
167
  const els = (() => {
116
168
  try {
117
169
  return Array.from(
@@ -146,24 +198,40 @@ function runInPage(ctx) {
146
198
 
147
199
  if (isRolePresentationExcluded(el)) continue;
148
200
 
149
- // Rule-specific applicability (only elements that already have a text alternative mechanism)
150
- if (!(el.getAttribute('alt') != null && String(el.getAttribute('alt')).trim() !== '')) continue;
201
+ // alt="" with another name is input-image-alt-decorative's question, so
202
+ // it is left there rather than asked twice.
203
+ let altRaw;
204
+ try {
205
+ altRaw = el.getAttribute('alt');
206
+ } catch {
207
+ altRaw = null;
208
+ }
209
+ if (altRaw != null && String(altRaw).trim() === '') continue;
210
+
211
+ // Applies when any text-alternative source gives the control a non-empty
212
+ // name; each present source is listed so the reviewer checks all of them.
213
+ const alt = collectTextAlternativeSources(el);
214
+ if (!alt.sources.length) continue;
151
215
 
152
216
  applicableCount += 1;
153
217
 
154
218
  const eligInfo = getEligibilityInfo ? getEligibilityInfo(el, ctx, { targetSet: 'acc' }) : null;
219
+ const sourcesText = alt.sources.join(', ');
220
+
221
+ const details = { name: alt.name, sources: alt.sources.slice() };
222
+ if (alt.alt) details.alt = alt.alt;
155
223
 
156
224
  const baseOccurrence = {
157
- summary: 'Review alt text on <input type="image"> for accuracy and appropriateness.',
158
- hint: 'Ensure the alt text describes the control’s action (e.g., “Search”, “Submit order”) in context.',
225
+ summary: `Review the text alternative of this <input type="image"> (${sourcesText}) for accuracy and appropriateness.`,
226
+ hint: 'Ensure each listed text alternative describes the control’s action (e.g., “Search”, “Submit order”) in context.',
159
227
  i18n: {
160
228
  summaryKey: 'inputImage_altQuality_summary_cantTell',
161
229
  hintKey: 'inputImage_altQuality_hint_cantTell',
162
- params: { element: 'input[type=image]' }
230
+ params: { element: 'input[type=image]', sources: sourcesText }
163
231
  },
164
232
  data: {
165
233
  visibilityFilter: eligInfo || { targetSet: 'acc', accEligible: null, reasons: [] },
166
- details: { alt: String(el.getAttribute('alt') || '') } // optional but useful
234
+ details
167
235
  }
168
236
  };
169
237
 
@@ -0,0 +1,231 @@
1
+ /* SPDX-License-Identifier: MPL-2.0 */
2
+
3
+ 'use strict';
4
+
5
+ /**
6
+ * @check landmark-complementary-is-top-level
7
+ * @atomic true
8
+ * @summary The complementary landmark must not be nested inside another landmark
9
+ * @standard Best Practices (no formal WCAG Success Criterion)
10
+ * @applicability
11
+ * Applies whenever the page contains at least one element carrying the
12
+ * complementary role: explicit role="complementary", or an <aside> that
13
+ * keeps its implicit role (see implementation notes on when it does not).
14
+ * @expectation
15
+ * No complementary candidate has an ancestor that is itself a landmark
16
+ * region. Complementary content supports the main content of the page and
17
+ * sits beside it; nested inside another landmark it is a section of that
18
+ * landmark instead, which is not what landmark navigation announces.
19
+ * @implementation-notes
20
+ * - Not WCAG-normative, authored as an advisory, cantTell-capped
21
+ * `type: 'manual'` rule, matching its three siblings
22
+ * (`landmark-banner-is-top-level`, `landmark-contentinfo-is-top-level`,
23
+ * `landmark-main-is-top-level`). Landmark detection and the
24
+ * ancestor walk are identical to theirs; only the role being looked for
25
+ * differs.
26
+ * - An unnamed <aside> inside sectioning content has no complementary role
27
+ * per HTML-AAM, so it is not a candidate at all: reporting it would name a
28
+ * landmark that does not exist. A *named* one keeps the role wherever it
29
+ * sits, which is exactly the case worth review -- an <aside aria-label>
30
+ * inside <main> really is a complementary landmark nested in another
31
+ * landmark. `landmark-unique` and the sibling top-level rules already
32
+ * resolve <aside> this way, through the same shared helper.
33
+ */
34
+
35
+ const id = 'landmark-complementary-is-top-level';
36
+
37
+ const meta = {
38
+ title: 'Complementary landmark must be top-level',
39
+ description:
40
+ 'Checks that the complementary landmark (role="complementary" or an <aside> that keeps its implicit role) is not nested inside another landmark region.',
41
+ i18n: {
42
+ titleKey: 'landmarkComplementaryIsTopLevel_title',
43
+ descriptionKey: 'landmarkComplementaryIsTopLevel_description'
44
+ },
45
+ helpUrl: null,
46
+ tags: ['best-practice', 'landmarks', 'structure', 'atomic', 'manual'],
47
+ wcagSc: [],
48
+ normativeMappings: [],
49
+ defaultSeverity: 'minor',
50
+ category: 'operable',
51
+ type: 'manual',
52
+ defaultConfidence: 'medium',
53
+ coverage: {}
54
+ };
55
+
56
+ function runInPage(ctx) {
57
+ const { document, root, helpers, rule } = ctx;
58
+
59
+ // Declared inside runInPage; see scripts/build-core.js header
60
+ // ("runInPage MUST be self-contained").
61
+ function normalizeWs(s) {
62
+ return String(s || '')
63
+ .replace(/\s+/g, ' ')
64
+ .trim();
65
+ }
66
+
67
+ // Delegates to the shared helpers.getLandmarkNameInfo (aria-label -> aria-labelledby, via the
68
+ // target's own accessible name, not raw textContent -> title attribute fallback) rather than a
69
+ // local copy -- see that function's header comment in src/core/dom-helpers.js. Sharing it keeps
70
+ // the title-attribute fallback consistent across the landmark rules.
71
+ function getAccessibleLandmarkName(el) {
72
+ try {
73
+ if (helpers && typeof helpers.getLandmarkNameInfo === 'function') {
74
+ const info = helpers.getLandmarkNameInfo(el, ctx);
75
+ if (info && info.present && info.value) return normalizeWs(info.value);
76
+ }
77
+ } catch {}
78
+ return '';
79
+ }
80
+
81
+ function getExplicitRoleToken(el) {
82
+ const raw = normalizeWs(el.getAttribute && el.getAttribute('role'));
83
+ if (!raw) return '';
84
+ return raw.split(/\s+/)[0].toLowerCase();
85
+ }
86
+
87
+ // Delegates to the shared helpers.hasLandmarkScopingAncestor for the
88
+ // question "does this element sit inside a sectioning-content/<main>
89
+ // ancestor that suppresses its conditional implicit role": role-aware
90
+ // (an ancestor's bare TAG only counts when it carries no role attribute
91
+ // at all; an explicit role="dialog"-style override no longer suppresses)
92
+ // rather than a local tag-only copy. See that function's header comment
93
+ // in src/core/aria-helpers.js for the full algorithm.
94
+ function hasSectioningAncestor(el, includeMain) {
95
+ return helpers && typeof helpers.hasLandmarkScopingAncestor === 'function'
96
+ ? helpers.hasLandmarkScopingAncestor(el, { includeMain })
97
+ : false;
98
+ }
99
+
100
+ function getImplicitLandmarkRole(el) {
101
+ const tag = el.tagName ? el.tagName.toLowerCase() : '';
102
+ if (tag === 'header') return hasSectioningAncestor(el, true) ? '' : 'banner';
103
+ if (tag === 'footer') return hasSectioningAncestor(el, true) ? '' : 'contentinfo';
104
+ if (tag === 'main') return 'main';
105
+ if (tag === 'nav') return 'navigation';
106
+ if (tag === 'aside') {
107
+ // A named <aside> is never suppressed, even when nested. It keeps
108
+ // "complementary" when it has an accessible name, even inside
109
+ // sectioning content. Matches landmark-unique's precedent.
110
+ if (!hasSectioningAncestor(el, false)) return 'complementary';
111
+ return getAccessibleLandmarkName(el) ? 'complementary' : '';
112
+ }
113
+ if (tag === 'section') return getAccessibleLandmarkName(el) ? 'region' : '';
114
+ if (tag === 'form') return getAccessibleLandmarkName(el) ? 'form' : '';
115
+ return '';
116
+ }
117
+
118
+ const LANDMARK_ROLES = new Set([
119
+ 'banner',
120
+ 'contentinfo',
121
+ 'main',
122
+ 'navigation',
123
+ 'complementary',
124
+ 'region',
125
+ 'form',
126
+ 'search'
127
+ ]);
128
+
129
+ function getLandmarkRole(el) {
130
+ if (!el || !el.getAttribute) return '';
131
+ const explicit = getExplicitRoleToken(el);
132
+ if (explicit) return LANDMARK_ROLES.has(explicit) ? explicit : '';
133
+ return getImplicitLandmarkRole(el);
134
+ }
135
+
136
+ // A candidate must actually carry the complementary role. An <aside> that
137
+ // HTML-AAM strips the role from is not a complementary landmark at all, so
138
+ // flagging it would report a landmark that does not exist.
139
+ function isComplementaryCandidate(el) {
140
+ return getLandmarkRole(el) === 'complementary';
141
+ }
142
+
143
+ function hasLandmarkAncestor(el) {
144
+ const scopeRoots = Array.isArray(root) ? root : root ? [root] : [];
145
+ let p = el.parentElement;
146
+ while (p) {
147
+ if (getLandmarkRole(p)) return true;
148
+ // Don't climb past the scanned scope -- see aria-helpers.js's
149
+ // hasLandmarkScopingAncestor for the same fix and rationale.
150
+ if (scopeRoots.includes(p)) break;
151
+ p = p.parentElement;
152
+ }
153
+ return false;
154
+ }
155
+
156
+ // queryAllSmart (shadow-DOM-aware) instead of plain document.querySelectorAll -- see
157
+ // landmark-unique-manual.js's header comment. A third-party shadow-DOM-hosted
158
+ // widget's own landmark is invisible to a light-DOM-only query.
159
+ let nodes;
160
+ try {
161
+ nodes =
162
+ helpers && typeof helpers.queryAllSmart === 'function'
163
+ ? helpers.queryAllSmart('header, footer, main, nav, aside, section, form, [role]')
164
+ : document.querySelectorAll('header, footer, main, nav, aside, section, form, [role]');
165
+ } catch {
166
+ nodes = [];
167
+ }
168
+
169
+ const complementaries = [];
170
+ const seen = new Set();
171
+ for (const el of nodes) {
172
+ if (!el || seen.has(el)) continue;
173
+ seen.add(el);
174
+ if (!isComplementaryCandidate(el)) continue;
175
+
176
+ // An aria-hidden candidate is removed from the accessibility tree
177
+ // entirely, so it is not part of the landmark structure assistive
178
+ // technology users navigate and there is no real landmark to call
179
+ // nested. queryAllSmart's default hidden-content policy only excludes
180
+ // "hard" CSS-based hiding (display:none, etc.), not the softer
181
+ // aria-hidden exclusion, so this needs its own check.
182
+ if (helpers && typeof helpers.isAccTreeEligible === 'function') {
183
+ const elig = (() => {
184
+ try {
185
+ return helpers.isAccTreeEligible(el, ctx);
186
+ } catch {
187
+ return { eligible: true, reasons: [] };
188
+ }
189
+ })();
190
+ if (elig && elig.eligible === false) continue;
191
+ }
192
+
193
+ complementaries.push(el);
194
+ }
195
+
196
+ if (complementaries.length === 0) {
197
+ return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
198
+ }
199
+
200
+ const occurrences = [];
201
+ for (const el of complementaries) {
202
+ if (!hasLandmarkAncestor(el)) continue;
203
+
204
+ occurrences.push(
205
+ helpers.reportOccurrence(el, {
206
+ summary: 'This complementary landmark is nested inside another landmark region.',
207
+ hint: 'Move the complementary landmark (<aside>/role="complementary") so it is not contained by another landmark; complementary content belongs beside the main content, not inside another region.',
208
+ i18n: {
209
+ summaryKey: 'landmarkComplementaryIsTopLevel_summary_cantTell',
210
+ hintKey: 'landmarkComplementaryIsTopLevel_hint_cantTell',
211
+ params: {}
212
+ },
213
+ data: {
214
+ details: { reasonCode: 'LANDMARK_COMPLEMENTARY_NOT_TOP_LEVEL' }
215
+ }
216
+ })
217
+ );
218
+ }
219
+
220
+ if (occurrences.length) {
221
+ return {
222
+ ruleId: rule.ruleId,
223
+ outcome: 'cantTell',
224
+ severity: rule.defaultSeverity || 'minor',
225
+ occurrences
226
+ };
227
+ }
228
+ return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
229
+ }
230
+
231
+ module.exports = { id, meta, runInPage };
@@ -24,6 +24,11 @@
24
24
  * enclosing list item/table cell/paragraph's own text, or (format
25
25
  * names only) a table's first-row header) naming what it belongs to.
26
26
  * @implementation-notes
27
+ * - Phrase lists exist for en, de, es, fr and ja. English is always
28
+ * checked; the list for the element's own language (nearest lang
29
+ * attribute, across shadow roots) is added on top. Matching every list
30
+ * everywhere would flag words that are generic in one language and a
31
+ * real name in another ("Suite", "Plus" on an English page).
27
32
  * - EXACT match only, on purpose, against small, well-established
28
33
  * phrase lists, not a substring/contains check. "Read more about our
29
34
  * privacy policy" does not match "read more"; only the bare phrase
@@ -52,7 +57,7 @@ const id = 'link-name-quality';
52
57
  const meta = {
53
58
  title: 'Link text should be descriptive, not generic',
54
59
  description:
55
- 'Flags links whose full accessible name is a known non-descriptive phrase (e.g. "click here", "read more", "more") or a bare file-format name (e.g. "HTML", "PDF") with no adjacent context naming what it leads to, for manual review of whether the purpose is clear.',
60
+ 'Flags links whose full accessible name is a known non-descriptive phrase (e.g. "click here", "read more", "more") or a bare file-format name (e.g. "HTML", "PDF") with no adjacent context naming what it leads to, for manual review of whether the purpose is clear. English phrases are always recognized, and German, Spanish, French or Japanese ones when the link is in that language.',
56
61
  i18n: {
57
62
  titleKey: 'linkNameQuality_title',
58
63
  descriptionKey: 'linkNameQuality_description'
@@ -79,7 +84,7 @@ const meta = {
79
84
  function runInPage(ctx) {
80
85
  const { helpers, rule } = ctx;
81
86
 
82
- const GENERIC_LINK_TEXT = new Set([
87
+ const GENERIC_LINK_TEXT_EN = new Set([
83
88
  'click here',
84
89
  'here',
85
90
  'click',
@@ -101,6 +106,100 @@ function runInPage(ctx) {
101
106
  'info'
102
107
  ]);
103
108
 
109
+ const GENERIC_LINK_TEXT = {
110
+ en: GENERIC_LINK_TEXT_EN,
111
+ de: new Set([
112
+ 'hier klicken',
113
+ 'klicken sie hier',
114
+ 'hier',
115
+ 'klicken',
116
+ 'mehr',
117
+ 'mehr info',
118
+ 'mehr infos',
119
+ 'mehr informationen',
120
+ 'weiterlesen',
121
+ 'mehr lesen',
122
+ 'mehr erfahren',
123
+ 'weiter',
124
+ 'details',
125
+ 'mehr details',
126
+ 'link',
127
+ 'dieser link',
128
+ 'los',
129
+ 'herunterladen',
130
+ 'mehr anzeigen',
131
+ 'info'
132
+ ]),
133
+ es: new Set([
134
+ 'haga clic aquí',
135
+ 'haz clic aquí',
136
+ 'clic aquí',
137
+ 'pulse aquí',
138
+ 'pincha aquí',
139
+ 'aquí',
140
+ 'clic',
141
+ 'más',
142
+ 'más info',
143
+ 'más información',
144
+ 'leer más',
145
+ 'saber más',
146
+ 'seguir leyendo',
147
+ 'continuar leyendo',
148
+ 'continuar',
149
+ 'detalles',
150
+ 'más detalles',
151
+ 'enlace',
152
+ 'este enlace',
153
+ 'ir',
154
+ 'descargar',
155
+ 'ver más',
156
+ 'info'
157
+ ]),
158
+ fr: new Set([
159
+ 'cliquez ici',
160
+ 'cliquer ici',
161
+ 'ici',
162
+ 'cliquez',
163
+ 'plus',
164
+ "plus d'infos",
165
+ "plus d'informations",
166
+ 'en savoir plus',
167
+ 'lire la suite',
168
+ 'la suite',
169
+ 'suite',
170
+ 'continuer',
171
+ 'détails',
172
+ 'plus de détails',
173
+ 'lien',
174
+ 'ce lien',
175
+ 'télécharger',
176
+ 'voir plus',
177
+ 'info'
178
+ ]),
179
+ ja: new Set([
180
+ 'こちら',
181
+ 'ここ',
182
+ 'こちらをクリック',
183
+ 'ここをクリック',
184
+ 'クリック',
185
+ '詳しく',
186
+ '詳しくは',
187
+ '詳しくはこちら',
188
+ '詳細',
189
+ '詳細はこちら',
190
+ '詳細を見る',
191
+ 'もっと見る',
192
+ 'もっと読む',
193
+ 'さらに詳しく',
194
+ '続きを読む',
195
+ '続き',
196
+ 'リンク',
197
+ 'このリンク',
198
+ 'ダウンロード',
199
+ '情報'
200
+ ])
201
+ };
202
+
104
203
  const FORMAT_NAME_LINK_TEXT = new Set([
105
204
  'html',
106
205
  'pdf',
@@ -124,15 +223,42 @@ function runInPage(ctx) {
124
223
 
125
224
  const CONTEXT_BLOCK_TAGS = new Set(['td', 'th', 'p', 'dd', 'blockquote', 'figcaption', 'dt']);
126
225
 
226
+ // NFKC folds full-width letters and punctuation (!, >) into their ASCII
227
+ // forms, and the curly apostrophe is folded so "plus d’infos" matches.
228
+ // Trailing arrows ("Read more »", 「詳しくはこちら→」) are decoration, not
229
+ // part of the phrase.
127
230
  function normalize(s) {
128
231
  return (s == null ? '' : String(s))
232
+ .normalize('NFKC')
233
+ .replace(/[\u2018\u2019]/g, "'")
129
234
  .replace(/\s+/g, ' ')
130
235
  .trim()
131
236
  .toLowerCase()
132
- .replace(/[.,;:!?]+$/g, '')
237
+ .replace(/[\s.,;:!?。、>»›→]+$/g, '')
133
238
  .trim();
134
239
  }
135
240
 
241
+ // Primary language subtag of the nearest lang attribute, crossing shadow
242
+ // roots; '' when none is declared. Phrase lists are matched in English
243
+ // plus this language, so a word that is generic in one language ("plus"
244
+ // in French) is not flagged when it is a real name in another.
245
+ function primaryLangOf(node) {
246
+ let n = node;
247
+ while (n) {
248
+ if (n.nodeType === 1 && n.getAttribute) {
249
+ const v = n.getAttribute('lang');
250
+ if (v != null) return v.trim().split('-')[0].toLowerCase();
251
+ }
252
+ n = n.parentNode || n.host || null;
253
+ }
254
+ return '';
255
+ }
256
+
257
+ function inPhraseList(byLang, normalized, lang) {
258
+ if (byLang.en.has(normalized)) return true;
259
+ return !!(lang && lang !== 'en' && byLang[lang] && byLang[lang].has(normalized));
260
+ }
261
+
136
262
  function ownDirectText(el) {
137
263
  let out = '';
138
264
  const kids = el.childNodes || [];
@@ -240,7 +366,7 @@ function runInPage(ctx) {
240
366
 
241
367
  applicableCount += 1;
242
368
 
243
- const isGeneric = GENERIC_LINK_TEXT.has(normalized);
369
+ const isGeneric = inPhraseList(GENERIC_LINK_TEXT, normalized, primaryLangOf(el));
244
370
  const isFormatName = !isGeneric && FORMAT_NAME_LINK_TEXT.has(normalized);
245
371
  if (!isGeneric && !isFormatName) continue;
246
372
 
@@ -13,6 +13,16 @@
13
13
  * @expectation If a strong transcript/text-alternative signal is present (e.g., aria-describedby binding to
14
14
  * a visible transcript block, or a nearby clearly labeled Transcript section/link), no occurrence is reported.
15
15
  * Otherwise, the rule reports cantTell (insufficient evidence) for that media element.
16
+ * @implementation-notes
17
+ * - An <audio> without `controls` is hidden by the browser's own stylesheet
18
+ * (`display: none`), not by the author, and it still plays. So the rule
19
+ * does not use queryAllSmart, whose hidden-content filter would drop it in
20
+ * a real browser. It queries <audio>/<video> directly (scope,
21
+ * excludeSelectors and open shadow roots honoured) and applies the
22
+ * eligibility check to the element itself, except for an <audio> without
23
+ * `controls`: there it applies the check to the parent (or shadow host)
24
+ * and to the element's own `hidden` and `aria-hidden="true"`, since its
25
+ * computed style cannot tell the browser's hiding from the author's.
16
26
  */
17
27
 
18
28
  const id = 'media-alternative-transcript-evidence';
@@ -48,14 +58,24 @@ function runInPage(ctx) {
48
58
  const { document, root, helpers, rule } = ctx;
49
59
  const safeRoot = root || document;
50
60
 
51
- // Conservative keyword set (deterministic). Includes common EN/FR terms.
52
- // Keep this list strict to avoid false positives.
61
+ // Conservative keyword set (deterministic), matched in every language at
62
+ // once: a Japanese page may well link an English transcript. Keep this
63
+ // list strict to avoid false positives.
53
64
  const TRANSCRIPT_TOKENS = [
54
65
  'transcript',
55
66
  'transcription',
56
67
  'texte intégral',
57
68
  'compte rendu',
58
- 'verbatim'
69
+ 'verbatim',
70
+ 'transkript',
71
+ 'transkription',
72
+ 'abschrift',
73
+ 'textfassung',
74
+ 'transcripción',
75
+ 'transcripcion',
76
+ 'トランスクリプト',
77
+ '文字起こし',
78
+ '書き起こし'
59
79
  ];
60
80
 
61
81
  // Minimum transcript body length to be considered "substantial" when used as evidence.
@@ -88,7 +108,7 @@ function runInPage(ctx) {
88
108
  try {
89
109
  if (!el) return '';
90
110
  return el.textContent || '';
91
- } catch (e) {
111
+ } catch {
92
112
  return '';
93
113
  }
94
114
  }
@@ -284,12 +304,49 @@ function runInPage(ctx) {
284
304
  const occurrences = [];
285
305
  let applicableCount = 0;
286
306
 
287
- const nodes = helpers.queryAllSmart
288
- ? helpers.queryAllSmart('audio,video')
289
- : helpers.queryAll('audio,video');
307
+ // Every match in scope, hidden or not (see @implementation-notes).
308
+ function queryAllUnfiltered(sel) {
309
+ const engineOptions = ctx.engineOptions || {};
310
+ const deep =
311
+ engineOptions.includeShadowDom !== false && typeof helpers.queryAllDeep === 'function';
312
+ const list = Array.from((deep ? helpers.queryAllDeep(sel) : helpers.queryAll(sel)) || []);
313
+ return typeof helpers.isExcluded === 'function'
314
+ ? list.filter((el) => !helpers.isExcluded(el))
315
+ : list;
316
+ }
317
+
318
+ function hiddenByBrowserStylesheet(el) {
319
+ return (
320
+ String(el.tagName || '').toLowerCase() === 'audio' &&
321
+ !(el.hasAttribute && el.hasAttribute('controls'))
322
+ );
323
+ }
324
+
325
+ // The eligibility that decides whether the media element is in scope.
326
+ function getMediaEligibility(el) {
327
+ if (!hiddenByBrowserStylesheet(el)) return getEligibility(el);
328
+ if (el.hasAttribute('hidden')) {
329
+ return { eligible: false, reasons: ['hiddenAttr'], targetSet: 'acc', accEligible: false };
330
+ }
331
+ if (
332
+ String(el.getAttribute('aria-hidden') || '')
333
+ .trim()
334
+ .toLowerCase() === 'true'
335
+ ) {
336
+ return { eligible: false, reasons: ['ariaHidden'], targetSet: 'acc', accEligible: false };
337
+ }
338
+ let parent = el.parentElement;
339
+ if (!parent) {
340
+ const rootNode = el.getRootNode ? el.getRootNode() : null;
341
+ parent = rootNode && rootNode.host ? rootNode.host : null;
342
+ }
343
+ return parent ? getEligibility(parent) : getEligibility(el);
344
+ }
345
+
346
+ const nodes = queryAllUnfiltered('audio,video');
290
347
 
291
348
  for (const el of nodes) {
292
- const eligInfo = getEligibility(el);
349
+ const eligInfo = getMediaEligibility(el);
293
350
  if (!eligInfo || !eligInfo.eligible) continue;
294
351
 
295
352
  applicableCount += 1;