@markuplint/rules 5.4.0 → 5.6.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 (116) hide show
  1. package/CHANGELOG.md +34 -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 +2 -2
  8. package/lib/consistent-table-row-length/index.js +1 -1
  9. package/lib/create-message.js +6 -5
  10. package/lib/element-supports-aria-prop/index.js +2 -2
  11. package/lib/form-attr-references-form/index.js +3 -3
  12. package/lib/helpers.d.ts +96 -5
  13. package/lib/helpers.js +187 -29
  14. package/lib/input-list-references-datalist/index.js +3 -3
  15. package/lib/itemprop-requires-itemscope/index.js +28 -11
  16. package/lib/label-for-references-labelable/index.js +3 -3
  17. package/lib/label-has-control/index.d.ts +17 -0
  18. package/lib/label-has-control/index.js +28 -1
  19. package/lib/label-no-multiple-controls/index.d.ts +4 -5
  20. package/lib/label-no-multiple-controls/index.js +8 -8
  21. package/lib/landmark-roles/roles.d.ts +13 -1
  22. package/lib/landmark-roles/roles.js +22 -0
  23. package/lib/link-types/index.js +8 -8
  24. package/lib/map-id-name-match/index.js +1 -1
  25. package/lib/meter-value-bounds/index.js +3 -3
  26. package/lib/no-ambiguous-navigable-target-names/index.js +5 -1
  27. package/lib/no-aria-hidden-on-hidden-until-found/index.js +1 -1
  28. package/lib/no-aria-on-presentational-children/index.js +4 -0
  29. package/lib/no-aria-on-unsupported-element/index.js +2 -2
  30. package/lib/no-boolean-attr-value/index.js +2 -1
  31. package/lib/no-broken-fragment-link/index.d.ts +5 -0
  32. package/lib/no-broken-fragment-link/index.js +8 -2
  33. package/lib/no-consecutive-br/index.js +1 -1
  34. package/lib/no-content-after-body/index.js +2 -2
  35. package/lib/no-contradictory-aria-prop/index.js +2 -2
  36. package/lib/no-default-aria-value/index.js +2 -2
  37. package/lib/no-default-value/index.js +4 -0
  38. package/lib/no-deprecated-aria-prop/index.js +2 -2
  39. package/lib/no-disallowed-ancestor/index.d.ts +16 -1
  40. package/lib/no-disallowed-ancestor/index.js +68 -5
  41. package/lib/no-disallowed-attr/index.js +5 -1
  42. package/lib/no-duplicate-autofocus/index.js +1 -1
  43. package/lib/no-duplicate-dt/index.js +1 -1
  44. package/lib/no-duplicate-h1/index.js +18 -14
  45. package/lib/no-duplicate-id/index.d.ts +9 -0
  46. package/lib/no-duplicate-id/index.js +23 -4
  47. package/lib/no-duplicate-sibling-attr/index.js +1 -1
  48. package/lib/no-duplicate-visible-main/index.js +1 -1
  49. package/lib/no-empty-table-track/index.js +1 -1
  50. package/lib/no-extra-selected-options/index.js +1 -1
  51. package/lib/no-ineffective-attr/index.js +4 -0
  52. package/lib/no-input-file-value/index.js +1 -1
  53. package/lib/no-invalid-aria-prop-value/index.js +3 -3
  54. package/lib/no-invalid-attr-value/index.js +12 -0
  55. package/lib/no-malformed-character-reference/index.js +1 -1
  56. package/lib/no-nested-top-level-landmark/index.js +20 -17
  57. package/lib/no-prohibited-naming/index.js +2 -2
  58. package/lib/no-redundant-accessible-name/index.js +15 -8
  59. package/lib/no-redundant-aria-prop/index.js +2 -2
  60. package/lib/no-refer-to-non-existent-id/index.d.ts +15 -0
  61. package/lib/no-refer-to-non-existent-id/index.js +47 -16
  62. package/lib/no-restricted-attr/index.js +2 -1
  63. package/lib/no-restricted-element/index.js +4 -2
  64. package/lib/no-skipped-heading-level/index.js +9 -4
  65. package/lib/no-stray-head-or-body-tag/index.d.ts +2 -1
  66. package/lib/no-stray-head-or-body-tag/index.js +4 -3
  67. package/lib/no-table-cell-overlap/index.js +3 -2
  68. package/lib/no-table-span-overflow/index.js +1 -1
  69. package/lib/no-translate.d.ts +17 -0
  70. package/lib/no-translate.js +29 -0
  71. package/lib/no-unclosed-element-at-eof/index.js +1 -1
  72. package/lib/no-unescaped-char/index.js +1 -1
  73. package/lib/no-unsupported-browser-features/index.d.ts +9 -0
  74. package/lib/no-unsupported-browser-features/index.js +10 -7
  75. package/lib/permitted-contents/content-model.d.ts +14 -0
  76. package/lib/permitted-contents/content-model.js +21 -1
  77. package/lib/permitted-contents/index.js +6 -8
  78. package/lib/permitted-contents/matches-selector.js +32 -5
  79. package/lib/permitted-contents/represent-transparent-nodes.js +100 -68
  80. package/lib/permitted-contents/slot-content.d.ts +18 -1
  81. package/lib/permitted-contents/slot-content.js +49 -2
  82. package/lib/permitted-contents/start.d.ts +8 -1
  83. package/lib/permitted-contents/start.js +39 -6
  84. package/lib/permitted-contents/utils.d.ts +11 -0
  85. package/lib/permitted-contents/utils.js +15 -0
  86. package/lib/placeholder-label-option/index.js +4 -26
  87. package/lib/progress-value-bounds/index.js +2 -2
  88. package/lib/require-accessible-name/index.js +2 -2
  89. package/lib/require-adjacent-popover/index.js +4 -2
  90. package/lib/require-ancestor/index.d.ts +37 -1
  91. package/lib/require-ancestor/index.js +82 -9
  92. package/lib/require-ancestor/meta.d.ts +5 -0
  93. package/lib/require-ancestor/meta.js +5 -0
  94. package/lib/require-attr/index.js +4 -1
  95. package/lib/require-datetime/index.js +3 -2
  96. package/lib/require-dialog-autofocus/index.js +8 -6
  97. package/lib/require-element/index.js +8 -2
  98. package/lib/require-h1/index.js +4 -8
  99. package/lib/require-landmark-label/index.js +17 -15
  100. package/lib/role-supports-aria-prop/index.js +2 -2
  101. package/lib/sizes-auto-requires-lazy-loading/index.d.ts +4 -2
  102. package/lib/sizes-auto-requires-lazy-loading/index.js +4 -2
  103. package/lib/usemap-references-map/index.js +12 -3
  104. package/lib/valid-speculation-rules/index.js +1 -1
  105. package/lib/wai-aria/checkings/contradictory-aria-prop.js +2 -2
  106. package/lib/wai-aria/checkings/interaction-in-hidden.js +6 -2
  107. package/lib/wai-aria/checkings/presentational-children.js +38 -17
  108. package/lib/wai-aria/checkings/redundant-aria-prop.js +1 -1
  109. package/lib/wai-aria/checkings/required-accessibility-parent-role.js +4 -2
  110. package/lib/wai-aria/checkings/required-owned-elements.d.ts +2 -0
  111. package/lib/wai-aria/checkings/required-owned-elements.js +175 -21
  112. package/lib/wai-aria/checkings/required-prop.js +2 -1
  113. package/lib/wai-aria/checkings/tab-requires-tabpanel.js +10 -6
  114. package/lib/wai-aria/checkings/value.d.ts +1 -4
  115. package/lib/wai-aria/checkings/value.js +8 -19
  116. package/package.json +14 -11
package/lib/helpers.js CHANGED
@@ -1,5 +1,100 @@
1
- import { escapeCSS } from '@markuplint/ml-spec';
2
- import { asciiLowerCase } from '@markuplint/shared';
1
+ import { getDOMValue, getRenderedChildNodes } from '@markuplint/ml-core';
2
+ import { asciiLowerCase, decodeEntities } from '@markuplint/shared';
3
+ export { getDOMValue };
4
+ /**
5
+ * The element that an id reference written in an attribute of `el` resolves to: the first element
6
+ * of the tree `el` is in whose id is the reference. The content of a `template` element and a
7
+ * declarative shadow root are trees of their own, so a reference there does not reach an id of the
8
+ * document, nor the other way around.
9
+ *
10
+ * The reference is compared with the character references in the markup resolved, as the id is.
11
+ *
12
+ * @template T - The rule configuration value type
13
+ * @template O - The rule options type
14
+ * @param el - The element that has the attribute
15
+ * @param reference - The value of the attribute, or one token of it, as written in the markup
16
+ * @param isExpression - Whether the value is written as code, which has no character reference in it
17
+ * @returns The element, or `null` when the tree has none, or `el` is in no tree
18
+ * @see https://html.spec.whatwg.org/multipage/dom.html#the-id-attribute
19
+ */
20
+ export function getElementByIdInTree(
21
+ // eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types
22
+ el, reference, isExpression = false) {
23
+ const root = el.getRootNode();
24
+ if (root.is(root.DOCUMENT_NODE) || root.is(root.DOCUMENT_FRAGMENT_NODE)) {
25
+ return root.getElementById(isExpression ? reference : decodeEntities(reference));
26
+ }
27
+ return null;
28
+ }
29
+ /**
30
+ * Whether the element is not an element of the output, so that what is written in it is rendered in
31
+ * place of it:
32
+ *
33
+ * - a fragment (`<>` of JSX), which `childNodes` already flattens;
34
+ * - a `template` element with a directive attribute (Vue's `<template v-if>`, `v-for`, `v-slot`).
35
+ * It is not an HTML `template` element: the template engine renders its children in place of it
36
+ * and drops it. A spread attribute (`{...props}`) is not such a directive: it sets attributes of
37
+ * the element, which stays.
38
+ *
39
+ * `isTransparent` of `@markuplint/ml-core` (`src/ml-dom/helper/tree-scope.ts`), which is not exported,
40
+ * decides the same of an element for the tree boundaries (`getRootNode()`, the shadow host): a kind
41
+ * added here belongs to it too.
42
+ *
43
+ * @template T - The rule configuration value type
44
+ * @template O - The rule options type
45
+ * @param el - An element
46
+ * @returns `true` if the output has no element for it
47
+ */
48
+ export function isRenderedAway(
49
+ // eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types
50
+ el) {
51
+ if (el.isFragment) {
52
+ return true;
53
+ }
54
+ if (el.localName !== 'template') {
55
+ return false;
56
+ }
57
+ for (const attr of el.attributes) {
58
+ if (attr.isDirective && attr.localName !== '#spread') {
59
+ return true;
60
+ }
61
+ }
62
+ return false;
63
+ }
64
+ /**
65
+ * Whether the element is a component whose output is not known, so that it may render an element that
66
+ * a rule looks for among the ancestors (`MLElement#isUnresolvedComponent`). What the parser sees as a
67
+ * component but renders its children in place is not one: what {@link isRenderedAway} says (a
68
+ * fragment such as `React.Fragment` or Astro's `Fragment`, a `template` with a directive).
69
+ *
70
+ * @template T - The rule configuration value type
71
+ * @template O - The rule options type
72
+ * @param el - An ancestor of the element
73
+ * @returns `true` if the element may render what is not known
74
+ */
75
+ export function isUnknownOutput(
76
+ // eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types
77
+ el) {
78
+ return !isRenderedAway(el) && el.isUnresolvedComponent();
79
+ }
80
+ /**
81
+ * The elements that match the selector in the tree `el` is in (see {@link getElementByIdInTree}).
82
+ *
83
+ * @template T - The rule configuration value type
84
+ * @template O - The rule options type
85
+ * @param el - An element of the tree
86
+ * @param selectors - A selector
87
+ * @returns The elements in the order of the markup, none when `el` is in no tree
88
+ */
89
+ export function querySelectorAllInTree(
90
+ // eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types
91
+ el, selectors) {
92
+ const root = el.getRootNode();
93
+ if (root.is(root.DOCUMENT_NODE) || root.is(root.DOCUMENT_FRAGMENT_NODE)) {
94
+ return [...root.querySelectorAll(selectors)];
95
+ }
96
+ return [];
97
+ }
3
98
  /** A `null`/`undefined` condition means the attribute applies unconditionally. */
4
99
  export function attrMatches(
5
100
  // eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types
@@ -30,9 +125,11 @@ node, condition) {
30
125
  }
31
126
  }
32
127
  }
33
- // Whether the attribute is the one that the selector names is up to the attribute (`isNamedBy`)
128
+ // Whether the attribute is the one that the selector names is up to the attribute (`isNamedBy`).
129
+ // An attribute that the framework does not output (`type={null}`) is known not to be there, so the
130
+ // condition is decided: it is not met.
34
131
  const names = [...referencedNames];
35
- return node.attributes.some(attr => attr.isDynamicValue && names.some(name => attr.isNamedBy(name)));
132
+ return node.attributes.some(attr => attr.isDynamicValue && !attr.isAbsent && names.some(name => attr.isNamedBy(name)));
36
133
  }
37
134
  /**
38
135
  * Memoizes `fn` by the identity of its argument. Use it for an object that is one object for every
@@ -118,6 +215,20 @@ attr) {
118
215
  export function namespaceKeyOf(attr) {
119
216
  return attr.ownerElement.elementType === 'authored' ? asciiLowerCase(attr.name) : attr.nameKey;
120
217
  }
218
+ /**
219
+ * The `aria-*` attributes that the element has. An attribute that the framework does not output
220
+ * (`aria-label={null}`, `MLAttr#isAbsent`) is not one of them: the DOM does not have it, so what a rule
221
+ * says of an attribute of the element (it is unsupported, deprecated, redundant, or its value is
222
+ * invalid) is not said of it.
223
+ *
224
+ * @param el - The element
225
+ * @returns The `aria-*` attributes that are output
226
+ */
227
+ export function getAriaAttrs(
228
+ // eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types
229
+ el) {
230
+ return el.attributes.filter(attr => !attr.isAbsent && isAriaAttr(attr));
231
+ }
121
232
  /**
122
233
  * Whether the attribute is an `aria-*` one, told by {@link namespaceKeyOf}.
123
234
  *
@@ -259,20 +370,60 @@ export function toNormalizedValue(value, spec) {
259
370
  }
260
371
  return normalized;
261
372
  }
262
- export function accnameMayBeMutable(
373
+ /**
374
+ * Whether a child of an element may stand for children that are not known: a block of a preprocessor
375
+ * that has no `blockBehavior` (an output such as `{value}` or an `include`), a `slot`, or a component
376
+ * whose output is not known (`MLElement#isUnresolvedComponent`).
377
+ *
378
+ * It is for a rule that asks which children an element has (the required children, the elements that
379
+ * satisfy a query). It looks at the child alone: a descendant of the child is not a child of the
380
+ * element, so what it renders cannot become the child that the rule asks for, and the attributes of
381
+ * a child do not decide whether the child exists. `MLElement#hasMutableChildren` looks at every
382
+ * descendant, which is the question for a rule that asks what the descendants are or have.
383
+ * `require-datetime` asks about the text, which any descendant may change, and looks at every
384
+ * descendant for an unresolved component itself.
385
+ *
386
+ * @template T - The rule configuration value type
387
+ * @template O - The rule options type
388
+ * @param node - A child node of the element
389
+ * @param components - Whether a component whose output is not known counts. It does not when the
390
+ * rule evaluates the components by the name that they are written with
391
+ * @returns True if the node may stand for children that are not known
392
+ */
393
+ export function mayRenderUnknownChildren(
263
394
  // eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types
264
- el,
395
+ node, components = true) {
396
+ if (node.is(node.MARKUPLINT_PREPROCESSOR_BLOCK)) {
397
+ return !node.blockBehavior;
398
+ }
399
+ return node.is(node.ELEMENT_NODE) && (node.localName === 'slot' || (components && node.isUnresolvedComponent()));
400
+ }
401
+ export function accnameMayBeMutable(
265
402
  // eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types
266
- document) {
267
- if (el.hasMutableAttributes() || el.hasMutableChildren(true)) {
403
+ el) {
404
+ if (el.hasMutableAttributes() || el.hasMutableChildren(true, isSlotOfUnknownAssignment)) {
268
405
  return true;
269
406
  }
270
- const ownedLabel = getOwnedLabel(el, document);
271
- if (ownedLabel && (ownedLabel.hasMutableAttributes() || ownedLabel.hasMutableChildren(true))) {
407
+ const ownedLabel = getOwnedLabel(el);
408
+ if (ownedLabel &&
409
+ (ownedLabel.hasMutableAttributes() || ownedLabel.hasMutableChildren(true, isSlotOfUnknownAssignment))) {
272
410
  return true;
273
411
  }
274
412
  return false;
275
413
  }
414
+ /**
415
+ * Whether a `slot` renders what the markup does not tell: the nodes assigned to it, or its children, are
416
+ * not known (`MLElement#getAssignedNodes` is `null`). A slot whose assignment is known renders them, which
417
+ * the rule reads itself.
418
+ *
419
+ * @param slot - A `slot` element
420
+ * @returns `true` if what the slot renders is not known
421
+ */
422
+ function isSlotOfUnknownAssignment(
423
+ // eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types
424
+ slot) {
425
+ return slot.getAssignedNodes() === null;
426
+ }
276
427
  /**
277
428
  * Whether the accessible name of `el` may be rendered by content the pretender
278
429
  * does not know: an unknown entry (`{ dynamic: true }`) in the `contents` of
@@ -286,41 +437,48 @@ document) {
286
437
  */
287
438
  export function accnameMayComeFromUnknownContent(
288
439
  // eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types
289
- el,
290
- // eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types
291
- document) {
440
+ el) {
292
441
  if (hasUnknownContent(el)) {
293
442
  return true;
294
443
  }
295
- const labelledby = el.getAttribute('aria-labelledby');
444
+ const labelledbyAttr = el.getAttributeNode('aria-labelledby');
445
+ const labelledby = labelledbyAttr?.value;
296
446
  if (labelledby) {
297
447
  const referenced = labelledby
298
448
  .trim()
299
449
  .split(/\s+/)
300
- .map(id => document.querySelector(`#${escapeCSS(id)}`));
450
+ .map(id => getElementByIdInTree(el, id, labelledbyAttr.isExpressionValue));
301
451
  if (referenced.some(target => target && hasUnknownContent(target))) {
302
452
  return true;
303
453
  }
304
454
  }
305
- const ownedLabel = getOwnedLabel(el, document);
455
+ const ownedLabel = getOwnedLabel(el);
306
456
  return !!ownedLabel && hasUnknownContent(ownedLabel);
307
457
  }
308
458
  /**
309
- * Walks the same children the accname resolver of `@markuplint/ml-core` does:
310
- * for a pretended element, the children the component renders (`as.childNodes`,
311
- * placed into `contents`), so that a nested component contributes its own
312
- * `contents`; otherwise the children as written. A hidden child is skipped
313
- * because the name computation never reads it (`isHidden` in that resolver).
459
+ * Walks the children {@link getRenderedChildNodes} returns. A hidden child is
460
+ * skipped because the name computation never reads it (`isHidden` in that resolver).
461
+ *
462
+ * A `template` whose shadow root is only maybe attached returns `true` before
463
+ * that walk. Its content is omitted from the computed name, so an ancestor
464
+ * whose computed name is empty is not reported: the rule calls this only after
465
+ * the computed name is empty. Light DOM text that remains in the computed name
466
+ * is still a name, and the rule does not reach this function.
467
+ *
468
+ * Unknown `contents` of a pretended component count only when no declarative
469
+ * shadow root is attached to it: the shadow root is rendered in their place.
314
470
  */
315
471
  function hasUnknownContent(
316
472
  // eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types
317
473
  el) {
318
474
  const context = el.pretenderContext;
319
- if (context?.type === 'pretender' && context.slotContent?.mutable) {
475
+ if (context?.type === 'pretender' && context.slotContent?.mutable && !el.declarativeShadowRoot) {
320
476
  return true;
321
477
  }
322
- const given = context?.type === 'pretender' ? [...context.as.childNodes] : [...el.childNodes];
323
- const children = context?.type === 'pretender' && context.slotContent ? context.slotContent.fill(given) : given;
478
+ if (context?.type !== 'pretender' && el.isHTMLTemplateElement && el.declarativeShadowRootAttachment === 'unknown') {
479
+ return true;
480
+ }
481
+ const children = [...getRenderedChildNodes(el)];
324
482
  return children.some(child => child.is(child.ELEMENT_NODE) &&
325
483
  child.getAttribute('aria-hidden') !== 'true' &&
326
484
  !child.hasAttribute('hidden') &&
@@ -329,17 +487,17 @@ el) {
329
487
  export const labelable = ['button', 'input:not([type=hidden])', 'meter', 'output', 'progress', 'select', 'textarea'];
330
488
  export function getOwnedLabel(
331
489
  // eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types
332
- el,
333
- // eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types
334
- document) {
490
+ el) {
335
491
  if (!labelable.some(cond => el.matches(cond))) {
336
492
  return null;
337
493
  }
338
494
  let ownedLabel = el.closest('label');
339
495
  if (!ownedLabel) {
340
- const id = el.getAttribute('id');
496
+ const id = getDOMValue(el.getAttributeNode('id'));
341
497
  if (id) {
342
- ownedLabel = document.querySelector(`label[for="${id}"]`);
498
+ // The label is looked for in the tree of the element, and the two values are compared as the DOM has them
499
+ ownedLabel =
500
+ querySelectorAllInTree(el, 'label[for]').find(label => getDOMValue(label.getAttributeNode('for')) === id) ?? null;
343
501
  }
344
502
  }
345
503
  return ownedLabel;
@@ -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
  * Require the `list` attribute on an `<input>` element to reference an
@@ -28,8 +29,7 @@ export default createRule({
28
29
  return;
29
30
  if (listAttr.isDynamicValue)
30
31
  return;
31
- const targetId = listAttr.value;
32
- const target = [...document.querySelectorAll('[id]')].find(candidate => candidate.getAttribute('id') === targetId);
32
+ const target = getElementByIdInTree(el, listAttr.value, listAttr.isExpressionValue);
33
33
  // Existence is checked by `no-refer-to-non-existent-id`. If the ID does not resolve
34
34
  // here, leave that diagnostic to the dedicated rule and stay silent.
35
35
  if (!target)
@@ -40,7 +40,7 @@ export default createRule({
40
40
  line: listAttr.valueNode?.startLine,
41
41
  col: listAttr.valueNode?.startCol,
42
42
  raw: listAttr.valueNode?.raw,
43
- message: t('The "{0}" attribute of the "{1}" element must reference a "{2}" element', 'list', 'input', 'datalist'),
43
+ message: t('The "{0*}" attribute of the "{1*}" element must reference a "{2*}" element', 'list', 'input', 'datalist'),
44
44
  });
45
45
  }
46
46
  });
@@ -1,4 +1,5 @@
1
1
  import { createRule } from '@markuplint/ml-core';
2
+ import { getDOMValue } from '../helpers.js';
2
3
  import meta from './meta.js';
3
4
  /**
4
5
  * Rule that requires every `itemprop` to belong to an item.
@@ -28,24 +29,40 @@ export default createRule({
28
29
  // once and then check each `[itemprop]` against the union set.
29
30
  // `itemref=" "` (whitespace-only) yields zero tokens and contributes
30
31
  // no entries to the set; orphans referencing such a host stay orphans.
31
- const itemrefIds = new Set();
32
- const itemrefHosts = document.querySelectorAll('[itemscope][itemref]');
32
+ // The ids are collected by tree: the content of a `template` element and a declarative shadow
33
+ // root are trees of their own, which an `itemref` does not reach out of, and the elements of
34
+ // every tree are checked.
35
+ // An `itemref` that is not known until it is rendered may reach any element of its tree, so
36
+ // that tree is not checked.
37
+ const itemrefIdsOfTree = new Map();
38
+ const treesWithDynamicItemref = new Set();
39
+ const itemrefHosts = document.deepQuerySelectorAll('[itemscope][itemref]');
33
40
  for (const host of itemrefHosts) {
34
- const value = host.getAttribute('itemref') ?? '';
35
- for (const token of value.split(/\s+/)) {
36
- if (token)
37
- itemrefIds.add(token);
41
+ const root = host.getRootNode();
42
+ let itemrefIds = itemrefIdsOfTree.get(root);
43
+ if (!itemrefIds) {
44
+ itemrefIds = new Set();
45
+ itemrefIdsOfTree.set(root, itemrefIds);
46
+ }
47
+ const itemref = host.getAttributeNode('itemref');
48
+ if (itemref?.isDynamicValue) {
49
+ treesWithDynamicItemref.add(root);
50
+ }
51
+ for (const token of itemref?.value.split(/\s+/) ?? []) {
52
+ const id = token ? getDOMValue(itemref, token) : null;
53
+ if (id != null)
54
+ itemrefIds.add(id);
38
55
  }
39
56
  }
40
- const itempropEls = document.querySelectorAll('[itemprop]');
57
+ const itempropEls = document.deepQuerySelectorAll('[itemprop]');
41
58
  for (const el of itempropEls) {
42
59
  const hasAncestorScope = el.closest('[itemscope]') != null;
43
- const id = el.getAttribute('id');
44
- const isItemrefTarget = id != null && id !== '' && itemrefIds.has(id);
45
- if (!hasAncestorScope && !isItemrefTarget) {
60
+ const id = getDOMValue(el.getAttributeNode('id'));
61
+ const isItemrefTarget = id != null && id !== '' && !!itemrefIdsOfTree.get(el.getRootNode())?.has(id);
62
+ if (!hasAncestorScope && !isItemrefTarget && !treesWithDynamicItemref.has(el.getRootNode())) {
46
63
  report({
47
64
  scope: el,
48
- message: t('{0} must {1}', t('the {0}', 'element'), 'belong to an item via an ancestor `itemscope` or be referenced by an `itemref`'),
65
+ message: t('{0} must belong to an item via an ancestor `itemscope` or be referenced by an `itemref`', t('the {0}', 'element')),
49
66
  });
50
67
  }
51
68
  }
@@ -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
  * Labelable elements per HTML LS §4.10.2 *Categories: Labelable elements*.
@@ -51,8 +52,7 @@ export default createRule({
51
52
  return;
52
53
  if (forAttr.isDynamicValue)
53
54
  return;
54
- const targetId = forAttr.value;
55
- const target = [...document.querySelectorAll('[id]')].find(candidate => candidate.getAttribute('id') === targetId);
55
+ const target = getElementByIdInTree(el, forAttr.value, forAttr.isExpressionValue);
56
56
  // Existence is checked by `no-refer-to-non-existent-id`. If the ID does not resolve
57
57
  // here, leave that diagnostic to the dedicated rule and stay silent.
58
58
  if (!target)
@@ -63,7 +63,7 @@ export default createRule({
63
63
  line: forAttr.valueNode?.startLine,
64
64
  col: forAttr.valueNode?.startCol,
65
65
  raw: forAttr.valueNode?.raw,
66
- message: t('The "{0}" attribute of the "{1}" element must reference a labelable element', 'for', 'label'),
66
+ message: t('The "{0*}" attribute of the "{1*}" element must reference a labelable element', 'for', 'label'),
67
67
  });
68
68
  }
69
69
  });
@@ -4,6 +4,23 @@
4
4
  * this rule performs — a `<label>` with an excess of descendant controls is
5
5
  * `label-no-multiple-controls`'s responsibility, which also accounts for the
6
6
  * `for`-references-an-external-control case that this rule does not see.
7
+ *
8
+ * An element inside a conditional preprocessor block counts, because the
9
+ * branch that renders it puts that element in the label's tree:
10
+ *
11
+ * > If the `for` attribute is not specified, but the `label` element has a labelable element descendant, then the first such descendant in tree order is the `label` element's labeled control.
12
+ *
13
+ * @see https://html.spec.whatwg.org/multipage/forms.html#labeled-control
14
+ *
15
+ * `children` does not list that element, because a conditional block is not
16
+ * flattened into `childNodes`. This rule still treats any element in
17
+ * `children` or in a `conditionalChildNodes()` branch as enough; it does not
18
+ * check that the element is labelable.
19
+ *
20
+ * A block that is not a fragment and has no `blockBehavior` is neither
21
+ * flattened nor a branch, so an element that exists only inside it is not
22
+ * counted. A Svelte `{#snippet}` is that kind of block: its body is a
23
+ * definition, not a branch rendered where the block is written.
7
24
  */
8
25
  declare const _default: Readonly<import("@markuplint/ml-core").RuleSeed<import("@markuplint/ml-core").RuleConfigValue, undefined>>;
9
26
  export default _default;
@@ -6,6 +6,23 @@ import meta from './meta.js';
6
6
  * this rule performs — a `<label>` with an excess of descendant controls is
7
7
  * `label-no-multiple-controls`'s responsibility, which also accounts for the
8
8
  * `for`-references-an-external-control case that this rule does not see.
9
+ *
10
+ * An element inside a conditional preprocessor block counts, because the
11
+ * branch that renders it puts that element in the label's tree:
12
+ *
13
+ * > If the `for` attribute is not specified, but the `label` element has a labelable element descendant, then the first such descendant in tree order is the `label` element's labeled control.
14
+ *
15
+ * @see https://html.spec.whatwg.org/multipage/forms.html#labeled-control
16
+ *
17
+ * `children` does not list that element, because a conditional block is not
18
+ * flattened into `childNodes`. This rule still treats any element in
19
+ * `children` or in a `conditionalChildNodes()` branch as enough; it does not
20
+ * check that the element is labelable.
21
+ *
22
+ * A block that is not a fragment and has no `blockBehavior` is neither
23
+ * flattened nor a branch, so an element that exists only inside it is not
24
+ * counted. A Svelte `{#snippet}` is that kind of block: its body is a
25
+ * definition, not a branch rendered where the block is written.
9
26
  */
10
27
  export default createRule({
11
28
  meta: meta,
@@ -21,7 +38,17 @@ export default createRule({
21
38
  if (el.pretenderContext?.type === 'pretender' && !el.hasAttribute('as')) {
22
39
  return;
23
40
  }
24
- if (el.children.length === 0 && !el.hasAttribute('for')) {
41
+ // `for` already associates the label, so the branch walk is wasted there.
42
+ // `children` already includes elements of an `each` block. A conditional
43
+ // block is not flattened, so look through its branches only when
44
+ // `children` is empty. One branch that contains an element is enough:
45
+ // the branches that never render together stay out of `children`.
46
+ if (el.hasAttribute('for')) {
47
+ return;
48
+ }
49
+ const hasElementInSomeBranch = el.children.length > 0 ||
50
+ el.conditionalChildNodes().some(branch => [...branch].some(node => node.is(node.ELEMENT_NODE)));
51
+ if (!hasElementInSomeBranch) {
25
52
  report({
26
53
  scope: el,
27
54
  message: t('{0} should associate with {1}', t('The "{0*}" {1}', 'label', 'element'), t('a {0}', 'control')),
@@ -21,11 +21,10 @@
21
21
  * `label-for-references-labelable`. This rule only inspects the label
22
22
  * subtree once the target's category has been classified.
23
23
  *
24
- * The `for` target lookup uses `document.querySelectorAll('[id]')` per
25
- * matching label, not a cached id map. Kept in sync with
26
- * `label-for-references-labelable` on purpose — the number of `<label>`
27
- * elements per document is typically small, so a shared id-index would
28
- * amortize poorly and split rule ownership across a helper module.
24
+ * The `for` target is resolved as `label-for-references-labelable` does it: in the tree of the
25
+ * label, so an id in the content of a `template` element is not the target of a label outside of
26
+ * it. The controls under the label are the ones of its own tree as well: what is in the content of
27
+ * a `template` element under the label is not a descendant of it.
29
28
  */
30
29
  declare const _default: Readonly<import("@markuplint/ml-core").RuleSeed<boolean, null>>;
31
30
  export default _default;
@@ -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
  * The HTML LS list of form-control descendants that may appear under a
@@ -48,11 +49,10 @@ const LABELABLE_SELECTOR = [
48
49
  * `label-for-references-labelable`. This rule only inspects the label
49
50
  * subtree once the target's category has been classified.
50
51
  *
51
- * The `for` target lookup uses `document.querySelectorAll('[id]')` per
52
- * matching label, not a cached id map. Kept in sync with
53
- * `label-for-references-labelable` on purpose — the number of `<label>`
54
- * elements per document is typically small, so a shared id-index would
55
- * amortize poorly and split rule ownership across a helper module.
52
+ * The `for` target is resolved as `label-for-references-labelable` does it: in the tree of the
53
+ * label, so an id in the content of a `template` element is not the target of a label outside of
54
+ * it. The controls under the label are the ones of its own tree as well: what is in the content of
55
+ * a `template` element under the label is not a descendant of it.
56
56
  */
57
57
  export default createRule({
58
58
  meta: meta,
@@ -72,7 +72,7 @@ export default createRule({
72
72
  const hasExternalLabeledControl = (() => {
73
73
  if (!forAttr || forAttr.isDynamicValue)
74
74
  return false;
75
- const target = [...document.querySelectorAll('[id]')].find(candidate => candidate.getAttribute('id') === forAttr.value);
75
+ const target = getElementByIdInTree(el, forAttr.value, forAttr.isExpressionValue);
76
76
  if (!target)
77
77
  return false;
78
78
  // A descendant with a matching id is still the label's labeled control
@@ -86,7 +86,7 @@ export default createRule({
86
86
  for (const control of controls) {
87
87
  report({
88
88
  scope: control,
89
- message: t('The "{0}" element must not contain a form-control descendant when the "{1}" attribute references an external labelable element', 'label', 'for'),
89
+ message: t('The "{0*}" element must not contain a form-control descendant when the "{1*}" attribute references an external labelable element', 'label', 'for'),
90
90
  });
91
91
  }
92
92
  return;
@@ -96,7 +96,7 @@ export default createRule({
96
96
  for (const control of controls.slice(1)) {
97
97
  report({
98
98
  scope: control,
99
- message: t('The "{0}" element may contain at most one form-control descendant ({1})', 'label', 'button, input, meter, output, progress, select, or textarea'),
99
+ message: t('The "{0*}" element may contain at most one form-control descendant ({1})', 'label', 'button, input, meter, output, progress, select, or textarea'),
100
100
  });
101
101
  }
102
102
  });
@@ -1,4 +1,4 @@
1
- import type { Element } from '@markuplint/ml-core';
1
+ import type { Document, Element } from '@markuplint/ml-core';
2
2
  import type { PlainData } from '@markuplint/ml-config';
3
3
  /**
4
4
  * Shared by the two rules split out of the former `landmark-roles` rule:
@@ -23,6 +23,18 @@ export type RoleSet<O extends PlainData> = {
23
23
  export declare function computeLandmarkRoles<O extends PlainData>(document: {
24
24
  querySelectorAll(selector: string): Iterable<Element<boolean, O>>;
25
25
  }): RoleSet<O>;
26
+ /**
27
+ * Collects the landmark-role elements of every tree of the document: the document itself, and the
28
+ * content of each `template` element and each declarative shadow root, which are trees of their own.
29
+ * Where the content of a `template` ends up in a document is not known from the markup, so its
30
+ * landmarks are not counted with the ones of the document, nor does an ancestor of the document make
31
+ * them nested.
32
+ *
33
+ * @template O - The rule options type
34
+ * @param document - The document to search.
35
+ * @returns The mapping from role to its matched elements, one for each tree.
36
+ */
37
+ export declare function computeLandmarkRolesOfEachTree<O extends PlainData>(document: Document<boolean, O>): RoleSet<O>[];
26
38
  /**
27
39
  * Collects all UUIDs of elements that have landmark roles.
28
40
  *
@@ -60,6 +60,28 @@ document) {
60
60
  roles.contentinfo.push(...footers);
61
61
  return roles;
62
62
  }
63
+ /**
64
+ * Collects the landmark-role elements of every tree of the document: the document itself, and the
65
+ * content of each `template` element and each declarative shadow root, which are trees of their own.
66
+ * Where the content of a `template` ends up in a document is not known from the markup, so its
67
+ * landmarks are not counted with the ones of the document, nor does an ancestor of the document make
68
+ * them nested.
69
+ *
70
+ * @template O - The rule options type
71
+ * @param document - The document to search.
72
+ * @returns The mapping from role to its matched elements, one for each tree.
73
+ */
74
+ export function computeLandmarkRolesOfEachTree(
75
+ // eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types
76
+ document) {
77
+ const trees = [document];
78
+ for (const template of document.deepQuerySelectorAll('template')) {
79
+ if (template.content) {
80
+ trees.push(template.content);
81
+ }
82
+ }
83
+ return trees.map(tree => computeLandmarkRoles(tree));
84
+ }
63
85
  /**
64
86
  * An element already collected through its explicit `role` attribute (e.g.
65
87
  * `<header role="banner">`) must not be added a second time by the implicit
@@ -122,28 +122,28 @@ function validateWhatwgKeyword(def, context, t) {
122
122
  switch (context) {
123
123
  case 'link': {
124
124
  if (def.link === 'not allowed') {
125
- return t('The "{0*}" {1} is not allowed on the {2}', def.keyword, 'keyword', '"link" element');
125
+ return t('The "{0*}" {1} is not allowed on {2}', def.keyword, 'keyword', t('the "{0*}" {1}', 'link', 'element'));
126
126
  }
127
127
  return null;
128
128
  }
129
129
  case 'body-link': {
130
130
  if (def.link === 'not allowed') {
131
- return t('The "{0*}" {1} is not allowed on the {2}', def.keyword, 'keyword', '"link" element');
131
+ return t('The "{0*}" {1} is not allowed on {2}', def.keyword, 'keyword', t('the "{0*}" {1}', 'link', 'element'));
132
132
  }
133
133
  if (def.bodyOk !== 'Yes') {
134
- return t('The "{0*}" {1} is not allowed on the {2} inside the {3}', def.keyword, 'keyword', '"link" element', '"body" element');
134
+ return t('The "{0*}" {1} is not allowed on {2} inside {3}', def.keyword, 'keyword', t('the "{0*}" {1}', 'link', 'element'), t('the "{0*}" {1}', 'body', 'element'));
135
135
  }
136
136
  return null;
137
137
  }
138
138
  case 'a-area': {
139
139
  if (def.a === 'not allowed') {
140
- return t('The "{0*}" {1} is not allowed on the {2}', def.keyword, 'keyword', '"a" element');
140
+ return t('The "{0*}" {1} is not allowed on {2}', def.keyword, 'keyword', t('the "{0*}" {1}', 'a', 'element'));
141
141
  }
142
142
  return null;
143
143
  }
144
144
  case 'form': {
145
145
  if (def.form === 'not allowed') {
146
- return t('The "{0*}" {1} is not allowed on the {2}', def.keyword, 'keyword', '"form" element');
146
+ return t('The "{0*}" {1} is not allowed on {2}', def.keyword, 'keyword', t('the "{0*}" {1}', 'form', 'element'));
147
147
  }
148
148
  return null;
149
149
  }
@@ -161,19 +161,19 @@ function validateMicroformatContext(def, context, t) {
161
161
  case 'link':
162
162
  case 'body-link': {
163
163
  if (!def.link) {
164
- return t('The "{0*}" {1} is not allowed on the {2}', def.keyword, 'keyword', '"link" element');
164
+ return t('The "{0*}" {1} is not allowed on {2}', def.keyword, 'keyword', t('the "{0*}" {1}', 'link', 'element'));
165
165
  }
166
166
  return null;
167
167
  }
168
168
  case 'a-area': {
169
169
  if (!def.a) {
170
- return t('The "{0*}" {1} is not allowed on the {2}', def.keyword, 'keyword', '"a" element');
170
+ return t('The "{0*}" {1} is not allowed on {2}', def.keyword, 'keyword', t('the "{0*}" {1}', 'a', 'element'));
171
171
  }
172
172
  return null;
173
173
  }
174
174
  case 'form': {
175
175
  // Microformats don't define form context; reject
176
- return t('The "{0*}" {1} is not allowed on the {2}', def.keyword, 'keyword', '"form" element');
176
+ return t('The "{0*}" {1} is not allowed on {2}', def.keyword, 'keyword', t('the "{0*}" {1}', 'form', 'element'));
177
177
  }
178
178
  }
179
179
  }