@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
@@ -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
@@ -45,8 +45,8 @@ export default createRule({
45
45
  if (valueNum <= maxValue)
46
46
  return;
47
47
  const message = maxAttr
48
- ? t('The value of the "{0}" attribute must be less than or equal to {1}', 'value', t('the value of the "{0}" attribute', 'max'))
49
- : t('The value of the "{0}" attribute must be less than or equal to {1}', 'value', t('{0} when the "{1}" attribute is absent', 'one', 'max'));
48
+ ? t('The value of the "{0*}" attribute must be less than or equal to {1}', 'value', t('the value of the "{0*}" attribute', 'max'))
49
+ : t('The value of the "{0*}" attribute must be less than or equal to {1}', 'value', t('{0} when the "{1*}" attribute is absent', 'one', 'max'));
50
50
  report({
51
51
  scope: valueAttr,
52
52
  line: valueAttr.valueNode?.startLine,
@@ -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,38 @@
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
+ * Looks for the ancestor that the content model asks for (`descendantOf`) in the tree of the
4
+ * element, as the DOM does: the climb stops at the top of the content of a `template` element, and
5
+ * does not go on to the tree that holds the `template`.
6
+ *
7
+ * What the rule decides for an element whose climb ends there depends on where the content is placed:
8
+ *
9
+ * - The content of an ordinary `template` element is placed where the markup does not tell
10
+ * (a script clones it into the document), so the rule does not decide and reports nothing,
11
+ * whether or not a matching ancestor stands outside of the `template`.
12
+ * - The content of a declarative shadow root is placed in its host, and it is a tree of its own:
13
+ * an ancestor outside of the shadow root is not an ancestor of the element, so it is reported.
14
+ *
15
+ * A `template` of a template engine that renders its children in place of it (Vue's `<template v-if>`,
16
+ * or the `template` of a framework whose spec puts the children in the element itself) is not an HTML
17
+ * `template` element, and the climb goes through it as through any other element.
18
+ *
19
+ * A component whose output is not known (`MLElement#isUnresolvedComponent`: no pretender stands for
20
+ * it) may render the ancestor, so an element with such a component above it, up to the top of its
21
+ * tree, is not reported: the rule does not decide what the file does not tell. A pretender that
22
+ * says what the component renders is read as that element, and a web component is the element
23
+ * that it is written as. What renders its children in place is not one even where the parser sees it
24
+ * as a component: a fragment (Vue's `slot` is one to its parser) and a `template` with a directive
25
+ * ({@link isUnknownOutput}).
26
+ *
27
+ * When the content model says the ancestor is required only of an element with a parent node
28
+ * (`descendantOfWithParentNode`, as the `area` element's is), an element that has no parent node in
29
+ * the file ({@link hasParentNode}) is not reported:
30
+ *
31
+ * > An area element with a parent node must have a map element ancestor.
32
+ *
33
+ * @see https://dom.spec.whatwg.org/#parent-element
34
+ * @see https://html.spec.whatwg.org/multipage/scripting.html#the-template-element
35
+ * @see https://html.spec.whatwg.org/multipage/image-maps.html#the-area-element
36
+ */
37
+ declare const _default: Readonly<import("@markuplint/ml-core").RuleSeed<RuleConfigValue, undefined>>;
2
38
  export default _default;
@@ -1,29 +1,102 @@
1
1
  import { createRule } from '@markuplint/ml-core';
2
+ import { getSpecByTagName, isShadowRoot } from '@markuplint/ml-spec';
3
+ import { isRenderedAway, isUnknownOutput } from '../helpers.js';
2
4
  import meta from './meta.js';
5
+ /**
6
+ * Looks for the ancestor that the content model asks for (`descendantOf`) in the tree of the
7
+ * element, as the DOM does: the climb stops at the top of the content of a `template` element, and
8
+ * does not go on to the tree that holds the `template`.
9
+ *
10
+ * What the rule decides for an element whose climb ends there depends on where the content is placed:
11
+ *
12
+ * - The content of an ordinary `template` element is placed where the markup does not tell
13
+ * (a script clones it into the document), so the rule does not decide and reports nothing,
14
+ * whether or not a matching ancestor stands outside of the `template`.
15
+ * - The content of a declarative shadow root is placed in its host, and it is a tree of its own:
16
+ * an ancestor outside of the shadow root is not an ancestor of the element, so it is reported.
17
+ *
18
+ * A `template` of a template engine that renders its children in place of it (Vue's `<template v-if>`,
19
+ * or the `template` of a framework whose spec puts the children in the element itself) is not an HTML
20
+ * `template` element, and the climb goes through it as through any other element.
21
+ *
22
+ * A component whose output is not known (`MLElement#isUnresolvedComponent`: no pretender stands for
23
+ * it) may render the ancestor, so an element with such a component above it, up to the top of its
24
+ * tree, is not reported: the rule does not decide what the file does not tell. A pretender that
25
+ * says what the component renders is read as that element, and a web component is the element
26
+ * that it is written as. What renders its children in place is not one even where the parser sees it
27
+ * as a component: a fragment (Vue's `slot` is one to its parser) and a `template` with a directive
28
+ * ({@link isUnknownOutput}).
29
+ *
30
+ * When the content model says the ancestor is required only of an element with a parent node
31
+ * (`descendantOfWithParentNode`, as the `area` element's is), an element that has no parent node in
32
+ * the file ({@link hasParentNode}) is not reported:
33
+ *
34
+ * > An area element with a parent node must have a map element ancestor.
35
+ *
36
+ * @see https://dom.spec.whatwg.org/#parent-element
37
+ * @see https://html.spec.whatwg.org/multipage/scripting.html#the-template-element
38
+ * @see https://html.spec.whatwg.org/multipage/image-maps.html#the-area-element
39
+ */
3
40
  export default createRule({
4
41
  meta: meta,
5
42
  async verify({ document, report, t }) {
6
43
  await document.walkOn('Element', el => {
7
- const elSpec = document.specs.specs.find(s => s.name === el.localName);
8
- const descendantOf = elSpec?.contentModel?.descendantOf;
44
+ const contentModel = getSpecByTagName(document.specs.specs, el.localName, el.namespaceURI)?.contentModel;
45
+ const descendantOf = contentModel?.descendantOf;
9
46
  if (!descendantOf) {
10
47
  return;
11
48
  }
12
- let ancestor = el.parentElement;
49
+ let ancestor = el.treeParentElement;
13
50
  let found = false;
14
51
  while (ancestor) {
15
52
  if (ancestor.matches(descendantOf)) {
16
53
  found = true;
17
54
  break;
18
55
  }
19
- ancestor = ancestor.parentElement;
56
+ // What the component renders is not known, so the ancestor may be among it. The climb does not
57
+ // leave the tree of the element (`treeParentElement`), so a component outside a declarative shadow
58
+ // root is not reached
59
+ if (isUnknownOutput(ancestor)) {
60
+ return;
61
+ }
62
+ ancestor = ancestor.treeParentElement;
63
+ }
64
+ if (found || (contentModel.descendantOfWithParentNode && !hasParentNode(el))) {
65
+ return;
20
66
  }
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
- });
67
+ const root = el.getRootNode();
68
+ if (root.is(root.DOCUMENT_FRAGMENT_NODE) && !isShadowRoot(root)) {
69
+ return;
26
70
  }
71
+ report({
72
+ scope: el,
73
+ message: t('{0} must appear as a descendant of {1}', t('the "{0*}" {1}', el.localName, 'element'), t('the "{0*}" {1}', descendantOf, 'element')),
74
+ });
27
75
  });
28
76
  },
29
77
  });
78
+ /**
79
+ * Whether the element has a parent node, as far as the file tells. In a whole document every element
80
+ * has one. An element at the top of a file that is a fragment of a document (a partial, a component)
81
+ * has none: where it is inserted is not known from the markup. What is rendered away between the
82
+ * element and the top of the file is not a parent node: a block of a template language, which
83
+ * `parentNode` (and so `parentElement`) walks through, and a fragment of JSX or a `template` with
84
+ * a directive ({@link isRenderedAway}).
85
+ *
86
+ * @template T - The rule configuration value type
87
+ * @template O - The rule options type
88
+ * @param el - An element
89
+ * @returns `false` if the element is at the top of a fragment document
90
+ */
91
+ function hasParentNode(
92
+ // eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types
93
+ el) {
94
+ if (!el.isInFragmentDocument()) {
95
+ return true;
96
+ }
97
+ let parent = el.parentElement;
98
+ while (parent && isRenderedAway(parent)) {
99
+ parent = parent.parentElement;
100
+ }
101
+ return parent !== null;
102
+ }
@@ -1,4 +1,9 @@
1
1
  declare const _default: {
2
2
  readonly category: "structure";
3
+ readonly specConformance: {
4
+ readonly sources: readonly ["html"];
5
+ readonly level: "must";
6
+ readonly cites: readonly ["https://html.spec.whatwg.org/multipage/image-maps.html#the-area-element"];
7
+ };
3
8
  };
4
9
  export default _default;
@@ -1,3 +1,8 @@
1
1
  export default {
2
2
  category: 'structure',
3
+ specConformance: {
4
+ sources: ['html'],
5
+ level: 'must',
6
+ cites: ['https://html.spec.whatwg.org/multipage/image-maps.html#the-area-element'],
7
+ },
3
8
  };
@@ -1,5 +1,6 @@
1
1
  import { createRule, getAttrSpecs } from '@markuplint/ml-core';
2
2
  import { attrMatches, match } from '../helpers.js';
3
+ import { noTranslate } from '../no-translate.js';
3
4
  import meta from './meta.js';
4
5
  export default createRule({
5
6
  meta: meta,
@@ -107,7 +108,9 @@ export default createRule({
107
108
  if (spec.values.length > 0 && value) {
108
109
  const matched = spec.values.some(pattern => match(value, pattern));
109
110
  if (!matched) {
110
- const expects = spec.values.length === 1 ? t(spec.values) : t('either {0}', t(spec.values));
111
+ const expects = spec.values.length === 1
112
+ ? t(spec.values.map(noTranslate))
113
+ : t('either {0}', t(spec.values.map(noTranslate)));
111
114
  const message = t('{0} expects {1}', t('the "{0*}" {1}', spec.name, 'attribute'), expects);
112
115
  const attrToken = el.getAttributeToken(spec.name);
113
116
  const valueToken = attrToken[0]?.valueNode;
@@ -8,8 +8,9 @@ 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])')) {
12
- if (time.hasMutableChildren()) {
11
+ for (const time of document.deepQuerySelectorAll('time:not([datetime])')) {
12
+ // The text of the element is the text of its descendants, which a component may render
13
+ if (time.hasMutableChildren() || [...time.querySelectorAll('*')].some(el => el.isUnresolvedComponent())) {
13
14
  continue;
14
15
  }
15
16
  const content = time.textContent.trim();
@@ -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'),
@@ -1,4 +1,5 @@
1
1
  import { createRule } from '@markuplint/ml-core';
2
+ import { mayRenderUnknownChildren } from '../helpers.js';
2
3
  import meta from './meta.js';
3
4
  export default createRule({
4
5
  meta: meta,
@@ -8,7 +9,10 @@ export default createRule({
8
9
  ignoreOmittedElements: true,
9
10
  },
10
11
  async verify({ document, report, t }) {
11
- const hasMutableContent = document.nodeList.some(n => n.is(n.ELEMENT_NODE) && n.hasMutableChildren());
12
+ // The element that a global rule asks for may be anywhere, so what any component, block or
13
+ // descendant renders may be it
14
+ const hasMutableContent = document.rule.options.ignoreHasMutableContents &&
15
+ document.nodeList.some(n => n.is(n.ELEMENT_NODE) && (n.hasMutableChildren() || n.isUnresolvedComponent()));
12
16
  if (!hasMutableContent) {
13
17
  for (const query of document.rule.value) {
14
18
  const matched = document.querySelectorAll(query);
@@ -30,7 +34,9 @@ export default createRule({
30
34
  if (el.rule.value === document.rule.value) {
31
35
  return;
32
36
  }
33
- if (el.rule.options.ignoreHasMutableContents && el.hasMutableChildren()) {
37
+ // What a descendant renders is not a child of the element that has to have the required one
38
+ if (el.rule.options.ignoreHasMutableContents &&
39
+ [...el.getPureChildNodes()].some(child => mayRenderUnknownChildren(child))) {
34
40
  return;
35
41
  }
36
42
  for (const query of el.rule.value) {
@@ -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;
@@ -9,8 +9,10 @@
9
9
  * `auto` (`allowsAutoSizes`), not `loading="lazy"` alone.
10
10
  *
11
11
  * Gated on `srcset` being present, matching the source rule this was split
12
- * from: `sizes="auto"` has nothing to auto-size without candidates to choose
13
- * from.
12
+ * from. An `<img>` without `srcset` is left to the `@markuplint/html-spec`
13
+ * data of its `sizes`: the condition allows it only on a lazily loaded image
14
+ * (`no-disallowed-attr`) and the type only as the value `auto`
15
+ * (`no-invalid-attr-value`).
14
16
  */
15
17
  declare const _default: Readonly<import("@markuplint/ml-core").RuleSeed<boolean, undefined>>;
16
18
  export default _default;
@@ -13,8 +13,10 @@ import meta from './meta.js';
13
13
  * `auto` (`allowsAutoSizes`), not `loading="lazy"` alone.
14
14
  *
15
15
  * Gated on `srcset` being present, matching the source rule this was split
16
- * from: `sizes="auto"` has nothing to auto-size without candidates to choose
17
- * from.
16
+ * from. An `<img>` without `srcset` is left to the `@markuplint/html-spec`
17
+ * data of its `sizes`: the condition allows it only on a lazily loaded image
18
+ * (`no-disallowed-attr`) and the type only as the value `auto`
19
+ * (`no-invalid-attr-value`).
18
20
  */
19
21
  export default createRule({
20
22
  meta,
@@ -1,4 +1,5 @@
1
1
  import { createRule } from '@markuplint/ml-core';
2
+ import { getDOMValue, querySelectorAllInTree } from '../helpers.js';
2
3
  import meta from './meta.js';
3
4
  /**
4
5
  * Require the `usemap` attribute on an `<img>` element to be a valid
@@ -27,18 +28,26 @@ export default createRule({
27
28
  return;
28
29
  if (usemapAttr.isDynamicValue)
29
30
  return;
30
- const value = usemapAttr.value;
31
+ // The character references are resolved on both sides, as the DOM has the strings
32
+ const value = getDOMValue(usemapAttr) ?? usemapAttr.value;
31
33
  if (!value.startsWith('#') || value.length <= 1)
32
34
  return;
33
35
  const targetName = value.slice(1);
34
- const target = [...document.querySelectorAll('map')].find(candidate => candidate.getAttribute('name') === targetName);
36
+ const target = querySelectorAllInTree(el, 'map').find(candidate => {
37
+ const name = candidate.getAttributeNode('name');
38
+ if (!name) {
39
+ return false;
40
+ }
41
+ // A name that is not known until it is rendered may be the one that the reference means
42
+ return name.isDynamicValue || getDOMValue(name) === targetName;
43
+ });
35
44
  if (!target) {
36
45
  report({
37
46
  scope: usemapAttr,
38
47
  line: usemapAttr.valueNode?.startLine,
39
48
  col: usemapAttr.valueNode?.startCol,
40
49
  raw: usemapAttr.valueNode?.raw,
41
- message: t('The "{0}" attribute of the "{1}" element must be a valid hash-name reference to a "{2}" element', 'usemap', 'img', 'map'),
50
+ message: t('The "{0*}" attribute of the "{1*}" element must be a valid hash-name reference to a "{2*}" element', 'usemap', 'img', 'map'),
42
51
  });
43
52
  }
44
53
  });
@@ -216,7 +216,7 @@ function validateUrls(urls, label, reportAt, t) {
216
216
  * Validate a document-rule `where` predicate. A predicate object must contain
217
217
  * exactly one of `and` / `or` / `not` / `href_matches` / `selector_matches`.
218
218
  *
219
- * @see https://wicg.github.io/nav-speculation/speculation-rules.html#document-rule-predicates
219
+ * @see https://html.spec.whatwg.org/multipage/speculative-loading.html#parse-a-document-rule-predicate
220
220
  */
221
221
  function validatePredicate(predicate, label, reportAt, t) {
222
222
  if (!isPlainObject(predicate)) {
@@ -50,7 +50,7 @@ export const checkingContradictoryAriaProp = ({ attr, propSpecs, attrSpecs }) =>
50
50
  }
51
51
  return {
52
52
  scope: attr,
53
- message: t('{0} contradicts {1}', t('the "{0*}" {1}', attr.name, `ARIA ${propSpec.type}`), t('the current "{0}" {1}', equivalentHtmlAttr.htmlAttrName, 'attribute')),
53
+ message: t('{0} contradicts {1}', t('the "{0*}" {1}', attr.name, `ARIA ${propSpec.type}`), t('the current "{0*}" {1}', equivalentHtmlAttr.htmlAttrName, 'attribute')),
54
54
  };
55
55
  }
56
56
  else if (value === 'true' &&
@@ -58,7 +58,7 @@ export const checkingContradictoryAriaProp = ({ attr, propSpecs, attrSpecs }) =>
58
58
  htmlAttrSpec?.type === 'Boolean') {
59
59
  return {
60
60
  scope: attr,
61
- message: t('{0} contradicts {1}', t('the "{0*}" {1}', attr.name, `ARIA ${propSpec.type}`), t('the implicit "{0}" {1}', equivalentHtmlAttr.htmlAttrName, 'attribute')),
61
+ message: t('{0} contradicts {1}', t('the "{0*}" {1}', attr.name, `ARIA ${propSpec.type}`), t('the implicit "{0*}" {1}', equivalentHtmlAttr.htmlAttrName, 'attribute')),
62
62
  };
63
63
  }
64
64
  }
@@ -1,4 +1,4 @@
1
- import { mayBeFocusable } from '@markuplint/ml-spec';
1
+ import { getComposedParentElement, mayBeFocusable } from '@markuplint/ml-spec';
2
2
  /**
3
3
  * Checks whether a focusable interactive element is inside an `aria-hidden=true` subtree.
4
4
  *
@@ -38,6 +38,10 @@ export const checkingInteractionInHidden = ({ el }) => t => {
38
38
  /**
39
39
  * Traverses the element's ancestor chain to find the closest element with `aria-hidden="true"`.
40
40
  *
41
+ * The chain is the one the accessibility tree is built from ({@link getComposedParentElement}): it ends at the
42
+ * top of the content of an ordinary `template` element, which is not rendered and whose placement is not known,
43
+ * and it goes on from a declarative shadow root to its host.
44
+ *
41
45
  * @param el - The element to start searching from (inclusive).
42
46
  * @returns The `aria-hidden` attribute node if found, or `null` if no ancestor is hidden.
43
47
  */
@@ -50,7 +54,7 @@ el) {
50
54
  if (ariaHidden?.value === 'true') {
51
55
  return ariaHidden;
52
56
  }
53
- current = current.parentElement;
57
+ current = getComposedParentElement(current);
54
58
  }
55
59
  return null;
56
60
  }