@markuplint/rules 5.3.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 (87) hide show
  1. package/CHANGELOG.md +28 -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 +7 -1
  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.d.ts +17 -6
  81. package/lib/wai-aria/checkings/presentational-children.js +259 -25
  82. package/lib/wai-aria/checkings/required-accessibility-parent-role.js +2 -1
  83. package/lib/wai-aria/checkings/required-prop.js +2 -1
  84. package/lib/wai-aria/checkings/tab-requires-tabpanel.js +10 -6
  85. package/lib/wai-aria/checkings/value.d.ts +1 -4
  86. package/lib/wai-aria/checkings/value.js +5 -17
  87. package/package.json +11 -11
@@ -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
@@ -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
@@ -10,6 +10,10 @@ export default createRule({
10
10
  if (!attr.valueNode) {
11
11
  return;
12
12
  }
13
+ // The value of an attribute that is not known, or that is not output, is not a name to judge
14
+ if (attr.isDynamicValue) {
15
+ return;
16
+ }
13
17
  const attrSpecs = getAttrSpecs(attr.ownerElement, document.specs);
14
18
  if (!attrSpecs) {
15
19
  return;
@@ -13,7 +13,13 @@ export default createRule({
13
13
  return;
14
14
  if (!elSpec.globalAttrs['#ARIAAttrs'])
15
15
  return;
16
- report(checkingPresentationalChildren({ el }));
16
+ for (const attr of el.attributes) {
17
+ // An attribute that the framework does not output is not an attribute of the element
18
+ if (attr.isAbsent) {
19
+ continue;
20
+ }
21
+ report(checkingPresentationalChildren({ attr, el }));
22
+ }
17
23
  });
18
24
  },
19
25
  });
@@ -1,5 +1,5 @@
1
1
  import { createRule, getSpec } from '@markuplint/ml-core';
2
- import { Collection, isAriaAttr } from '../helpers.js';
2
+ import { Collection, getAriaAttrs } from '../helpers.js';
3
3
  import meta from './meta.js';
4
4
  /**
5
5
  * Extracted from the former `wai-aria` umbrella rule (#3989), which always
@@ -22,7 +22,7 @@ export default createRule({
22
22
  return;
23
23
  }
24
24
  const roleAttr = el.getAttributeNode('role');
25
- const propAttrs = el.attributes.filter(attr => isAriaAttr(attr));
25
+ const propAttrs = getAriaAttrs(el);
26
26
  const ariaAttrs = new Collection(roleAttr, ...propAttrs);
27
27
  for (const ariaAttr of ariaAttrs) {
28
28
  report({
@@ -10,7 +10,8 @@ export default createRule({
10
10
  if (!attrSpec) {
11
11
  return;
12
12
  }
13
- if (attr.isDynamicValue) {
13
+ // The value of an expression is not written as the value of an HTML attribute
14
+ if (attr.isExpressionValue) {
14
15
  return;
15
16
  }
16
17
  const name = attr.name;
@@ -1,4 +1,9 @@
1
1
  /**
2
+ * The target of a fragment link is an element of the document tree: the content of a `template`
3
+ * element and a shadow root are not where the navigation scrolls to. So the ids and names are the ones
4
+ * of the document (`document.querySelectorAll`, not the other trees), and a link in any tree is checked
5
+ * against them. A dynamic id in the document, not in another tree, stops the check.
6
+ *
2
7
  * Split out of the former `no-refer-to-non-existent-id` rule. Unlike the
3
8
  * `DOMID`-typed attributes and ARIA ID references that rule covers, HTML LS
4
9
  * does not treat a broken hyperlink fragment as a conformance violation —
@@ -4,6 +4,11 @@ import meta from './meta.js';
4
4
  /** CSS selector matching hyperlink elements that have an `href` attribute. */
5
5
  const HYPERLINK_SELECTOR = 'a[href], area[href]';
6
6
  /**
7
+ * The target of a fragment link is an element of the document tree: the content of a `template`
8
+ * element and a shadow root are not where the navigation scrolls to. So the ids and names are the ones
9
+ * of the document (`document.querySelectorAll`, not the other trees), and a link in any tree is checked
10
+ * against them. A dynamic id in the document, not in another tree, stops the check.
11
+ *
7
12
  * Split out of the former `no-refer-to-non-existent-id` rule. Unlike the
8
13
  * `DOMID`-typed attributes and ARIA ID references that rule covers, HTML LS
9
14
  * does not treat a broken hyperlink fragment as a conformance violation —
@@ -38,7 +43,8 @@ export default createRule({
38
43
  hasDynamicId = true;
39
44
  }
40
45
  if (attr.valueType !== 'code') {
41
- idList.add(decodeEntities(attr.value));
46
+ // An expression is code, not markup: its string has no character reference to decode
47
+ idList.add(attr.isExpressionValue ? attr.value : decodeEntities(attr.value));
42
48
  }
43
49
  }
44
50
  if (hasDynamicId) {
@@ -53,7 +59,7 @@ export default createRule({
53
59
  hasDynamicName = true;
54
60
  }
55
61
  if (attr.valueType !== 'code') {
56
- nameList.add(decodeEntities(attr.value));
62
+ nameList.add(attr.isExpressionValue ? attr.value : decodeEntities(attr.value));
57
63
  }
58
64
  }
59
65
  await document.walkOn('Element', el => {
@@ -4,7 +4,7 @@ export default createRule({
4
4
  meta: meta,
5
5
  defaultSeverity: 'warning',
6
6
  verify({ document, report, t }) {
7
- const brList = [...document.querySelectorAll('br')];
7
+ const brList = [...document.deepQuerySelectorAll('br')];
8
8
  for (const br of brList) {
9
9
  let next = br.nextSibling;
10
10
  while (next && next.is(next.TEXT_NODE) && !next.nodeValue?.trim()) {
@@ -2,7 +2,7 @@ import { createRule, ariaSpecs, getAttrSpecs, getSpec } from '@markuplint/ml-cor
2
2
  import { ARIA_RECOMMENDED_VERSION } from '@markuplint/ml-spec';
3
3
  import { checkingContradictoryAriaProp } from '../wai-aria/checkings/contradictory-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-implicit-props` rule (#3989): the
@@ -21,7 +21,7 @@ export default createRule({
21
21
  return;
22
22
  if (!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;
@@ -2,7 +2,7 @@ import { createRule, ariaSpecs, getSpec } from '@markuplint/ml-core';
2
2
  import { ARIA_RECOMMENDED_VERSION } from '@markuplint/ml-spec';
3
3
  import { checkingDefaultValue } from '../wai-aria/checkings/default-value.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
  export default createRule({
8
8
  meta,
@@ -15,7 +15,7 @@ export default createRule({
15
15
  return;
16
16
  if (!elSpec.globalAttrs['#ARIAAttrs'])
17
17
  return;
18
- const propAttrs = el.attributes.filter(attr => isAriaAttr(attr));
18
+ const propAttrs = getAriaAttrs(el);
19
19
  if (propAttrs.length === 0)
20
20
  return;
21
21
  const ariaVersion = el.rule.options?.version ?? document.ruleCommonSettings?.ariaVersion ?? ARIA_RECOMMENDED_VERSION;
@@ -10,6 +10,10 @@ export default createRule({
10
10
  if (!attrSpec) {
11
11
  return;
12
12
  }
13
+ // The value of an attribute that is not known, or that is not output, is not the default value
14
+ if (attr.isDynamicValue) {
15
+ return;
16
+ }
13
17
  const spec = findAttrSpec(attrSpec, attr);
14
18
  if (!spec) {
15
19
  return;
@@ -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 { checkingDeprecatedProps } from '../wai-aria/checkings/deprecated-props.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
  export default createRule({
8
8
  meta,
@@ -15,7 +15,7 @@ export default createRule({
15
15
  return;
16
16
  if (!elSpec.globalAttrs['#ARIAAttrs'])
17
17
  return;
18
- const propAttrs = el.attributes.filter(attr => isAriaAttr(attr));
18
+ const propAttrs = getAriaAttrs(el);
19
19
  if (propAttrs.length === 0)
20
20
  return;
21
21
  const ariaVersion = el.rule.options?.version ?? document.ruleCommonSettings?.ariaVersion ?? ARIA_RECOMMENDED_VERSION;
@@ -1,2 +1,17 @@
1
- declare const _default: Readonly<import("@markuplint/ml-core").RuleSeed<import("@markuplint/ml-core").RuleConfigValue, undefined>>;
1
+ import type { RuleConfigValue } from '@markuplint/ml-core';
2
+ /**
3
+ * Checks the ancestors of an element against its content model, which holds them in two forms
4
+ * that this rule reads together: `forbiddenAncestors` (a deny list, any one match is a violation)
5
+ * and `permittedAncestors` (an allow list, an ancestor that matches none is a violation).
6
+ * The walk goes up from the parent and reports the nearest ancestor that violates, once.
7
+ *
8
+ * It does not enter the content of a `template` element, and it does not judge a node that is not
9
+ * an element of the document (a fragment, a `template` that a template engine drops): see
10
+ * {@link getAncestorKind}.
11
+ *
12
+ * Known limitation: `:aria(has no name)` in an entry reads a dynamic value of `aria-label` or
13
+ * `title` as a name, as the accessible name computation does, so `<form aria-label={x}><main>`
14
+ * is reported although `x` may be empty at runtime and the form then has no name.
15
+ */
16
+ declare const _default: Readonly<import("@markuplint/ml-core").RuleSeed<RuleConfigValue, undefined>>;
2
17
  export default _default;
@@ -1,17 +1,43 @@
1
1
  import { createRule } from '@markuplint/ml-core';
2
+ import { getSpecByTagName } from '@markuplint/ml-spec';
2
3
  import meta from './meta.js';
4
+ /**
5
+ * Checks the ancestors of an element against its content model, which holds them in two forms
6
+ * that this rule reads together: `forbiddenAncestors` (a deny list, any one match is a violation)
7
+ * and `permittedAncestors` (an allow list, an ancestor that matches none is a violation).
8
+ * The walk goes up from the parent and reports the nearest ancestor that violates, once.
9
+ *
10
+ * It does not enter the content of a `template` element, and it does not judge a node that is not
11
+ * an element of the document (a fragment, a `template` that a template engine drops): see
12
+ * {@link getAncestorKind}.
13
+ *
14
+ * Known limitation: `:aria(has no name)` in an entry reads a dynamic value of `aria-label` or
15
+ * `title` as a name, as the accessible name computation does, so `<form aria-label={x}><main>`
16
+ * is reported although `x` may be empty at runtime and the form then has no name.
17
+ */
3
18
  export default createRule({
4
19
  meta: meta,
5
20
  async verify({ document, report, t }) {
6
21
  await document.walkOn('Element', el => {
7
- const elSpec = document.specs.specs.find(s => s.name === el.localName);
8
- const forbiddenAncestors = elSpec?.contentModel?.forbiddenAncestors;
9
- if (!forbiddenAncestors || forbiddenAncestors.length === 0) {
22
+ const contentModel = getSpecByTagName(document.specs.specs, el.localName, el.namespaceURI)?.contentModel;
23
+ const forbiddenAncestors = contentModel?.forbiddenAncestors ?? [];
24
+ const permittedAncestors = contentModel?.permittedAncestors;
25
+ if (forbiddenAncestors.length === 0 && !permittedAncestors) {
10
26
  return;
11
27
  }
12
28
  let ancestor = el.parentElement;
13
29
  while (ancestor) {
14
- if (forbiddenAncestors.some(selector => ancestor.matches(selector))) {
30
+ const kind = getAncestorKind(ancestor);
31
+ if (kind === 'template-content') {
32
+ break;
33
+ }
34
+ if (kind === 'transparent') {
35
+ ancestor = ancestor.parentElement;
36
+ continue;
37
+ }
38
+ const current = ancestor;
39
+ if (forbiddenAncestors.some(selector => current.matches(selector)) ||
40
+ (permittedAncestors && !matchesPermitted(current, permittedAncestors))) {
15
41
  report({
16
42
  scope: el,
17
43
  message: t('{0} must not appear as a descendant of {1}', t('the "{0}" {1}', el.localName, 'element'), t('the "{0}" {1}', ancestor.localName, 'element')),
@@ -23,3 +49,51 @@ export default createRule({
23
49
  });
24
50
  },
25
51
  });
52
+ /**
53
+ * What `ancestor` is for the walk up the tree.
54
+ *
55
+ * - `template-content`: an HTML `template` element (`isHTMLTemplateElement`). The elements in its
56
+ * content belong to a separate `DocumentFragment`, so they have no ancestors in the document:
57
+ * `<section><template><main></main></template></section>` has a `main` element with none.
58
+ * markuplint holds the content as children of the `template` element, so the walk stops here.
59
+ * A `template` of a framework that puts the children in the element itself (React, Vue: the
60
+ * `templateContent` of its spec) is an `element` below: the children are in the document.
61
+ * - `transparent`: a node that is not an element of the document, which the walk skips as if it
62
+ * were not there and does not judge:
63
+ * - a fragment (`<>` of JSX), which `childNodes` already flattens;
64
+ * - a `template` element with a directive attribute (Vue's `<template v-if>`, `v-for`, `v-slot`).
65
+ * It is not an HTML `template` element: the template engine renders its children in place of
66
+ * it and drops it.
67
+ * - `element`: any other element.
68
+ *
69
+ * @see https://html.spec.whatwg.org/multipage/scripting.html#the-template-element
70
+ */
71
+ function getAncestorKind(
72
+ // eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types
73
+ ancestor) {
74
+ if (ancestor.isFragment) {
75
+ return 'transparent';
76
+ }
77
+ if (ancestor.localName !== 'template') {
78
+ return 'element';
79
+ }
80
+ for (const attr of ancestor.attributes) {
81
+ if (attr.isDirective) {
82
+ return 'transparent';
83
+ }
84
+ }
85
+ return ancestor.isHTMLTemplateElement ? 'template-content' : 'element';
86
+ }
87
+ /**
88
+ * Whether `ancestor` matches an entry of `permittedAncestors`.
89
+ *
90
+ * `#custom` is not a selector: the element decides it, as the `:model()` pseudo-class does
91
+ * for the `#custom` of a content model category (`isCustomElement` follows the pretender).
92
+ * It is read only here: in `forbiddenAncestors` every entry is a selector, so `#custom` there
93
+ * is an id selector.
94
+ */
95
+ function matchesPermitted(
96
+ // eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types
97
+ ancestor, selectors) {
98
+ return selectors.some(selector => (selector === '#custom' ? ancestor.isCustomElement : ancestor.matches(selector)));
99
+ }
@@ -14,6 +14,10 @@ export default createRule({
14
14
  if (attr.isDirective) {
15
15
  return;
16
16
  }
17
+ // An attribute that the framework does not output (`async={false}`) is not an attribute of the element
18
+ if (attr.isAbsent) {
19
+ return;
20
+ }
17
21
  const name = attr.name;
18
22
  const attrName = attr.nameNode;
19
23
  // https://html.spec.whatwg.org/multipage/custom-elements.html#attr-is
@@ -4,7 +4,7 @@ export default createRule({
4
4
  meta: meta,
5
5
  defaultSeverity: 'warning',
6
6
  verify({ document, report, t }) {
7
- const dlList = [...document.querySelectorAll('dl')];
7
+ const dlList = [...document.deepQuerySelectorAll('dl')];
8
8
  for (const dl of dlList) {
9
9
  const dtList = [...dl.querySelectorAll(':scope > dt, :scope > div > dt')];
10
10
  const names = new Set();
@@ -15,27 +15,31 @@ export default createRule({
15
15
  defaultOptions: {
16
16
  'in-document-fragment': false,
17
17
  },
18
- async verify({ document, report, t }) {
19
- const h1Stack = [];
18
+ verify({ document, report, t }) {
20
19
  if (document.nodeList.length === 0) {
21
20
  return;
22
21
  }
23
22
  if (!document.rule.options['in-document-fragment'] && document.isFragment) {
24
23
  return;
25
24
  }
26
- await document.walkOn('Element', node => {
27
- if (node.nodeName.toLowerCase() === 'h1') {
28
- h1Stack.push(node);
25
+ // The h1 elements are counted by tree: the content of a `template` element and a declarative
26
+ // shadow root are trees of their own, where it is not known from the markup where they are inserted
27
+ const h1sOfTree = new Map();
28
+ for (const h1 of document.deepQuerySelectorAll('h1')) {
29
+ const root = h1.getRootNode();
30
+ h1sOfTree.set(root, [...(h1sOfTree.get(root) ?? []), h1]);
31
+ }
32
+ for (const h1Stack of h1sOfTree.values()) {
33
+ const duplicated = h1Stack[1];
34
+ if (duplicated) {
35
+ const message = t('{0} is {1:c}', t('the "{0*}" {1}', 'h1', 'element'), 'duplicated');
36
+ report({
37
+ message,
38
+ line: duplicated.startLine,
39
+ col: duplicated.startCol,
40
+ raw: duplicated.raw,
41
+ });
29
42
  }
30
- });
31
- if (h1Stack.length > 1 && h1Stack[1]) {
32
- const message = t('{0} is {1:c}', t('the "{0*}" {1}', 'h1', 'element'), 'duplicated');
33
- report({
34
- message,
35
- line: h1Stack[1].startLine,
36
- col: h1Stack[1].startCol,
37
- raw: h1Stack[1].raw,
38
- });
39
43
  }
40
44
  },
41
45
  });
@@ -1,2 +1,11 @@
1
+ /**
2
+ * An id is unique in its tree, not in the file: the content of a `template` element and a
3
+ * declarative shadow root are trees of their own, where an id may repeat one of another tree.
4
+ * The ids are grouped by `getRootNode()`, and compared after the character references are
5
+ * resolved (`a&#x62;` is `ab`), as the DOM has them.
6
+ *
7
+ * @see https://html.spec.whatwg.org/multipage/dom.html#the-id-attribute
8
+ * @see https://dom.spec.whatwg.org/#concept-tree
9
+ */
1
10
  declare const _default: Readonly<import("@markuplint/ml-core").RuleSeed<import("@markuplint/ml-core").RuleConfigValue, undefined>>;
2
11
  export default _default;
@@ -1,18 +1,37 @@
1
1
  import { createRule } from '@markuplint/ml-core';
2
+ import { getDOMValue } from '../helpers.js';
2
3
  import meta from './meta.js';
4
+ /**
5
+ * An id is unique in its tree, not in the file: the content of a `template` element and a
6
+ * declarative shadow root are trees of their own, where an id may repeat one of another tree.
7
+ * The ids are grouped by `getRootNode()`, and compared after the character references are
8
+ * resolved (`a&#x62;` is `ab`), as the DOM has them.
9
+ *
10
+ * @see https://html.spec.whatwg.org/multipage/dom.html#the-id-attribute
11
+ * @see https://dom.spec.whatwg.org/#concept-tree
12
+ */
3
13
  export default createRule({
4
14
  meta: meta,
5
15
  async verify({ document, report, t }) {
6
16
  const message = t('{0} is {1:c}', t('{0} of {1}', t('the {0}', 'value'), t('the "{0*}" {1}', 'id', 'attribute')), 'duplicated');
7
- const idStack = [];
17
+ const idsOfTree = new Map();
8
18
  await document.walkOn('Element', node => {
9
19
  const idAttrs = node.getAttributeToken('id');
10
20
  for (const idAttr of idAttrs) {
11
21
  if (idAttr.isDynamicValue || idAttr.isDirective) {
12
22
  continue;
13
23
  }
14
- const id = idAttr.value;
15
- if (idStack.includes(id)) {
24
+ const id = getDOMValue(idAttr);
25
+ if (id == null) {
26
+ continue;
27
+ }
28
+ const root = node.getRootNode();
29
+ let ids = idsOfTree.get(root);
30
+ if (!ids) {
31
+ ids = new Set();
32
+ idsOfTree.set(root, ids);
33
+ }
34
+ if (ids.has(id)) {
16
35
  report({
17
36
  scope: node,
18
37
  message,
@@ -21,7 +40,7 @@ export default createRule({
21
40
  raw: idAttr.raw,
22
41
  });
23
42
  }
24
- idStack.push(id);
43
+ ids.add(id);
25
44
  }
26
45
  });
27
46
  },
@@ -18,7 +18,7 @@ import meta from './meta.js';
18
18
  export default createRule({
19
19
  meta,
20
20
  verify({ document, report, t }) {
21
- const tables = document.querySelectorAll('table');
21
+ const tables = document.deepQuerySelectorAll('table');
22
22
  for (const table of tables) {
23
23
  const grid = new Grid(table);
24
24
  if (grid.hasOverlapped()) {
@@ -7,6 +7,10 @@ export default createRule({
7
7
  defaultSeverity: 'warning',
8
8
  async verify({ document, report, t }) {
9
9
  await document.walkOn('Attr', attr => {
10
+ // An attribute that the framework does not output (`defer={false}`) is not an attribute of the element
11
+ if (attr.isAbsent) {
12
+ return;
13
+ }
10
14
  const attrSpec = getAttrSpecs(attr.ownerElement, document.specs);
11
15
  if (!attrSpec) {
12
16
  return;