@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
@@ -27,6 +27,11 @@
27
27
  * billing), and (c) is the whole of the field's programmatic label, not
28
28
  * the visible fragment of a label whose descriptive part is hidden.
29
29
  * @implementation-notes
30
+ * - Phrase lists exist for en, de, es, fr and ja. English is always
31
+ * checked; the list for the element's own language (nearest lang
32
+ * attribute, across shadow roots) is added on top. Matching every list
33
+ * everywhere would flag words that are generic in one language and a
34
+ * real name in another ("Suite", "Plus" on an English page).
30
35
  * - Authored as `type: 'manual'` (cantTell-capped, never fail). Whether a
31
36
  * label describes its field is a reading judgment: ACT cc0f0a fails
32
37
  * `<label>Menu<input type="text" name="fname"></label>` on the meaning
@@ -66,7 +71,7 @@ const id = 'form-control-label-quality';
66
71
  const meta = {
67
72
  title: 'Form field labels should be descriptive and distinguishable',
68
73
  description:
69
- 'Flags a visible form-field label that is a placeholder ("Label", "Field"), or that repeats another field\'s label with no visible context (heading, legend, or row) telling the two apart.',
74
+ 'Flags a visible form-field label that is a placeholder ("Label", "Field"), or that repeats another field\'s label with no visible context (heading, legend, or row) telling the two apart. English placeholders are always recognized, and German, Spanish, French or Japanese ones when the field is in that language.',
70
75
  i18n: {
71
76
  titleKey: 'formControlLabelQuality_title',
72
77
  descriptionKey: 'formControlLabelQuality_description'
@@ -95,7 +100,7 @@ function runInPage(ctx) {
95
100
 
96
101
  // Declared inside runInPage; see scripts/build-core.js header
97
102
  // ("runInPage MUST be self-contained").
98
- const PLACEHOLDER_LABEL_TEXT = new Set([
103
+ const PLACEHOLDER_LABEL_TEXT_EN = new Set([
99
104
  'label',
100
105
  'field',
101
106
  'form field',
@@ -117,6 +122,79 @@ function runInPage(ctx) {
117
122
  'default'
118
123
  ]);
119
124
 
125
+ const PLACEHOLDER_LABEL_TEXT = {
126
+ en: PLACEHOLDER_LABEL_TEXT_EN,
127
+ de: new Set([
128
+ 'beschriftung',
129
+ 'label',
130
+ 'feld',
131
+ 'formularfeld',
132
+ 'eingabe',
133
+ 'eingabefeld',
134
+ 'text',
135
+ 'textfeld',
136
+ 'text eingeben',
137
+ 'hier eingeben',
138
+ 'wert',
139
+ 'platzhalter',
140
+ 'unbenannt',
141
+ 'test',
142
+ 'beispiel',
143
+ 'standard'
144
+ ]),
145
+ es: new Set([
146
+ 'etiqueta',
147
+ 'campo',
148
+ 'campo de formulario',
149
+ 'entrada',
150
+ 'campo de entrada',
151
+ 'texto',
152
+ 'campo de texto',
153
+ 'introduzca texto',
154
+ 'escriba aquí',
155
+ 'valor',
156
+ 'marcador de posición',
157
+ 'sin título',
158
+ 'prueba',
159
+ 'ejemplo',
160
+ 'predeterminado'
161
+ ]),
162
+ fr: new Set([
163
+ 'libellé',
164
+ 'étiquette',
165
+ 'champ',
166
+ 'champ de formulaire',
167
+ 'saisie',
168
+ 'champ de saisie',
169
+ 'texte',
170
+ 'champ de texte',
171
+ 'saisir du texte',
172
+ 'saisissez ici',
173
+ 'valeur',
174
+ 'espace réservé',
175
+ 'sans titre',
176
+ 'test',
177
+ 'exemple',
178
+ 'par défaut'
179
+ ]),
180
+ ja: new Set([
181
+ 'ラベル',
182
+ 'フィールド',
183
+ '入力',
184
+ '入力欄',
185
+ '入力フィールド',
186
+ 'テキスト',
187
+ 'テキストフィールド',
188
+ 'ここに入力',
189
+ '値',
190
+ 'プレースホルダー',
191
+ '無題',
192
+ '未定',
193
+ 'テスト',
194
+ 'サンプル'
195
+ ])
196
+ };
197
+
120
198
  const FIELD_SELECTOR = [
121
199
  'input:not([type="hidden"]):not([type="submit"]):not([type="reset"]):not([type="button"]):not([type="image"])',
122
200
  'select',
@@ -143,13 +221,35 @@ function runInPage(ctx) {
143
221
  .trim();
144
222
  }
145
223
 
224
+ // NFKC folds full-width forms (「入力:」 ends in a full-width colon).
146
225
  function normalize(s) {
147
- return normalizeWs(s)
226
+ return normalizeWs(String(s || '').normalize('NFKC'))
148
227
  .toLowerCase()
149
- .replace(/[.,;:!?*]+$/g, '')
228
+ .replace(/[.,;:!?*。、]+$/g, '')
150
229
  .trim();
151
230
  }
152
231
 
232
+ // Primary language subtag of the nearest lang attribute, crossing shadow
233
+ // roots; '' when none is declared. Phrase lists are matched in English
234
+ // plus this language, so a word that is generic in one language ("plus"
235
+ // in French) is not flagged when it is a real name in another.
236
+ function primaryLangOf(node) {
237
+ let n = node;
238
+ while (n) {
239
+ if (n.nodeType === 1 && n.getAttribute) {
240
+ const v = n.getAttribute('lang');
241
+ if (v != null) return v.trim().split('-')[0].toLowerCase();
242
+ }
243
+ n = n.parentNode || n.host || null;
244
+ }
245
+ return '';
246
+ }
247
+
248
+ function inPhraseList(byLang, normalized, lang) {
249
+ if (byLang.en.has(normalized)) return true;
250
+ return !!(lang && lang !== 'en' && byLang[lang] && byLang[lang].has(normalized));
251
+ }
252
+
153
253
  const isDomVisibleEligible =
154
254
  helpers && typeof helpers.isDomVisibleEligible === 'function'
155
255
  ? helpers.isDomVisibleEligible
@@ -206,29 +306,63 @@ function runInPage(ctx) {
206
306
  return out;
207
307
  }
208
308
 
309
+ // Index of `<label for="...">` elements by their `for` value, built once
310
+ // (not the native `el.labels`, deliberately -- see getNativeLabels).
311
+ const labelsByForId = new Map();
312
+ try {
313
+ for (const label of document.querySelectorAll('label[for]')) {
314
+ const forVal = normalizeWs(label.getAttribute('for'));
315
+ if (!forVal) continue;
316
+ const bucket = labelsByForId.get(forVal);
317
+ if (bucket) bucket.push(label);
318
+ else labelsByForId.set(forVal, [label]);
319
+ }
320
+ } catch {
321
+ // labelsByForId stays empty; getNativeLabels still has the wrapping-label check
322
+ }
323
+
324
+ // Per HTML's label-control algorithm, a wrapping <label> with no `for`
325
+ // attribute is associated with its own FIRST labelable descendant only --
326
+ // these are exactly the tags that count (input excluding type=hidden,
327
+ // which FIELD_SELECTOR already excludes from `el` itself, but a wrapping
328
+ // label could still wrap a hidden input ahead of the real field).
329
+ const LABELABLE_SELECTOR =
330
+ 'input:not([type="hidden"]), select, textarea, button, meter, output, progress';
331
+
332
+ // The native `el.labels` accessor is spec-correct but, in this engine's
333
+ // supported Node/jsdom runtime (see tests/node-runtime-parity.test.js),
334
+ // jsdom implements it as a live query that walks the WHOLE document on
335
+ // every access, and for every `<label for>` it passes, calls `.control`
336
+ // -- itself another whole-document walk to resolve that id (jsdom's
337
+ // form-controls.js getLabelsForLabelable / HTMLLabelElement-impl.js
338
+ // `get control`). Called once per field, that's the O(fields * document
339
+ // size) cost that used to dominate this rule under jsdom (a real browser
340
+ // maintains an internal id index, so this cost is jsdom-specific, but
341
+ // jsdom is a real, tested runtime for this engine, not just a benchmark
342
+ // artifact). A `for`-attribute index built once above, plus a bounded
343
+ // `closest('label')` walk, answers the same question in O(1) amortized
344
+ // per field instead.
209
345
  function getNativeLabels(el) {
210
346
  const labels = [];
211
- try {
212
- if (el.labels && el.labels.length) {
213
- for (const label of el.labels) labels.push(label);
214
- return labels;
215
- }
216
- } catch {
217
- // fall through to the manual lookup
218
- }
219
347
  const idVal = normalizeWs(el.getAttribute && el.getAttribute('id'));
220
348
  if (idVal) {
221
- try {
222
- for (const label of document.querySelectorAll('label[for]')) {
223
- if (normalizeWs(label.getAttribute('for')) === idVal) labels.push(label);
224
- }
225
- } catch {
226
- // ignore
349
+ const forLabels = labelsByForId.get(idVal);
350
+ if (forLabels) {
351
+ for (const label of forLabels) labels.push(label);
227
352
  }
228
353
  }
229
354
  try {
230
355
  const wrapping = el.closest ? el.closest('label') : null;
231
- if (wrapping && labels.indexOf(wrapping) === -1) labels.push(wrapping);
356
+ const hasForAttr = !!(wrapping && wrapping.hasAttribute && wrapping.hasAttribute('for'));
357
+ if (wrapping && !hasForAttr && labels.indexOf(wrapping) === -1) {
358
+ let firstControl = null;
359
+ try {
360
+ firstControl = wrapping.querySelector ? wrapping.querySelector(LABELABLE_SELECTOR) : null;
361
+ } catch {
362
+ firstControl = null;
363
+ }
364
+ if (firstControl === el) labels.push(wrapping);
365
+ }
232
366
  } catch {
233
367
  // ignore
234
368
  }
@@ -269,15 +403,17 @@ function runInPage(ctx) {
269
403
  }
270
404
  }
271
405
 
406
+ // Nearest preceding *visible* heading text, per field. Precomputed below
407
+ // (once `fields` is built) via a single document-order sweep rather than
408
+ // scanning the full `headings` array backward for every field: `headings`
409
+ // and `fields` are each already in document order (both come from
410
+ // querySelectorAll/queryAllSmart), so a two-pointer merge answers every
411
+ // field in one O(fields + headings) pass instead of the O(fields *
412
+ // headings) pairwise compareDocumentPosition/isVisible calls a per-field
413
+ // backward scan requires -- the dominant cost on heading/form-heavy pages.
414
+ const nearestVisibleHeadingByField = new Map();
272
415
  function nearestVisibleHeadingText(el) {
273
- for (let i = headings.length - 1; i >= 0; i--) {
274
- const heading = headings[i];
275
- if (!precedes(heading, el)) continue;
276
- if (!isVisible(heading)) continue;
277
- const text = normalizeWs(heading.textContent);
278
- if (text) return text;
279
- }
280
- return '';
416
+ return nearestVisibleHeadingByField.get(el) || '';
281
417
  }
282
418
 
283
419
  function fieldsetLegendText(el) {
@@ -309,6 +445,9 @@ function runInPage(ctx) {
309
445
 
310
446
  // A table row or list item carries its own context (the product name a
311
447
  // repeated "Quantity" field belongs to), so it takes part in the key.
448
+ // `row.textContent` serializes the whole row subtree, so it's memoized per
449
+ // row element -- several fields (one per column) commonly share a row.
450
+ const rowTextCache = new Map();
312
451
  function rowContextText(el, labelText) {
313
452
  let row;
314
453
  try {
@@ -317,7 +456,11 @@ function runInPage(ctx) {
317
456
  row = null;
318
457
  }
319
458
  if (!row || !isVisible(row)) return '';
320
- const text = normalizeWs(row.textContent);
459
+ let text = rowTextCache.get(row);
460
+ if (text === undefined) {
461
+ text = normalizeWs(row.textContent);
462
+ rowTextCache.set(row, text);
463
+ }
321
464
  if (!text) return '';
322
465
  return normalizeWs(text.split(labelText).join(' '));
323
466
  }
@@ -348,6 +491,73 @@ function runInPage(ctx) {
348
491
  });
349
492
  }
350
493
 
494
+ // Populate nearestVisibleHeadingByField (declared above nearestVisibleHeadingText)
495
+ // via a single two-pointer sweep instead of, per field, scanning the full
496
+ // `headings` array backward and calling compareDocumentPosition/isVisible
497
+ // against every one of them -- the O(fields * headings) cost that used to
498
+ // dominate this rule (and the whole engine) on heading/form-heavy pages.
499
+ //
500
+ // Two correctness precautions this needs, since a two-pointer merge only
501
+ // works over sequences that are BOTH already in real document order:
502
+ //
503
+ // 1. A field inside a shadow root has no document-order relationship to
504
+ // any heading at all: per the DOM spec, compareDocumentPosition
505
+ // between nodes in different trees returns an implementation-specific
506
+ // (not document-order-derived) PRECEDING/FOLLOWING bit. Merging it in
507
+ // would be meaningless and could even desync the sweep for later
508
+ // fields, so it's filtered out up front and always answered '' --
509
+ // matching the "no light-DOM heading can be this field's visible
510
+ // context" reading rather than trusting that arbitrary bit.
511
+ // 2. `fields` (from queryAllSmart) is NOT guaranteed to be globally
512
+ // document-order: it's built root by root (see resolveContextRoots),
513
+ // and a multi-region `engineOptions.contextSelector` array is resolved
514
+ // in the CALLER's array order, not sorted by document position. A
515
+ // fresh copy is sorted by real position before the sweep so the merge
516
+ // is correct regardless of contextSelector's region order (this is a
517
+ // cheap O(k log k) sort, not the O(n*m) cost being fixed).
518
+ {
519
+ const orderedFields = [];
520
+ for (const field of fields) {
521
+ let sameRoot;
522
+ try {
523
+ sameRoot =
524
+ typeof field.el.getRootNode !== 'function' || field.el.getRootNode() === document;
525
+ } catch {
526
+ sameRoot = true;
527
+ }
528
+
529
+ if (!sameRoot) {
530
+ nearestVisibleHeadingByField.set(field.el, '');
531
+ continue;
532
+ }
533
+ orderedFields.push(field);
534
+ }
535
+
536
+ orderedFields.sort((a, b) => {
537
+ try {
538
+ const bits = a.el.compareDocumentPosition(b.el);
539
+ if (bits & 4) return -1; // b follows a
540
+ if (bits & 2) return 1; // b precedes a
541
+ } catch {
542
+ /* ignore -- treat as equal/unordered */
543
+ }
544
+ return 0;
545
+ });
546
+
547
+ let hIdx = 0;
548
+ let current = '';
549
+ for (const field of orderedFields) {
550
+ while (hIdx < headings.length && precedes(headings[hIdx], field.el)) {
551
+ const heading = headings[hIdx];
552
+ hIdx += 1;
553
+ if (!isVisible(heading)) continue;
554
+ const text = normalizeWs(heading.textContent);
555
+ if (text) current = text;
556
+ }
557
+ nearestVisibleHeadingByField.set(field.el, current);
558
+ }
559
+ }
560
+
351
561
  if (!fields.length) {
352
562
  return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
353
563
  }
@@ -365,7 +575,11 @@ function runInPage(ctx) {
365
575
  const occurrences = [];
366
576
 
367
577
  for (const field of fields) {
368
- const isPlaceholder = PLACEHOLDER_LABEL_TEXT.has(field.normalized);
578
+ const isPlaceholder = inPhraseList(
579
+ PLACEHOLDER_LABEL_TEXT,
580
+ field.normalized,
581
+ primaryLangOf(field.el)
582
+ );
369
583
  const shared = byKey.get(field.key) || [];
370
584
  const isDuplicate = shared.length > 1;
371
585
  const isPartiallyHidden = field.hiddenParts > 0;
@@ -11,6 +11,8 @@
11
11
  * Applies whenever the page contains two or more heading elements
12
12
  * (native <h1>-<h6>, or explicit role="heading" with aria-level;
13
13
  * default level 2 per the ARIA spec when aria-level is absent/invalid).
14
+ * A native <hx> with a valid aria-level (a positive integer) takes that
15
+ * level, as browsers expose it; otherwise it takes its tag level.
14
16
  * @expectation
15
17
  * In document order, each heading's level is no more than one greater
16
18
  * than the highest heading level seen so far. Jumping deeper by more
@@ -73,7 +75,13 @@ function runInPage(ctx) {
73
75
  }
74
76
  const tag = el.tagName ? el.tagName.toLowerCase() : '';
75
77
  const m = /^h([1-6])$/.exec(tag);
76
- return m ? parseInt(m[1], 10) : 0;
78
+ if (!m) return 0;
79
+ // Browsers expose a valid aria-level on <hx> in place of the tag level.
80
+ const ariaLevel = normalizeWs(el.getAttribute && el.getAttribute('aria-level'));
81
+ if (/^[0-9]+$/.test(ariaLevel) && parseInt(ariaLevel, 10) >= 1) {
82
+ return parseInt(ariaLevel, 10);
83
+ }
84
+ return parseInt(m[1], 10);
77
85
  }
78
86
 
79
87
  const nodes = helpers.queryAllSmart
@@ -21,6 +21,11 @@
21
21
  * 2", "Section 3"), a filename, or a URL. None of these describe the
22
22
  * topic or purpose of the content they introduce.
23
23
  * @implementation-notes
24
+ * - Phrase lists exist for en, de, es, fr and ja. English is always
25
+ * checked; the list for the element's own language (nearest lang
26
+ * attribute, across shadow roots) is added on top. Matching every list
27
+ * everywhere would flag words that are generic in one language and a
28
+ * real name in another ("Suite", "Plus" on an English page).
24
29
  * - Authored as `type: 'manual'` (cantTell-capped, never fail), for the
25
30
  * same reason `link-name-quality` is: whether a heading describes the
26
31
  * content that follows it is a reading-comprehension judgment. What is
@@ -52,7 +57,7 @@ const id = 'heading-quality';
52
57
  const meta = {
53
58
  title: 'Heading text should be descriptive, not a placeholder',
54
59
  description:
55
- 'Flags headings whose accessible name is a placeholder rather than a description of the content that follows: a generic word ("Heading", "Untitled"), a numbered template slot ("Section 2"), a filename, or a URL.',
60
+ 'Flags headings whose accessible name is a placeholder rather than a description of the content that follows: a generic word ("Heading", "Untitled"), a numbered template slot ("Section 2"), a filename, or a URL. English phrases are always recognized, and German, Spanish, French or Japanese ones when the heading is in that language.',
56
61
  i18n: {
57
62
  titleKey: 'headingQuality_title',
58
63
  descriptionKey: 'headingQuality_description'
@@ -81,7 +86,7 @@ function runInPage(ctx) {
81
86
 
82
87
  // Declared inside runInPage; see scripts/build-core.js header
83
88
  // ("runInPage MUST be self-contained").
84
- const PLACEHOLDER_HEADING_TEXT = new Set([
89
+ const PLACEHOLDER_HEADING_TEXT_EN = new Set([
85
90
  'heading',
86
91
  'header',
87
92
  'headline',
@@ -118,8 +123,101 @@ function runInPage(ctx) {
118
123
  // A numbered template slot left as authored: "Heading 2", "Section 3",
119
124
  // "Chapter #1". The word alone is already in the set above; this catches
120
125
  // the same words carrying an index.
121
- const NUMBERED_PLACEHOLDER =
122
- /^(heading|header|headline|subheading|title|subtitle|section|chapter|part|step)\s*[-#:.]?\s*\d+$/;
126
+ // "Inhalt" and "Contenido" are left out: they usually head a table of
127
+ // contents, which is a real heading.
128
+ const PLACEHOLDER_HEADING_TEXT = {
129
+ en: PLACEHOLDER_HEADING_TEXT_EN,
130
+ de: new Set([
131
+ 'überschrift',
132
+ 'zwischenüberschrift',
133
+ 'titel',
134
+ 'untertitel',
135
+ 'unbenannt',
136
+ 'ohne titel',
137
+ 'abschnitt',
138
+ 'neuer abschnitt',
139
+ 'kapitel',
140
+ 'hauptinhalt',
141
+ 'text',
142
+ 'beispieltext',
143
+ 'blindtext',
144
+ 'platzhalter',
145
+ 'platzhaltertext',
146
+ 'titel hier eingeben',
147
+ 'test',
148
+ 'beispiel',
149
+ 'standard'
150
+ ]),
151
+ es: new Set([
152
+ 'encabezado',
153
+ 'título',
154
+ 'subtítulo',
155
+ 'sin título',
156
+ 'sección',
157
+ 'nueva sección',
158
+ 'capítulo',
159
+ 'contenido principal',
160
+ 'texto',
161
+ 'texto de ejemplo',
162
+ 'marcador de posición',
163
+ 'escriba el título aquí',
164
+ 'su título aquí',
165
+ 'por definir',
166
+ 'prueba',
167
+ 'ejemplo',
168
+ 'predeterminado'
169
+ ]),
170
+ fr: new Set([
171
+ 'titre',
172
+ 'sous-titre',
173
+ 'sans titre',
174
+ 'section',
175
+ 'nouvelle section',
176
+ 'chapitre',
177
+ 'contenu',
178
+ 'contenu principal',
179
+ 'texte',
180
+ "texte d'exemple",
181
+ 'espace réservé',
182
+ 'insérer le titre ici',
183
+ 'votre titre ici',
184
+ 'à définir',
185
+ 'test',
186
+ 'exemple',
187
+ 'par défaut'
188
+ ]),
189
+ ja: new Set([
190
+ '見出し',
191
+ '小見出し',
192
+ 'タイトル',
193
+ 'サブタイトル',
194
+ '無題',
195
+ 'セクション',
196
+ '新しいセクション',
197
+ '章',
198
+ 'コンテンツ',
199
+ 'メインコンテンツ',
200
+ 'テキスト',
201
+ 'サンプルテキスト',
202
+ 'ダミーテキスト',
203
+ 'プレースホルダー',
204
+ 'ここにタイトルを入力',
205
+ 'タイトルを入力',
206
+ '未定',
207
+ 'テスト',
208
+ 'サンプル'
209
+ ])
210
+ };
211
+
212
+ // A numbered template slot left as authored: "Heading 2", "Section 3",
213
+ // "Chapter #1", 「見出し 2」, 「第 1 章」.
214
+ const NUMBERED_PLACEHOLDER = {
215
+ en: /^(heading|header|headline|subheading|title|subtitle|section|chapter|part|step)\s*[-#:.]?\s*\d+$/,
216
+ de: /^(überschrift|titel|abschnitt|kapitel|teil|schritt)\s*[-#:.]?\s*\d+$/,
217
+ es: /^(encabezado|título|sección|capítulo|parte|paso)\s*[-#:.]?\s*\d+$/,
218
+ fr: /^(titre|section|chapitre|partie|étape)\s*[-#:.]?\s*\d+$/,
219
+ ja: /^(?:(見出し|タイトル|セクション|章|ステップ|パート)\s*[-#:.]?\s*\d+|第\s*\d+\s*章)$/
220
+ };
123
221
 
124
222
  const FILENAME_LIKE =
125
223
  /^[\w\s\-.,()[\]]+\.(png|jpe?g|gif|svg|webp|avif|bmp|ico|tiff?|pdf|docx?|xlsx?|pptx?|html?|txt|csv|zip)$/;
@@ -132,13 +230,36 @@ function runInPage(ctx) {
132
230
  .trim();
133
231
  }
134
232
 
233
+ // NFKC folds full-width letters and digits (「見出し2」) into ASCII forms.
135
234
  function normalize(s) {
136
- return normalizeWs(s)
235
+ return normalizeWs(String(s || '').normalize('NFKC'))
236
+ .replace(/[\u2018\u2019]/g, "'")
137
237
  .toLowerCase()
138
- .replace(/[.,;:!?]+$/g, '')
238
+ .replace(/[.,;:!?。、]+$/g, '')
139
239
  .trim();
140
240
  }
141
241
 
242
+ // Primary language subtag of the nearest lang attribute, crossing shadow
243
+ // roots; '' when none is declared. Phrase lists are matched in English
244
+ // plus this language, so a word that is generic in one language ("plus"
245
+ // in French) is not flagged when it is a real name in another.
246
+ function primaryLangOf(node) {
247
+ let n = node;
248
+ while (n) {
249
+ if (n.nodeType === 1 && n.getAttribute) {
250
+ const v = n.getAttribute('lang');
251
+ if (v != null) return v.trim().split('-')[0].toLowerCase();
252
+ }
253
+ n = n.parentNode || n.host || null;
254
+ }
255
+ return '';
256
+ }
257
+
258
+ function inPhraseList(byLang, normalized, lang) {
259
+ if (byLang.en.has(normalized)) return true;
260
+ return !!(lang && lang !== 'en' && byLang[lang] && byLang[lang].has(normalized));
261
+ }
262
+
142
263
  function getExplicitRoleToken(el) {
143
264
  const raw = normalizeWs(el.getAttribute && el.getAttribute('role'));
144
265
  if (!raw) return '';
@@ -248,8 +369,21 @@ function runInPage(ctx) {
248
369
  }
249
370
  }
250
371
 
251
- function classify(normalized) {
252
- if (PLACEHOLDER_HEADING_TEXT.has(normalized) || NUMBERED_PLACEHOLDER.test(normalized)) {
372
+ function isNumberedPlaceholder(normalized, lang) {
373
+ if (NUMBERED_PLACEHOLDER.en.test(normalized)) return true;
374
+ return !!(
375
+ lang &&
376
+ lang !== 'en' &&
377
+ NUMBERED_PLACEHOLDER[lang] &&
378
+ NUMBERED_PLACEHOLDER[lang].test(normalized)
379
+ );
380
+ }
381
+
382
+ function classify(normalized, lang) {
383
+ if (
384
+ inPhraseList(PLACEHOLDER_HEADING_TEXT, normalized, lang) ||
385
+ isNumberedPlaceholder(normalized, lang)
386
+ ) {
253
387
  return 'PLACEHOLDER_HEADING_TEXT';
254
388
  }
255
389
  if (URL_LIKE.test(normalized)) return 'URL_LIKE_HEADING';
@@ -282,7 +416,7 @@ function runInPage(ctx) {
282
416
 
283
417
  applicableCount += 1;
284
418
 
285
- const reasonCode = classify(normalized);
419
+ const reasonCode = classify(normalized, primaryLangOf(el));
286
420
  if (!reasonCode) continue;
287
421
 
288
422
  const eligInfo = helpers.getEligibilityInfo
@@ -14,7 +14,8 @@
14
14
  * accessibility tree by any of: an aria-hidden ancestor-or-self, an
15
15
  * explicit role="none"/"presentation" not overridden by focusability, an
16
16
  * <img alt=""> (the native decorative marker, same focusability
17
- * override), an unlabeled <svg> whose implicit role is graphics-document
17
+ * override; only a literally empty alt, so alt=" " is not one), an
18
+ * unlabeled <svg> whose implicit role is graphics-document
18
19
  * (no img/graphics-symbol role restatement, aria-name, <title>/<desc>, or
19
20
  * focusability), or an unlabeled <canvas> with no explicit role at all.
20
21
  * Per ACT e88epe, an element is skipped entirely when any ancestor
@@ -198,12 +199,14 @@ function runInPage(ctx) {
198
199
  // Presentational exclusion: explicit role="none"/"presentation", or (img
199
200
  // only) the native alt="" marker. Both are overridden by focusability, per
200
201
  // ARIA conflict resolution (a focusable element is never presentational).
202
+ // Only a literally empty alt is the marker: HTML-AAM maps an img to
203
+ // presentation only for alt="", so alt=" " keeps the img role with an
204
+ // empty name, which img-alt-present fails.
201
205
  function isPresentationallyExcluded(el, tag) {
202
206
  const role = getExplicitRole(el);
203
207
  let presentational = role === 'presentation' || role === 'none';
204
208
  if (!presentational && tag === 'img') {
205
- const alt = el.getAttribute('alt');
206
- presentational = alt != null && trim(alt) === '';
209
+ presentational = el.getAttribute('alt') === '';
207
210
  }
208
211
  if (!presentational) return false;
209
212
  return !isFocusable(el);