@markuplint/rules 5.4.0 → 5.5.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 (86) hide show
  1. package/CHANGELOG.md +21 -0
  2. package/CLAUDE.md +4 -3
  3. package/lib/aria-prop-requires-role/index.js +2 -2
  4. package/lib/attr-eligibility.d.ts +21 -0
  5. package/lib/attr-eligibility.js +86 -6
  6. package/lib/attr-order/index.js +2 -1
  7. package/lib/attr-value-quotes/index.js +1 -1
  8. package/lib/consistent-table-row-length/index.js +1 -1
  9. package/lib/element-supports-aria-prop/index.js +2 -2
  10. package/lib/form-attr-references-form/index.js +2 -2
  11. package/lib/helpers.d.ts +43 -5
  12. package/lib/helpers.js +88 -26
  13. package/lib/input-list-references-datalist/index.js +2 -2
  14. package/lib/itemprop-requires-itemscope/index.js +27 -10
  15. package/lib/label-for-references-labelable/index.js +2 -2
  16. package/lib/label-has-control/index.d.ts +17 -0
  17. package/lib/label-has-control/index.js +28 -1
  18. package/lib/label-no-multiple-controls/index.d.ts +4 -5
  19. package/lib/label-no-multiple-controls/index.js +6 -6
  20. package/lib/landmark-roles/roles.d.ts +13 -1
  21. package/lib/landmark-roles/roles.js +22 -0
  22. package/lib/no-ambiguous-navigable-target-names/index.js +4 -0
  23. package/lib/no-aria-on-presentational-children/index.js +4 -0
  24. package/lib/no-aria-on-unsupported-element/index.js +2 -2
  25. package/lib/no-boolean-attr-value/index.js +2 -1
  26. package/lib/no-broken-fragment-link/index.d.ts +5 -0
  27. package/lib/no-broken-fragment-link/index.js +8 -2
  28. package/lib/no-consecutive-br/index.js +1 -1
  29. package/lib/no-contradictory-aria-prop/index.js +2 -2
  30. package/lib/no-default-aria-value/index.js +2 -2
  31. package/lib/no-default-value/index.js +4 -0
  32. package/lib/no-deprecated-aria-prop/index.js +2 -2
  33. package/lib/no-disallowed-ancestor/index.d.ts +16 -1
  34. package/lib/no-disallowed-ancestor/index.js +78 -4
  35. package/lib/no-disallowed-attr/index.js +4 -0
  36. package/lib/no-duplicate-dt/index.js +1 -1
  37. package/lib/no-duplicate-h1/index.js +18 -14
  38. package/lib/no-duplicate-id/index.d.ts +9 -0
  39. package/lib/no-duplicate-id/index.js +23 -4
  40. package/lib/no-empty-table-track/index.js +1 -1
  41. package/lib/no-ineffective-attr/index.js +4 -0
  42. package/lib/no-invalid-aria-prop-value/index.js +3 -3
  43. package/lib/no-invalid-attr-value/index.js +12 -0
  44. package/lib/no-nested-top-level-landmark/index.js +20 -17
  45. package/lib/no-prohibited-naming/index.js +2 -2
  46. package/lib/no-redundant-accessible-name/index.js +8 -7
  47. package/lib/no-redundant-aria-prop/index.js +2 -2
  48. package/lib/no-refer-to-non-existent-id/index.d.ts +15 -0
  49. package/lib/no-refer-to-non-existent-id/index.js +47 -16
  50. package/lib/no-restricted-element/index.js +4 -2
  51. package/lib/no-skipped-heading-level/index.js +9 -4
  52. package/lib/no-stray-head-or-body-tag/index.d.ts +2 -1
  53. package/lib/no-stray-head-or-body-tag/index.js +2 -1
  54. package/lib/no-table-cell-overlap/index.js +1 -1
  55. package/lib/no-table-span-overflow/index.js +1 -1
  56. package/lib/no-unescaped-char/index.js +1 -1
  57. package/lib/permitted-contents/index.js +2 -4
  58. package/lib/permitted-contents/matches-selector.js +28 -5
  59. package/lib/permitted-contents/represent-transparent-nodes.js +100 -68
  60. package/lib/permitted-contents/slot-content.d.ts +2 -1
  61. package/lib/permitted-contents/slot-content.js +28 -2
  62. package/lib/permitted-contents/start.js +4 -2
  63. package/lib/permitted-contents/utils.d.ts +11 -0
  64. package/lib/permitted-contents/utils.js +15 -0
  65. package/lib/placeholder-label-option/index.js +4 -26
  66. package/lib/require-accessible-name/index.js +2 -2
  67. package/lib/require-adjacent-popover/index.js +4 -2
  68. package/lib/require-ancestor/index.d.ts +20 -0
  69. package/lib/require-ancestor/index.js +33 -7
  70. package/lib/require-datetime/index.js +1 -1
  71. package/lib/require-dialog-autofocus/index.js +8 -6
  72. package/lib/require-h1/index.js +4 -8
  73. package/lib/require-landmark-label/index.js +17 -15
  74. package/lib/role-supports-aria-prop/index.js +2 -2
  75. package/lib/sizes-auto-requires-lazy-loading/index.d.ts +4 -2
  76. package/lib/sizes-auto-requires-lazy-loading/index.js +4 -2
  77. package/lib/usemap-references-map/index.js +11 -2
  78. package/lib/valid-speculation-rules/index.js +1 -1
  79. package/lib/wai-aria/checkings/interaction-in-hidden.js +6 -2
  80. package/lib/wai-aria/checkings/presentational-children.js +38 -17
  81. package/lib/wai-aria/checkings/required-accessibility-parent-role.js +2 -1
  82. package/lib/wai-aria/checkings/required-prop.js +2 -1
  83. package/lib/wai-aria/checkings/tab-requires-tabpanel.js +10 -6
  84. package/lib/wai-aria/checkings/value.d.ts +1 -4
  85. package/lib/wai-aria/checkings/value.js +5 -17
  86. package/package.json +11 -11
@@ -1,8 +1,9 @@
1
+ import { isInterElementWhitespace } from '@markuplint/selector';
1
2
  import { resolveContentModel } from './content-model.js';
2
3
  import { cmLog } from './debug.js';
3
4
  import { order } from './order.js';
4
5
  import { expandChildren, getContentOwner, getSlotContent } from './slot-content.js';
5
- import { Collection, isTransparent, matches } from './utils.js';
6
+ import { Collection, isComment, isTransparent, matches } from './utils.js';
6
7
  /**
7
8
  * A map tracking child nodes that are currently being evaluated through
8
9
  * a transparent content model. Used to prevent infinite recursion and
@@ -97,78 +98,13 @@ childNodes, rules, specs, options, mode) {
97
98
  }
98
99
  continue;
99
100
  }
100
- // A component whose children sit inside an inner wrapper element (or one of
101
- // several) cannot be looked through as transparent content: the children
102
- // belong to the wrapper, not to the outermost element that is being matched.
103
- const childSlot = getSlotContent(childNode, mode);
104
- if (childSlot && (!childSlot.wrapper || getContentOwner(childNode, mode) !== childNode)) {
101
+ const branchGroups = transparentBranchGroups(childNode, rules, specs, options, mode);
102
+ if (branchGroups == null) {
105
103
  for (const p of patterns) {
106
104
  p.push(childNode);
107
105
  }
108
106
  continue;
109
107
  }
110
- const models = resolveContentModel(childNode, rules, specs, mode);
111
- if (models == null || typeof models === 'boolean') {
112
- for (const p of patterns) {
113
- p.push(childNode);
114
- }
115
- continue;
116
- }
117
- const noTransparentModels = models.filter(m => !isTransparent(m));
118
- if (noTransparentModels.length === models.length) {
119
- for (const p of patterns) {
120
- p.push(childNode);
121
- }
122
- continue;
123
- }
124
- const childNodesPatterns = options.evaluateConditionalChildNodes
125
- ? childNode.conditionalChildNodes().map(childNodes => [...expandChildren(childNode, [...childNodes], mode)])
126
- : [
127
- [...expandChildren(childNode, [...childNode.childNodes], mode)].filter(child => !(child.is(child.TEXT_NODE) && child.isWhitespace())),
128
- ];
129
- const branchGroups = [];
130
- for (const branchChildNodes of childNodesPatterns) {
131
- const collection = new Collection([...branchChildNodes]);
132
- let unmatched;
133
- if (noTransparentModels.length > 0) {
134
- const result = order(noTransparentModels, collection.unmatched, rules, specs, options, Number.POSITIVE_INFINITY, mode);
135
- unmatched = result.unmatched;
136
- }
137
- else {
138
- unmatched = collection.unmatched;
139
- }
140
- const transparent = models.find(m => isTransparent(m));
141
- if (!transparent || !isTransparent(transparent)) {
142
- throw new Error('Unreachable code');
143
- }
144
- const branchChildren = [];
145
- for (const _child of unmatched) {
146
- const child = _child;
147
- if (transparentMode.has(child)) {
148
- continue;
149
- }
150
- transparentMode.set(child, true);
151
- if (child.is(child.ELEMENT_NODE)) {
152
- const transparentCondMatched = matches(transparent.transparent, child, specs, mode);
153
- if (!transparentCondMatched.matched) {
154
- branchChildren.push({
155
- type: 'TRANSPARENT_MODEL_DISALLOWS',
156
- matched: [],
157
- unmatched: [childNode],
158
- zeroMatch: false,
159
- query: transparent.transparent,
160
- hint: {
161
- not: transparentCondMatched.not,
162
- transparent: childNode,
163
- },
164
- });
165
- continue;
166
- }
167
- }
168
- branchChildren.push(child);
169
- }
170
- branchGroups.push(branchChildren);
171
- }
172
108
  // The transparent element itself is still a child of the current parent
173
109
  // and must be evaluated against the parent's own content model (e.g., a
174
110
  // `<button>` must reject an `<a href>` descendant even though `<a>`'s
@@ -223,3 +159,99 @@ childNodes, rules, specs, options, mode) {
223
159
  });
224
160
  return result;
225
161
  }
162
+ /**
163
+ * The children that a transparent element stands for in its parent, one group per
164
+ * conditional branch, or `null` when the element is not looked through (its content
165
+ * model has no transparent part, or it is a component whose children belong to an
166
+ * inner wrapper).
167
+ *
168
+ * A child that is itself a transparent element is followed by the children it
169
+ * stands for, so that `<span><ins><ins><div>` puts the `<div>` in the `<span>`: the
170
+ * HTML Standard derives the content model of a transparent element from its parent
171
+ * again for each transparent element in which the content finds itself.
172
+ *
173
+ * > In some cases, where transparent elements are nested in each other, the process
174
+ * > has to be applied iteratively.
175
+ *
176
+ * @see https://html.spec.whatwg.org/multipage/dom.html#transparent-content-models
177
+ *
178
+ * The branches of a nested transparent element are merged into each branch of the
179
+ * outer one, the same over-approximation as the fallback documented on
180
+ * {@link MAX_PATTERNS}.
181
+ */
182
+ function transparentBranchGroups(
183
+ // eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types
184
+ childNode, rules, specs, options, mode) {
185
+ // A component whose children sit inside an inner wrapper element (or one of
186
+ // several) cannot be looked through as transparent content: the children
187
+ // belong to the wrapper, not to the outermost element that is being matched.
188
+ const childSlot = getSlotContent(childNode, mode);
189
+ if (childSlot && (!childSlot.wrapper || getContentOwner(childNode, mode) !== childNode)) {
190
+ return null;
191
+ }
192
+ const models = resolveContentModel(childNode, rules, specs, mode);
193
+ if (models == null || typeof models === 'boolean') {
194
+ return null;
195
+ }
196
+ const noTransparentModels = models.filter(m => !isTransparent(m));
197
+ if (noTransparentModels.length === models.length) {
198
+ return null;
199
+ }
200
+ const childNodesPatterns = options.evaluateConditionalChildNodes
201
+ ? childNode
202
+ .conditionalChildNodes()
203
+ .map(childNodes => [...expandChildren(childNode, [...childNodes], mode)].filter(child => !isComment(child)))
204
+ : [
205
+ [...expandChildren(childNode, [...childNode.childNodes], mode)].filter(child => !isComment(child) && !(child.is(child.TEXT_NODE) && isInterElementWhitespace(child.raw))),
206
+ ];
207
+ const branchGroups = [];
208
+ for (const branchChildNodes of childNodesPatterns) {
209
+ const collection = new Collection([...branchChildNodes]);
210
+ let unmatched;
211
+ if (noTransparentModels.length > 0) {
212
+ const result = order(noTransparentModels, collection.unmatched, rules, specs, options, Number.POSITIVE_INFINITY, mode);
213
+ unmatched = result.unmatched;
214
+ }
215
+ else {
216
+ unmatched = collection.unmatched;
217
+ }
218
+ const transparent = models.find(m => isTransparent(m));
219
+ if (!transparent || !isTransparent(transparent)) {
220
+ throw new Error('Unreachable code');
221
+ }
222
+ const branchChildren = [];
223
+ for (const _child of unmatched) {
224
+ const child = _child;
225
+ if (transparentMode.has(child)) {
226
+ continue;
227
+ }
228
+ transparentMode.set(child, true);
229
+ if (child.is(child.ELEMENT_NODE)) {
230
+ const transparentCondMatched = matches(transparent.transparent, child, specs, mode);
231
+ if (!transparentCondMatched.matched) {
232
+ branchChildren.push({
233
+ type: 'TRANSPARENT_MODEL_DISALLOWS',
234
+ matched: [],
235
+ unmatched: [childNode],
236
+ zeroMatch: false,
237
+ query: transparent.transparent,
238
+ hint: {
239
+ not: transparentCondMatched.not,
240
+ transparent: childNode,
241
+ },
242
+ });
243
+ continue;
244
+ }
245
+ branchChildren.push(child);
246
+ const nested = transparentBranchGroups(child, rules, specs, options, mode);
247
+ if (nested != null) {
248
+ branchChildren.push(...nested.flat());
249
+ }
250
+ continue;
251
+ }
252
+ branchChildren.push(child);
253
+ }
254
+ branchGroups.push(branchChildren);
255
+ }
256
+ return branchGroups;
257
+ }
@@ -24,7 +24,8 @@ export declare function getContentOwner<T extends RuleConfigValue, O extends Pla
24
24
  * The children of `el` to validate: the nodes given at the usage site, passed
25
25
  * through the pretender's `contents` when it declares any, with the children of
26
26
  * fragment pretenders (`element: '#fragment'`) expanded in place because such a
27
- * component contributes its contents directly to its parent.
27
+ * component contributes its contents directly to its parent. What the framework
28
+ * moves to the `<head>` (`movedToHead` of the spec) is not a child of `el` in the DOM.
28
29
  */
29
30
  export declare function expandChildren<T extends RuleConfigValue, O extends PlainData>(el: Element<T, O>, given: readonly ChildNode<T, O>[], mode: Mode): readonly ChildNode<T, O>[];
30
31
  /**
@@ -34,14 +34,40 @@ el, mode) {
34
34
  }
35
35
  return el;
36
36
  }
37
+ /**
38
+ * Whether the framework moves `child` out of `parent`: the spec of the framework lists the elements
39
+ * it places in the `<head>` wherever they are written (`movedToHead`), so what `parent` contains in
40
+ * the DOM does not include them. The `<head>` itself keeps them: that is where they are placed.
41
+ *
42
+ * @see MLMLSpec.movedToHead in `@markuplint/ml-spec`
43
+ */
44
+ function isMovedToHead(
45
+ // eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types
46
+ parent,
47
+ // eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types
48
+ child) {
49
+ const selectors = parent.ownerMLDocument.specs.movedToHead;
50
+ if (!selectors || !child.is(child.ELEMENT_NODE) || parent.localName === 'head') {
51
+ return false;
52
+ }
53
+ return selectors.some(selector => child.matches(selector));
54
+ }
37
55
  /**
38
56
  * The children of `el` to validate: the nodes given at the usage site, passed
39
57
  * through the pretender's `contents` when it declares any, with the children of
40
58
  * fragment pretenders (`element: '#fragment'`) expanded in place because such a
41
- * component contributes its contents directly to its parent.
59
+ * component contributes its contents directly to its parent. What the framework
60
+ * moves to the `<head>` (`movedToHead` of the spec) is not a child of `el` in the DOM.
42
61
  */
43
62
  export function expandChildren(
44
63
  // eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types
64
+ el, given, mode) {
65
+ // Only here, not in the expansion of a fragment: what a fragment contributes belongs to `el`, so
66
+ // it is `el` that decides whether it is moved (the `<head>` keeps it), not the fragment.
67
+ return fillAndExpand(el, given, mode).filter(child => !isMovedToHead(el, child));
68
+ }
69
+ function fillAndExpand(
70
+ // eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types
45
71
  el, given, mode) {
46
72
  const slotContent = getSlotContent(el, mode);
47
73
  const children = slotContent ? slotContent.fill(given) : given;
@@ -71,7 +97,7 @@ node, mode) {
71
97
  function expandFragment(
72
98
  // eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types
73
99
  node, mode) {
74
- return isFragmentPretender(node, mode) ? expandChildren(node, [...node.childNodes], mode) : [node];
100
+ return isFragmentPretender(node, mode) ? fillAndExpand(node, [...node.childNodes], mode) : [node];
75
101
  }
76
102
  /**
77
103
  * A violation on an element that only the pretender renders (`contents`) is reported
@@ -1,6 +1,8 @@
1
+ import { isInterElementWhitespace } from '@markuplint/selector';
1
2
  import { order } from './order.js';
2
3
  import { representTransparentNodes } from './represent-transparent-nodes.js';
3
4
  import { expandChildren } from './slot-content.js';
5
+ import { isComment } from './utils.js';
4
6
  /**
5
7
  * Entry point for validating an element's child nodes against a content model definition.
6
8
  * Handles the three forms of content model: `false` (no content allowed), `true` (any content
@@ -23,10 +25,10 @@ el, rules, specs, options, mode) {
23
25
  ? el.conditionalChildNodes().map(childNodes => [...childNodes])
24
26
  : [[...el.childNodes]];
25
27
  const childNodesPatterns = givenPatterns.map(given => {
26
- const children = expandChildren(el, given, mode);
28
+ const children = expandChildren(el, given, mode).filter(child => !isComment(child));
27
29
  return options.evaluateConditionalChildNodes
28
30
  ? [...children]
29
- : children.filter(child => !(child.is(child.TEXT_NODE) && child.isWhitespace()));
31
+ : children.filter(child => !(child.is(child.TEXT_NODE) && isInterElementWhitespace(child.raw)));
30
32
  });
31
33
  return childNodesPatterns.flatMap(childNodes => {
32
34
  if (contents === false) {
@@ -1,6 +1,17 @@
1
1
  import type { ChildNode, Hints, MissingNodeReason, Mode, RepeatSign, Specs } from './types.js';
2
2
  import type { PermittedContentPattern, PermittedContentChoice, PermittedContentOneOrMore, PermittedContentOptional, PermittedContentRequire, PermittedContentTransparent, PermittedContentZeroOrMore, Model } from '@markuplint/ml-spec';
3
3
  import type { ReadonlyDeep } from 'type-fest';
4
+ /**
5
+ * Whether a child node is a comment, which a content model does not see.
6
+ * Without this, a comment is matched like an element and takes the place of
7
+ * one the model requires (`<html><!-- c --><head>` leaves `head` unmatched).
8
+ *
9
+ * @param node - The child node to test.
10
+ * @returns True if the node is a comment node.
11
+ * @see https://html.spec.whatwg.org/multipage/dom.html#content-models
12
+ * > Inter-element whitespace, comment nodes, and processing instruction nodes must be ignored when establishing whether an element's contents match the element's content model or not
13
+ */
14
+ export declare function isComment(node: ChildNode): boolean;
4
15
  /**
5
16
  * Determines whether a given value is a terminal model (a selector string or an array
6
17
  * of selector strings) rather than a nested array of content model patterns.
@@ -1,6 +1,21 @@
1
1
  import { createSelector } from '@markuplint/selector';
2
2
  import { bgGreen, green, bgRed, bgBlue, blue, bgMagenta, cyan } from './debug.js';
3
3
  import { transparentMode } from './represent-transparent-nodes.js';
4
+ /**
5
+ * Whether a child node is a comment, which a content model does not see.
6
+ * Without this, a comment is matched like an element and takes the place of
7
+ * one the model requires (`<html><!-- c --><head>` leaves `head` unmatched).
8
+ *
9
+ * @param node - The child node to test.
10
+ * @returns True if the node is a comment node.
11
+ * @see https://html.spec.whatwg.org/multipage/dom.html#content-models
12
+ * > Inter-element whitespace, comment nodes, and processing instruction nodes must be ignored when establishing whether an element's contents match the element's content model or not
13
+ */
14
+ export function isComment(
15
+ // eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types
16
+ node) {
17
+ return node.is(node.COMMENT_NODE);
18
+ }
4
19
  /**
5
20
  * Determines whether a given value is a terminal model (a selector string or an array
6
21
  * of selector strings) rather than a nested array of content model patterns.
@@ -1,4 +1,5 @@
1
1
  import { createRule } from '@markuplint/ml-core';
2
+ import { getSelectDisplaySize } from '@markuplint/ml-spec';
2
3
  import { decodeEntities } from '@markuplint/shared';
3
4
  import meta from './meta.js';
4
5
  /**
@@ -17,7 +18,7 @@ import meta from './meta.js';
17
18
  export default createRule({
18
19
  meta: meta,
19
20
  verify({ document, report, t }) {
20
- for (const select of document.querySelectorAll('select')) {
21
+ for (const select of document.deepQuerySelectorAll('select')) {
21
22
  if (!needPlaceholderLabelOption(select)) {
22
23
  continue;
23
24
  }
@@ -88,17 +89,10 @@ select) {
88
89
  return value === '' || value === null;
89
90
  }
90
91
  /**
91
- * The display size of a `<select>`.
92
- *
93
- * > The display size of a select element is the result of applying the rules for parsing non-negative integers to the
94
- * > value of the element's size attribute, if it has one and parsing it is successful. If applying those rules to the
95
- * > attribute's value is not successful, or if the size attribute is absent, then the element's display size is 4 if
96
- * > the element's multiple content attribute is present, and 1 otherwise.
92
+ * The display size of a `<select>`, as {@link getSelectDisplaySize} defines it.
97
93
  *
98
94
  * `null` when the size is given by an expression (`:size="n"`, `size={n}`): it
99
95
  * is not known until the template runs, so the rule must not assume it is 1.
100
- *
101
- * @see https://html.spec.whatwg.org/multipage/form-elements.html#concept-select-size
102
96
  */
103
97
  function displaySize(
104
98
  // eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types
@@ -107,23 +101,7 @@ select) {
107
101
  if (size?.isDynamicValue) {
108
102
  return null;
109
103
  }
110
- return parseNonNegativeInteger(size?.value ?? null) ?? (select.hasAttribute('multiple') ? 4 : 1);
111
- }
112
- /**
113
- * The rules for parsing non-negative integers: leading ASCII whitespace is
114
- * skipped, an optional `+` or `-` is read, then the leading run of ASCII
115
- * digits; whatever follows the digits is ignored (`"3abc"` is 3, `"01"` is 1).
116
- * No digits, or a negative number, is an error. `-0` is not less than zero.
117
- *
118
- * @see https://html.spec.whatwg.org/multipage/common-microsyntaxes.html#rules-for-parsing-non-negative-integers
119
- */
120
- function parseNonNegativeInteger(text) {
121
- const match = /^[\t\n\f\r ]*([+-]?)(\d+)/.exec(text ?? '');
122
- if (!match) {
123
- return null;
124
- }
125
- const value = Number(match[2]);
126
- return match[1] === '-' && value !== 0 ? null : value;
104
+ return getSelectDisplaySize(size?.value ?? null, select.hasAttribute('multiple'));
127
105
  }
128
106
  /**
129
107
  * The first option of a select's list of options, and whether an optgroup lies
@@ -10,7 +10,7 @@ export default createRule({
10
10
  async verify({ document, report, t }) {
11
11
  await document.walkOn('Element', el => {
12
12
  const ariaVersion = el.rule.options?.ariaVersion ?? document.ruleCommonSettings?.ariaVersion ?? ARIA_RECOMMENDED_VERSION;
13
- if (accnameMayBeMutable(el, document)) {
13
+ if (accnameMayBeMutable(el)) {
14
14
  return;
15
15
  }
16
16
  if (!isExposed(el, document.specs, ariaVersion)) {
@@ -29,7 +29,7 @@ export default createRule({
29
29
  return;
30
30
  }
31
31
  // The static scan cannot decide whether the unknown content is the name.
32
- if (accnameMayComeFromUnknownContent(el, document)) {
32
+ if (accnameMayComeFromUnknownContent(el)) {
33
33
  return;
34
34
  }
35
35
  report({ scope: el, message: t('Require {0}', 'accessible name') });
@@ -1,5 +1,6 @@
1
1
  import { createRule } from '@markuplint/ml-core';
2
2
  import { ARIA_RECOMMENDED_VERSION, mayBeFocusable } from '@markuplint/ml-spec';
3
+ import { getElementByIdInTree } from '../helpers.js';
3
4
  import meta from './meta.js';
4
5
  /**
5
6
  * Command values from the Invoker Commands API that relate to popover behavior.
@@ -11,7 +12,8 @@ export default createRule({
11
12
  defaultSeverity: 'warning',
12
13
  verify({ document, report, t }) {
13
14
  const ariaVersion = document.ruleCommonSettings?.ariaVersion ?? ARIA_RECOMMENDED_VERSION;
14
- const triggers = document.querySelectorAll('[popovertarget], [commandfor]');
15
+ // The target is looked for in the tree of the trigger, so the triggers of every tree are checked
16
+ const triggers = document.deepQuerySelectorAll('[popovertarget], [commandfor]');
15
17
  Triggers: for (const trigger of triggers) {
16
18
  let targetId = null;
17
19
  const popovertarget = trigger.getAttribute('popovertarget');
@@ -27,7 +29,7 @@ export default createRule({
27
29
  if (!targetId) {
28
30
  continue;
29
31
  }
30
- const target = document.getElementById(targetId);
32
+ const target = getElementByIdInTree(trigger, targetId, trigger.getAttributeNode(popovertarget ? 'popovertarget' : 'commandfor')?.isExpressionValue);
31
33
  if (!target) {
32
34
  continue;
33
35
  }
@@ -1,2 +1,22 @@
1
+ /**
2
+ * Looks for the ancestor that the content model asks for (`descendantOf`) in the tree of the
3
+ * element, as the DOM does: the climb stops at the top of the content of a `template` element, and
4
+ * does not go on to the tree that holds the `template`.
5
+ *
6
+ * What the rule decides for an element whose climb ends there depends on where the content is placed:
7
+ *
8
+ * - The content of an ordinary `template` element is placed where the markup does not tell
9
+ * (a script clones it into the document), so the rule does not decide and reports nothing,
10
+ * whether or not a matching ancestor stands outside of the `template`.
11
+ * - The content of a declarative shadow root is placed in its host, and it is a tree of its own:
12
+ * an ancestor outside of the shadow root is not an ancestor of the element, so it is reported.
13
+ *
14
+ * A `template` of a template engine that renders its children in place of it (Vue's `<template v-if>`,
15
+ * or the `template` of a framework whose spec puts the children in the element itself) is not an HTML
16
+ * `template` element, and the climb goes through it as through any other element.
17
+ *
18
+ * @see https://dom.spec.whatwg.org/#parent-element
19
+ * @see https://html.spec.whatwg.org/multipage/scripting.html#the-template-element
20
+ */
1
21
  declare const _default: Readonly<import("@markuplint/ml-core").RuleSeed<import("@markuplint/ml-core").RuleConfigValue, undefined>>;
2
22
  export default _default;
@@ -1,5 +1,26 @@
1
1
  import { createRule } from '@markuplint/ml-core';
2
+ import { isShadowRoot } from '@markuplint/ml-spec';
2
3
  import meta from './meta.js';
4
+ /**
5
+ * Looks for the ancestor that the content model asks for (`descendantOf`) in the tree of the
6
+ * element, as the DOM does: the climb stops at the top of the content of a `template` element, and
7
+ * does not go on to the tree that holds the `template`.
8
+ *
9
+ * What the rule decides for an element whose climb ends there depends on where the content is placed:
10
+ *
11
+ * - The content of an ordinary `template` element is placed where the markup does not tell
12
+ * (a script clones it into the document), so the rule does not decide and reports nothing,
13
+ * whether or not a matching ancestor stands outside of the `template`.
14
+ * - The content of a declarative shadow root is placed in its host, and it is a tree of its own:
15
+ * an ancestor outside of the shadow root is not an ancestor of the element, so it is reported.
16
+ *
17
+ * A `template` of a template engine that renders its children in place of it (Vue's `<template v-if>`,
18
+ * or the `template` of a framework whose spec puts the children in the element itself) is not an HTML
19
+ * `template` element, and the climb goes through it as through any other element.
20
+ *
21
+ * @see https://dom.spec.whatwg.org/#parent-element
22
+ * @see https://html.spec.whatwg.org/multipage/scripting.html#the-template-element
23
+ */
3
24
  export default createRule({
4
25
  meta: meta,
5
26
  async verify({ document, report, t }) {
@@ -9,21 +30,26 @@ export default createRule({
9
30
  if (!descendantOf) {
10
31
  return;
11
32
  }
12
- let ancestor = el.parentElement;
33
+ let ancestor = el.treeParentElement;
13
34
  let found = false;
14
35
  while (ancestor) {
15
36
  if (ancestor.matches(descendantOf)) {
16
37
  found = true;
17
38
  break;
18
39
  }
19
- ancestor = ancestor.parentElement;
40
+ ancestor = ancestor.treeParentElement;
20
41
  }
21
- if (!found) {
22
- report({
23
- scope: el,
24
- message: t('{0} must appear as a descendant of {1}', t('the "{0}" {1}', el.localName, 'element'), t('the "{0}" {1}', descendantOf, 'element')),
25
- });
42
+ if (found) {
43
+ return;
44
+ }
45
+ const root = el.getRootNode();
46
+ if (root.is(root.DOCUMENT_FRAGMENT_NODE) && !isShadowRoot(root)) {
47
+ return;
26
48
  }
49
+ report({
50
+ scope: el,
51
+ message: t('{0} must appear as a descendant of {1}', t('the "{0}" {1}', el.localName, 'element'), t('the "{0}" {1}', descendantOf, 'element')),
52
+ });
27
53
  });
28
54
  },
29
55
  });
@@ -8,7 +8,7 @@ export default createRule({
8
8
  langs: undefined,
9
9
  },
10
10
  verify({ document, report, t }) {
11
- for (const time of document.querySelectorAll('time:not([datetime])')) {
11
+ for (const time of document.deepQuerySelectorAll('time:not([datetime])')) {
12
12
  if (time.hasMutableChildren()) {
13
13
  continue;
14
14
  }
@@ -1,4 +1,5 @@
1
1
  import { createRule } from '@markuplint/ml-core';
2
+ import { getElementByIdInTree } from '../helpers.js';
2
3
  import meta from './meta.js';
3
4
  /**
4
5
  * Without `autofocus`, the browser's dialog focusing steps fall back to the
@@ -14,8 +15,9 @@ export default createRule({
14
15
  defaultSeverity: 'warning',
15
16
  meta: meta,
16
17
  verify({ document, report, t }) {
17
- const triggers = document.querySelectorAll('button[command][commandfor]');
18
- const reportedDialogIds = new Set();
18
+ // The dialog is looked for in the tree of the trigger, so the triggers of every tree are checked
19
+ const triggers = document.deepQuerySelectorAll('button[command][commandfor]');
20
+ const reportedDialogs = new Set();
19
21
  for (const trigger of triggers) {
20
22
  const command = trigger.getAttribute('command');
21
23
  if (!command || command.toLowerCase() !== 'show-modal') {
@@ -25,11 +27,11 @@ export default createRule({
25
27
  if (!targetId) {
26
28
  continue;
27
29
  }
28
- if (reportedDialogIds.has(targetId)) {
30
+ const target = getElementByIdInTree(trigger, targetId, trigger.getAttributeNode('commandfor')?.isExpressionValue);
31
+ if (!target) {
29
32
  continue;
30
33
  }
31
- const target = document.getElementById(targetId);
32
- if (!target) {
34
+ if (reportedDialogs.has(target)) {
33
35
  continue;
34
36
  }
35
37
  if (target.localName !== 'dialog') {
@@ -42,7 +44,7 @@ export default createRule({
42
44
  if (autofocusDescendants.length > 0) {
43
45
  continue;
44
46
  }
45
- reportedDialogIds.add(targetId);
47
+ reportedDialogs.add(target);
46
48
  report({
47
49
  scope: target,
48
50
  message: t('The "{0*}" element referenced by a "{1*}" command requires an element with the "{2*}" attribute', 'dialog', 'show-modal', 'autofocus'),
@@ -14,20 +14,16 @@ export default createRule({
14
14
  defaultOptions: {
15
15
  'in-document-fragment': false,
16
16
  },
17
- async verify({ document, report, t }) {
18
- const h1Stack = [];
17
+ verify({ document, report, t }) {
19
18
  if (document.nodeList.length === 0) {
20
19
  return;
21
20
  }
22
21
  if (!document.rule.options['in-document-fragment'] && document.isFragment) {
23
22
  return;
24
23
  }
25
- await document.walkOn('Element', node => {
26
- if (node.nodeName.toLowerCase() === 'h1') {
27
- h1Stack.push(node);
28
- }
29
- });
30
- if (h1Stack.length === 0) {
24
+ // The h1 of the document is required: what is in the content of a `template` element or in a
25
+ // declarative shadow root is a tree of its own, and where it is inserted is not known from the markup
26
+ if (!document.querySelector('h1')) {
31
27
  const message = t('Require {0}', t('the "{0*}" {1}', 'h1', 'element'));
32
28
  report({
33
29
  message,
@@ -1,5 +1,5 @@
1
1
  import { createRule } from '@markuplint/ml-core';
2
- import { computeLandmarkRoles } from '../landmark-roles/roles.js';
2
+ import { computeLandmarkRolesOfEachTree } from '../landmark-roles/roles.js';
3
3
  import meta from './meta.js';
4
4
  /**
5
5
  * Split from the former `landmark-roles` rule (#3989): when a document has
@@ -17,22 +17,24 @@ export default createRule({
17
17
  if (document.isFragment) {
18
18
  return;
19
19
  }
20
- const roles = computeLandmarkRoles(document);
21
- for (const role of Object.keys(roles)) {
22
- const roleEls = roles[role];
23
- const duplicated = 1 < roleEls.length;
24
- if (!duplicated) {
25
- continue;
26
- }
27
- for (const el of roleEls) {
28
- if (el.rule.disabled) {
20
+ // Landmarks are counted by tree: the content of a `template` element is a tree of its own
21
+ for (const roles of computeLandmarkRolesOfEachTree(document)) {
22
+ for (const role of Object.keys(roles)) {
23
+ const roleEls = roles[role];
24
+ const duplicated = 1 < roleEls.length;
25
+ if (!duplicated) {
29
26
  continue;
30
27
  }
31
- if (!hasLabel(el)) {
32
- report({
33
- scope: el,
34
- message: t('Require {0}', t('unique {0}', 'accessible name')),
35
- });
28
+ for (const el of roleEls) {
29
+ if (el.rule.disabled) {
30
+ continue;
31
+ }
32
+ if (!hasLabel(el)) {
33
+ report({
34
+ scope: el,
35
+ message: t('Require {0}', t('unique {0}', 'accessible name')),
36
+ });
37
+ }
36
38
  }
37
39
  }
38
40
  }
@@ -2,7 +2,7 @@ import { createRule, getComputedRole, ariaSpecs, getSpec } from '@markuplint/ml-
2
2
  import { ARIA_RECOMMENDED_VERSION } from '@markuplint/ml-spec';
3
3
  import { checkingRoleSupportsAriaProp } from '../wai-aria/checkings/role-supports-aria-prop.js';
4
4
  import { defaultOptions } from '../wai-aria/default-options.js';
5
- import { isAriaAttr } from '../helpers.js';
5
+ import { getAriaAttrs } from '../helpers.js';
6
6
  import meta from './meta.js';
7
7
  /**
8
8
  * Split from the former `wai-aria-disallowed-props` rule (#3989): the
@@ -21,7 +21,7 @@ export default createRule({
21
21
  return;
22
22
  if (elSpec && !elSpec.globalAttrs['#ARIAAttrs'])
23
23
  return;
24
- const propAttrs = el.attributes.filter(attr => isAriaAttr(attr));
24
+ const propAttrs = getAriaAttrs(el);
25
25
  if (propAttrs.length === 0)
26
26
  return;
27
27
  const ariaVersion = el.rule.options?.version ?? document.ruleCommonSettings?.ariaVersion ?? ARIA_RECOMMENDED_VERSION;