@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
@@ -20,9 +20,14 @@
20
20
  * event equivalents), or `onfocus`/`onblur` (the standard substitute
21
21
  * for hover-triggered behavior: focus/blur are the keyboard-
22
22
  * navigable analog to mouseover/mouseout, per WCAG technique G90).
23
- * Otherwise the element's mouse-driven behavior (a hover tooltip, a
24
- * custom dropdown, a drag interaction) has no way to be triggered by a
25
- * keyboard-only user.
23
+ * A handler is reachable only if keyboard events can reach it:
24
+ * `onfocus`/`onblur` when the element itself can take focus, and a key
25
+ * handler when the element or one of its descendants can (key events
26
+ * bubble, focus events do not). Otherwise the element's mouse-driven
27
+ * behavior (a hover tooltip, a custom dropdown, a drag interaction) has
28
+ * no way to be triggered by a keyboard-only user, and it is flagged with
29
+ * a reason saying whether the keyboard handlers are missing or cannot
30
+ * run.
26
31
  * @implementation-notes
27
32
  * - Authored as `type: 'manual'` (cantTell-capped, never fail), not
28
33
  * `automatic`: this can only see inline `on*="..."` HTML attributes.
@@ -93,6 +98,35 @@ function runInPage(ctx) {
93
98
  return (v == null ? '' : String(v)).trim();
94
99
  }
95
100
 
101
+ const FOCUS_ATTRS = ['onfocus', 'onblur'];
102
+ const FOCUSABLE_CANDIDATES =
103
+ 'a[href], area[href], button, input, select, textarea, summary, iframe, [tabindex], [contenteditable]';
104
+
105
+ function canTakeFocus(el) {
106
+ if (!helpers.getFocusableInfo) return true;
107
+ try {
108
+ const info = helpers.getFocusableInfo(el, ctx);
109
+ return !!(info && info.focusable);
110
+ } catch {
111
+ return true;
112
+ }
113
+ }
114
+
115
+ // Focus and blur fire only on the element that takes focus. Key events are
116
+ // dispatched to the focused element and bubble, so a key handler also runs
117
+ // for a focusable descendant.
118
+ function keyboardCanReach(el, keyboardAttrs) {
119
+ if (canTakeFocus(el)) return true;
120
+ if (keyboardAttrs.every((a) => FOCUS_ATTRS.indexOf(a) !== -1)) return false;
121
+ let descendants;
122
+ try {
123
+ descendants = Array.from(el.querySelectorAll(FOCUSABLE_CANDIDATES));
124
+ } catch {
125
+ return true;
126
+ }
127
+ return descendants.some((d) => canTakeFocus(d));
128
+ }
129
+
96
130
  const selector = MOUSE_ONLY_ATTRS.map((a) => `[${a}]`).join(', ');
97
131
  const nodes = helpers.queryAllSmart
98
132
  ? helpers.queryAllSmart(selector)
@@ -114,13 +148,40 @@ function runInPage(ctx) {
114
148
 
115
149
  applicableCount += 1;
116
150
 
117
- const hasKeyboardEquiv = KEYBOARD_EQUIV_ATTRS.some((a) => trim(el.getAttribute(a)));
118
- if (hasKeyboardEquiv) continue;
151
+ const presentKeyboardAttrs = KEYBOARD_EQUIV_ATTRS.filter((a) => trim(el.getAttribute(a)));
152
+ if (presentKeyboardAttrs.length && keyboardCanReach(el, presentKeyboardAttrs)) continue;
153
+ const unreachable = presentKeyboardAttrs.length > 0;
119
154
 
120
155
  const eligInfo = helpers.getEligibilityInfo
121
156
  ? helpers.getEligibilityInfo(el, ctx, { targetSet: 'acc' })
122
157
  : null;
123
158
 
159
+ if (unreachable) {
160
+ occurrences.push(
161
+ helpers.reportOccurrence(el, {
162
+ summary: `This element has ${presentMouseAttrs.join(', ')} and ${presentKeyboardAttrs.join(', ')}, but it cannot take keyboard focus, so the keyboard handlers never run.`,
163
+ hint: 'Make the element focusable (use a native control, or add tabindex="0"), or move the handlers to a focusable element, so this functionality is also reachable by keyboard.',
164
+ i18n: {
165
+ summaryKey: 'mouseOnlyEventHandlers_summary_cantTell_notFocusable',
166
+ hintKey: 'mouseOnlyEventHandlers_hint_cantTell_notFocusable',
167
+ params: {
168
+ attrs: presentMouseAttrs.join(', '),
169
+ keyboardAttrs: presentKeyboardAttrs.join(', ')
170
+ }
171
+ },
172
+ data: {
173
+ details: {
174
+ reasonCode: 'MOUSE_ONLY_HANDLER_KEYBOARD_EQUIVALENT_NOT_FOCUSABLE',
175
+ mouseAttrs: presentMouseAttrs,
176
+ keyboardAttrs: presentKeyboardAttrs
177
+ },
178
+ visibilityFilter: eligInfo || { targetSet: 'acc', accEligible: null, reasons: [] }
179
+ }
180
+ })
181
+ );
182
+ continue;
183
+ }
184
+
124
185
  occurrences.push(
125
186
  helpers.reportOccurrence(el, {
126
187
  summary: `This element has ${presentMouseAttrs.join(', ')} but no keyboard-reachable equivalent handler.`,
@@ -5,11 +5,16 @@
5
5
  /**
6
6
  * @check no-autoplay-audio
7
7
  * @atomic true
8
- * @summary Autoplaying, unmuted <audio>/<video> should provide a pause/stop or volume-control mechanism
8
+ * @summary Sound that plays automatically should have a pause/stop or volume-control mechanism
9
9
  * @standard WCAG 2.2
10
10
  * @sc 1.4.2
11
11
  * @applicability
12
12
  * Any <audio autoplay> or <video autoplay> element that is not `muted`.
13
+ * Also any <bgsound>, and any <embed> or <object> that loads sound or
14
+ * video, or a plugin (Flash) that may play it: its `type` is audio/*,
15
+ * video/* or a plugin type, or its `src`/`data` ends in a sound or video
16
+ * file extension. An <embed> or <object> with `autostart` or `autoplay`
17
+ * set to false (attribute or <param>) is left out.
13
18
  * @expectation
14
19
  * SC 1.4.2 only applies when audio plays automatically for MORE than 3
15
20
  * seconds; clip duration is not knowable from static markup (jsdom does
@@ -27,13 +32,22 @@
27
32
  * - Elements with `muted` present are not flagged: muted playback is not
28
33
  * audible, so the SC's condition ("plays automatically... audio")
29
34
  * does not apply.
35
+ * - <embed>, <object> and <bgsound> have no `controls` or `muted` to
36
+ * read, so each one found is asked about. <bgsound> is obsolete
37
+ * and current browsers ignore it, but it still plays in older ones.
38
+ * - Sound started by a script cannot be detected.
30
39
  * - Custom (JS-built) controls that don't use the native `controls`
31
40
  * attribute cannot be detected statically. That's a documented limitation,
32
41
  * same class as `iframe-focusable-content`'s `contentDocument` gap.
33
42
  * - Not gated on `isAccTreeEligible`: unlike most rules, a `display:none`
34
43
  * or `aria-hidden` audio/video element still plays audible sound in a
35
44
  * real browser, so visual/AT-tree eligibility is not a relevant filter
36
- * here.
45
+ * here. For the same reason the rule does not use queryAllSmart, whose
46
+ * hidden-content filter would drop such elements: an <audio> without
47
+ * `controls` is always one, since browsers hide it with their own
48
+ * stylesheet (`display: none`). It queries the DOM directly (and open
49
+ * shadow roots, unless includeShadowDom is false), honouring only the
50
+ * scan scope and excludeSelectors.
37
51
  */
38
52
 
39
53
  const id = 'no-autoplay-audio';
@@ -41,7 +55,7 @@ const id = 'no-autoplay-audio';
41
55
  const meta = {
42
56
  title: 'Autoplaying audio should provide a pause/stop or volume-control mechanism',
43
57
  description:
44
- 'Flags <audio>/<video> elements that autoplay unmuted with no native controls attribute, for manual review against the 3-second exemption in WCAG 1.4.2.',
58
+ 'Flags <audio>/<video> elements that autoplay unmuted with no native controls attribute, and <embed>, <object> or <bgsound> elements that may play sound, for manual review against the 3-second exemption in WCAG 1.4.2.',
45
59
  i18n: {
46
60
  titleKey: 'noAutoplayAudio_title',
47
61
  descriptionKey: 'noAutoplayAudio_description'
@@ -68,9 +82,18 @@ const meta = {
68
82
  function runInPage(ctx) {
69
83
  const { helpers, rule } = ctx;
70
84
 
71
- const nodes = helpers.queryAllSmart
72
- ? helpers.queryAllSmart('audio[autoplay], video[autoplay]')
73
- : helpers.queryAll('audio[autoplay], video[autoplay]');
85
+ // Every match in scope, hidden or not (see @implementation-notes).
86
+ function queryAllUnfiltered(sel) {
87
+ const engineOptions = ctx.engineOptions || {};
88
+ const deep =
89
+ engineOptions.includeShadowDom !== false && typeof helpers.queryAllDeep === 'function';
90
+ const list = Array.from((deep ? helpers.queryAllDeep(sel) : helpers.queryAll(sel)) || []);
91
+ return typeof helpers.isExcluded === 'function'
92
+ ? list.filter((el) => !helpers.isExcluded(el))
93
+ : list;
94
+ }
95
+
96
+ const nodes = queryAllUnfiltered('audio[autoplay], video[autoplay]');
74
97
 
75
98
  const occurrences = [];
76
99
  let applicableCount = 0;
@@ -110,6 +133,70 @@ function runInPage(ctx) {
110
133
  }
111
134
  }
112
135
 
136
+ // <embed>, <object> and <bgsound>: no controls or muted attribute to read.
137
+ const MEDIA_EXT =
138
+ /\.(mp3|wav|wave|ogg|oga|opus|m4a|aac|flac|wma|mid|midi|mp4|m4v|webm|ogv|mov|avi|wmv|mpg|mpeg|swf)(?:[?#]|$)/i;
139
+ const PLUGIN_TYPES = /^(application\/x-shockwave-flash|application\/futuresplash)$/i;
140
+
141
+ function attr(el, name) {
142
+ return String(el.getAttribute(name) || '').trim();
143
+ }
144
+
145
+ function mayPlaySound(el, urlAttr) {
146
+ const type = attr(el, 'type').toLowerCase().split(';')[0].trim();
147
+ if (type) return /^(audio|video)\//.test(type) || PLUGIN_TYPES.test(type);
148
+ return MEDIA_EXT.test(attr(el, urlAttr));
149
+ }
150
+
151
+ function startsDisabled(el) {
152
+ const isOff = (v) => /^(false|0|no)$/i.test(String(v || '').trim());
153
+ if (isOff(el.getAttribute('autostart')) || isOff(el.getAttribute('autoplay'))) return true;
154
+ return Array.from(el.children || []).some((c) => {
155
+ if ((c.tagName || '').toLowerCase() !== 'param') return false;
156
+ const name = attr(c, 'name').toLowerCase();
157
+ return (
158
+ (name === 'autostart' || name === 'autoplay' || name === 'play') &&
159
+ isOff(c.getAttribute('value'))
160
+ );
161
+ });
162
+ }
163
+
164
+ // The fallback inside an <object> already asked about is the same sound.
165
+ const askedObjects = [];
166
+
167
+ for (const el of queryAllUnfiltered('embed, object, bgsound')) {
168
+ if (!el || !el.getAttribute) continue;
169
+ if (askedObjects.some((o) => o !== el && o.contains(el))) continue;
170
+ const tag = (el.tagName || '').toLowerCase();
171
+ if (tag === 'embed' && !mayPlaySound(el, 'src')) continue;
172
+ if (tag === 'object' && !mayPlaySound(el, 'data')) continue;
173
+ if (tag !== 'bgsound' && startsDisabled(el)) continue;
174
+
175
+ applicableCount += 1;
176
+ if (tag === 'object') askedObjects.push(el);
177
+
178
+ const baseOccurrence = {
179
+ selector: helpers.buildSelector ? helpers.buildSelector(el) : 'html',
180
+ html: helpers.getOuterHtmlSnippet ? helpers.getOuterHtmlSnippet(el) : el.outerHTML || '',
181
+ summary: 'This element may play sound as soon as the page loads.',
182
+ hint: 'Check whether it plays sound on its own. If the sound lasts more than 3 seconds, users need a way to pause or stop it, or to change its volume without changing the system volume.',
183
+ i18n: {
184
+ summaryKey: 'noAutoplayAudio_summary_cantTell_embedded',
185
+ hintKey: 'noAutoplayAudio_hint_cantTell_embedded',
186
+ params: { element: tag }
187
+ },
188
+ data: {
189
+ details: { reasonCode: 'EMBEDDED_SOUND_SOURCE', mediaTag: tag }
190
+ }
191
+ };
192
+
193
+ if (helpers && typeof helpers.reportOccurrence === 'function') {
194
+ occurrences.push(helpers.reportOccurrence(el, baseOccurrence));
195
+ } else {
196
+ occurrences.push(baseOccurrence);
197
+ }
198
+ }
199
+
113
200
  if (applicableCount === 0) {
114
201
  return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
115
202
  }
@@ -5,14 +5,15 @@
5
5
  /**
6
6
  * @check p-as-heading
7
7
  * @atomic true
8
- * @summary A <p> styled to look like a heading should probably be a real heading
8
+ * @summary Text styled to look like a heading should probably be a real heading
9
9
  * @standard WCAG 2.2
10
10
  * @sc 1.3.1
11
11
  * @applicability
12
- * `<p>` elements with short (<=120 char), non-empty trimmed text
13
- * content that is entirely bold (the `<p>`'s own computed
14
- * `font-weight` >= 700, OR its entire text is wrapped in a single
15
- * `<strong>`/`<b>` child) and rendered at >=18px.
12
+ * `<p>` elements, and `<div>` elements that hold only text and inline
13
+ * markup, with short (<=120 char), non-empty trimmed text in which every
14
+ * piece of text is bold (computed `font-weight` >= 700, however it got
15
+ * there: on the element itself, a `<strong>`/`<b>` or a styled `<span>`)
16
+ * and rendered at >=18px.
16
17
  * @expectation
17
18
  * Text styled to visually read as a heading (bold, larger-than-body
18
19
  * size, short) should be marked up with a real heading element
@@ -30,14 +31,22 @@
30
31
  * surrounding text (unlike `link-in-text-block`); it's simpler on
31
32
  * purpose, since "looks like a heading" is closer to an absolute
32
33
  * judgment than a relative-contrast one.
34
+ * - Weight and size are read from the element that holds each piece of
35
+ * text, so `<p><span style="font-weight:bold">` counts and a `<p>` with
36
+ * one normal-weight word does not.
37
+ * - A `<div>` is only considered when it has no block, list, table, form
38
+ * control or image inside it, so only the innermost block is asked
39
+ * about. A `<div>` with a role, and text inside a heading, button,
40
+ * label, legend, caption, table header or `<summary>`, are left out:
41
+ * that text already has a role of its own.
33
42
  */
34
43
 
35
44
  const id = 'p-as-heading';
36
45
 
37
46
  const meta = {
38
- title: 'A <p> styled to look like a heading should probably be a real heading',
47
+ title: 'Text styled to look like a heading should probably be a real heading',
39
48
  description:
40
- 'Flags short <p> elements whose entire text is bold and rendered at >=18px, for manual review of whether a real heading element should be used instead.',
49
+ 'Flags short <p> and <div> elements whose text is all bold and rendered at >=18px, for manual review of whether a real heading element should be used instead.',
41
50
  i18n: {
42
51
  titleKey: 'pAsHeading_title',
43
52
  descriptionKey: 'pAsHeading_description'
@@ -93,65 +102,101 @@ function runInPage(ctx) {
93
102
  return Number.isFinite(n) && n >= 700;
94
103
  }
95
104
 
96
- function isEntirelyBold(p, text) {
97
- const cs = safeComputedStyle(p);
98
- if (isBoldWeight(cs)) return true;
99
-
100
- // A single <strong>/<b> child that wraps the whole text also counts.
101
- const children = Array.from(p.children || []);
102
- const boldWrap = children.find((c) => {
103
- const tag = (c.tagName || '').toLowerCase();
104
- return tag === 'strong' || tag === 'b';
105
- });
106
- if (boldWrap && children.length === 1) {
107
- const wrapText = trim(boldWrap.textContent || '');
108
- if (wrapText && wrapText === text) return true;
105
+ // Elements whose text already has a role of its own.
106
+ const OWN_ROLE_ANCESTORS =
107
+ 'h1, h2, h3, h4, h5, h6, [role="heading"], button, [role="button"], label, legend, caption, th, [role="columnheader"], [role="rowheader"], summary';
108
+
109
+ // Anything but text and inline markup makes a <div> a container, not a
110
+ // passage of text.
111
+ const NOT_INLINE =
112
+ 'address, article, aside, blockquote, details, dialog, div, dl, fieldset, figure, figcaption, footer, form, h1, h2, h3, h4, h5, h6, header, hgroup, hr, li, main, nav, ol, p, pre, section, table, ul, img, svg, picture, video, audio, canvas, iframe, object, embed, input, select, textarea, button';
113
+
114
+ function textPieces(el) {
115
+ const pieces = [];
116
+ const doc = el.ownerDocument;
117
+ const walker = doc.createTreeWalker(el, 4 /* NodeFilter.SHOW_TEXT */);
118
+ let node = walker.nextNode();
119
+ while (node) {
120
+ if (trim(node.nodeValue) && node.parentElement) pieces.push(node.parentElement);
121
+ node = walker.nextNode();
109
122
  }
110
- return false;
123
+ return pieces;
111
124
  }
112
125
 
113
- function getFontSizePx(p) {
114
- const cs = safeComputedStyle(p);
115
- if (!cs) return 0;
116
- const px = Number.parseFloat(cs.fontSize);
117
- return Number.isFinite(px) ? px : 0;
126
+ // Every piece of text is bold, and the smallest is the size reported.
127
+ function boldSize(el) {
128
+ let minPx = Infinity;
129
+ for (const holder of textPieces(el)) {
130
+ const cs = safeComputedStyle(holder);
131
+ if (!isBoldWeight(cs)) return 0;
132
+ const px = Number.parseFloat(cs.fontSize);
133
+ if (!Number.isFinite(px)) return 0;
134
+ minPx = Math.min(minPx, px);
135
+ }
136
+ return Number.isFinite(minPx) ? minPx : 0;
137
+ }
138
+
139
+ function isCandidate(el) {
140
+ const tag = (el.tagName || '').toLowerCase();
141
+ if (el.closest && el.closest(OWN_ROLE_ANCESTORS)) return false;
142
+ if (tag === 'p') return true;
143
+ if (tag !== 'div') return false;
144
+ if (trim(el.getAttribute('role'))) return false;
145
+ return !el.querySelector(NOT_INLINE);
118
146
  }
119
147
 
120
- const nodes = helpers.queryAllSmart ? helpers.queryAllSmart('p') : helpers.queryAll('p');
148
+ const nodes = helpers.queryAllSmart
149
+ ? helpers.queryAllSmart('p, div')
150
+ : helpers.queryAll('p, div');
121
151
 
122
152
  const occurrences = [];
123
153
  let applicableCount = 0;
124
154
 
125
155
  for (const el of nodes) {
126
156
  if (!el || !el.getAttribute) continue;
157
+ if (!isCandidate(el)) continue;
127
158
 
128
159
  const text = trim(el.textContent || '');
129
160
  if (!text || text.length > MAX_HEADING_LIKE_CHARS) continue;
130
161
 
131
162
  applicableCount += 1;
132
163
 
133
- if (!isEntirelyBold(el, text)) continue;
134
-
135
- const fontSizePx = getFontSizePx(el);
164
+ const fontSizePx = boldSize(el);
136
165
  if (fontSizePx < MIN_FONT_SIZE_PX) continue;
137
166
 
167
+ const isParagraph = (el.tagName || '').toLowerCase() === 'p';
138
168
  const stableSelector = helpers.buildSelector ? helpers.buildSelector(el) : 'html';
139
169
  const html = helpers.getOuterHtmlSnippet ? helpers.getOuterHtmlSnippet(el) : el.outerHTML || '';
140
170
 
141
- const baseOccurrence = {
142
- selector: stableSelector,
143
- html,
144
- summary: 'This paragraph is entirely bold and rendered at a heading-like size.',
145
- hint: 'If this text introduces a new section, use a real heading element (<h1>-<h6> or role="heading") instead of styling a paragraph to look like one.',
146
- i18n: {
147
- summaryKey: 'pAsHeading_summary_cantTell',
148
- hintKey: 'pAsHeading_hint_cantTell',
149
- params: { fontSizePx: String(fontSizePx) }
150
- },
151
- data: {
152
- details: { reasonCode: 'BOLD_LARGE_PARAGRAPH', fontSizePx }
153
- }
154
- };
171
+ const baseOccurrence = isParagraph
172
+ ? {
173
+ selector: stableSelector,
174
+ html,
175
+ summary: 'This paragraph is entirely bold and rendered at a heading-like size.',
176
+ hint: 'If this text introduces a new section, use a real heading element (<h1>-<h6> or role="heading") instead of styling a paragraph to look like one.',
177
+ i18n: {
178
+ summaryKey: 'pAsHeading_summary_cantTell',
179
+ hintKey: 'pAsHeading_hint_cantTell',
180
+ params: { fontSizePx: String(fontSizePx) }
181
+ },
182
+ data: {
183
+ details: { reasonCode: 'BOLD_LARGE_PARAGRAPH', fontSizePx }
184
+ }
185
+ }
186
+ : {
187
+ selector: stableSelector,
188
+ html,
189
+ summary: 'This block of text is entirely bold and rendered at a heading-like size.',
190
+ hint: 'If this text introduces a new section, use a real heading element (<h1>-<h6> or role="heading") instead of styling a <div> to look like one.',
191
+ i18n: {
192
+ summaryKey: 'pAsHeading_summary_cantTell_div',
193
+ hintKey: 'pAsHeading_hint_cantTell_div',
194
+ params: { fontSizePx: String(fontSizePx) }
195
+ },
196
+ data: {
197
+ details: { reasonCode: 'BOLD_LARGE_DIV', fontSizePx }
198
+ }
199
+ };
155
200
 
156
201
  if (helpers && typeof helpers.reportOccurrence === 'function') {
157
202
  occurrences.push(helpers.reportOccurrence(el, baseOccurrence));
@@ -10,7 +10,9 @@
10
10
  * @sc 2.4.2
11
11
  * @applicability
12
12
  * Applies to a run over a whole document whose <title> resolves to
13
- * non-empty text; a missing or empty title is page-title-present's
13
+ * non-empty text. The title element is the first HTML <title> anywhere in
14
+ * the document, as for document.title, so one the parser left in <body>
15
+ * counts; a missing or empty title is page-title-present's
14
16
  * failure, not a pattern to review. A run narrowed by contextSelector or
15
17
  * by engineOptions.fragment is notApplicable, as is a title matching none
16
18
  * of the patterns below.
@@ -78,7 +80,24 @@ function runInPage(ctx) {
78
80
  const occurrences = [];
79
81
  let applicableCount = 1;
80
82
 
81
- const titleEl = document.querySelector('head > title');
83
+ // The document's title element, found as page-title-present finds it: the
84
+ // first HTML-namespace <title> anywhere in the document, since a <title>
85
+ // the parser leaves in <body> is still what document.title reads. An inline
86
+ // <svg><title> is not the page title.
87
+ const HTML_NS = 'http://www.w3.org/1999/xhtml';
88
+ let titleEl = null;
89
+ for (const t of Array.from(document.getElementsByTagName('title'))) {
90
+ if (!t.namespaceURI || t.namespaceURI === HTML_NS) {
91
+ titleEl = t;
92
+ break;
93
+ }
94
+ }
95
+ // Kept as the stable selector for the usual place; a <title> elsewhere gets
96
+ // the selector the engine builds for the node.
97
+ const titleSelector =
98
+ titleEl && titleEl.parentElement && titleEl.parentElement.localName === 'head'
99
+ ? 'head > title'
100
+ : undefined;
82
101
  const rawTitle = document.title || '';
83
102
  const titleText = rawTitle.replace(/\s+/g, ' ').trim();
84
103
  const titleLc = titleText.toLowerCase();
@@ -176,7 +195,7 @@ function runInPage(ctx) {
176
195
  };
177
196
 
178
197
  const occBase = {
179
- selector: 'head > title',
198
+ selector: titleSelector,
180
199
  html: '',
181
200
  summary:
182
201
  'The set of page titles may not be descriptive enough to distinguish pages by topic or purpose.',
@@ -244,18 +263,68 @@ function runInPage(ctx) {
244
263
  return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
245
264
  }
246
265
 
247
- const GENERIC_TITLES = new Set(['home', 'homepage', 'welcome', 'untitled', 'page', 'document']);
266
+ const GENERIC_TITLES = {
267
+ en: new Set(['home', 'homepage', 'welcome', 'untitled', 'page', 'document']),
268
+ de: new Set(['startseite', 'willkommen', 'unbenannt', 'ohne titel', 'seite', 'dokument']),
269
+ es: new Set([
270
+ 'inicio',
271
+ 'página de inicio',
272
+ 'bienvenido',
273
+ 'bienvenida',
274
+ 'sin título',
275
+ 'página',
276
+ 'documento'
277
+ ]),
278
+ fr: new Set(['accueil', "page d'accueil", 'bienvenue', 'sans titre', 'page', 'document']),
279
+ ja: new Set(['ホーム', 'トップページ', 'トップ', 'ようこそ', '無題', 'ページ', 'ドキュメント'])
280
+ };
281
+
282
+ // The page-specific half of "Brand | Home" in languages other than
283
+ // English. English keeps its word-boundary patterns below.
284
+ const TEMPLATE_TOKENS = {
285
+ de: ['startseite', 'willkommen'],
286
+ es: ['inicio', 'página de inicio', 'bienvenido', 'bienvenida'],
287
+ fr: ['accueil', "page d'accueil", 'bienvenue'],
288
+ ja: ['ホーム', 'トップページ', 'トップ', 'ようこそ']
289
+ };
290
+
291
+ const htmlEl = document.documentElement;
292
+ const pageLang =
293
+ htmlEl && htmlEl.getAttribute && htmlEl.getAttribute('lang')
294
+ ? htmlEl.getAttribute('lang').trim().split('-')[0].toLowerCase()
295
+ : '';
296
+ const titleNorm = titleLc.normalize('NFKC').replace(/[\u2018\u2019]/g, "'");
297
+
298
+ // Chinese, Japanese and Korean characters each carry roughly a word, so
299
+ // they count double: 「お問い合わせ」 is a full title in six characters.
300
+ function effectiveLength(s) {
301
+ let n = 0;
302
+ for (const ch of s)
303
+ n += /[\u3040-\u30ff\u3400-\u9fff\uac00-\ud7af\uf900-\ufaff]/.test(ch) ? 2 : 1;
304
+ return n;
305
+ }
248
306
 
249
307
  // Conservative signals:
250
308
  // - very short title (likely non-descriptive)
251
309
  // - title is one of a small set of generic titles
252
- const isVeryShort = titleText.length > 0 && titleText.length < 8;
253
- const isGeneric = GENERIC_TITLES.has(titleLc);
310
+ const isVeryShort = titleText.length > 0 && effectiveLength(titleText) < 8;
311
+ const isGeneric =
312
+ GENERIC_TITLES.en.has(titleNorm) ||
313
+ !!(pageLang && GENERIC_TITLES[pageLang] && GENERIC_TITLES[pageLang].has(titleNorm));
314
+
315
+ function isLocalTemplate(title) {
316
+ const tokens = TEMPLATE_TOKENS[pageLang];
317
+ if (!tokens) return false;
318
+ const parts = title.split(/\s*(?:\||-|—|:)\s*/).filter(Boolean);
319
+ if (parts.length < 2) return false;
320
+ return tokens.includes(parts[0]) || tokens.includes(parts[parts.length - 1]);
321
+ }
254
322
 
255
323
  // Template-like: "Brand | Home" or "Home - Brand" where the page-specific part is a generic token.
256
324
  const templateLike =
257
325
  /\b(home|homepage|welcome)\b\s*(\||-|—|:)\s*.+/i.test(titleText) ||
258
- /.+\s*(\||-|—|:)\s*\b(home|homepage|welcome)\b/i.test(titleText);
326
+ /.+\s*(\||-|—|:)\s*\b(home|homepage|welcome)\b/i.test(titleText) ||
327
+ isLocalTemplate(titleNorm);
259
328
 
260
329
  if (isGeneric || isVeryShort || templateLike) {
261
330
  const reasonCode = isGeneric
@@ -271,7 +340,7 @@ function runInPage(ctx) {
271
340
  ? 'pageTitlePatterns_summary_cantTell_veryShort'
272
341
  : 'pageTitlePatterns_summary_cantTell_templateLike';
273
342
  const occBase = {
274
- selector: 'head > title',
343
+ selector: titleSelector,
275
344
  html: '',
276
345
  summary:
277
346
  'The page title may not be descriptive enough to identify the page topic or purpose.',