@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
package/CHANGELOG.md CHANGED
@@ -3,6 +3,34 @@
3
3
  All notable changes to this project will be documented in this file.
4
4
  See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
5
5
 
6
+ # [5.5.0](https://github.com/markuplint/markuplint/compare/v5.4.0...v5.5.0) (2026-11-10)
7
+
8
+ ### Bug Fixes
9
+
10
+ * **html-spec:** allow script-supporting elements in table sections, colgroup and dl ([197176a](https://github.com/markuplint/markuplint/commit/197176a7aae805e2949f9b96c6a27f39d478d261)), closes [#3591](https://github.com/markuplint/markuplint/issues/3591) [#4492](https://github.com/markuplint/markuplint/issues/4492)
11
+ * report a main element that is not hierarchically correct ([b44c936](https://github.com/markuplint/markuplint/commit/b44c936cf2e40c8142f88fe6b503af6f437cd341))
12
+ * **rules:** accept style as the first child in permitted-contents ([0523872](https://github.com/markuplint/markuplint/commit/05238722e9501b7758035d55293a178ab766001a)), closes [#x20](https://github.com/markuplint/markuplint/issues/x20)
13
+ * **rules:** accept the interactive content that canvas allows ([095eb3a](https://github.com/markuplint/markuplint/commit/095eb3aeb16186b0f5cb3f4a3bd342360dae1c2c)), closes [#4491](https://github.com/markuplint/markuplint/issues/4491)
14
+ * **rules:** count an element inside a conditional block as a label's descendant ([6ca1da5](https://github.com/markuplint/markuplint/commit/6ca1da545a903bec024f4418c3595f16d72365bc))
15
+ * **rules:** do not judge an element that the framework moves to the head by its parent ([db28a20](https://github.com/markuplint/markuplint/commit/db28a20bc562233f0c55594077a15276ec9c113a)), closes [#4484](https://github.com/markuplint/markuplint/issues/4484) [#4484](https://github.com/markuplint/markuplint/issues/4484)
16
+ * **rules:** do not look for an ancestor across the boundary of template content ([b660c8b](https://github.com/markuplint/markuplint/commit/b660c8bc5d2b4cbcc16f592ad536db9a2b8bdaaa))
17
+ * **rules:** do not treat template content as an accessible name ([0d004ef](https://github.com/markuplint/markuplint/commit/0d004efd84e6cd201cdcdc66163b89c4b0a714d2))
18
+ * **rules:** ignore comment nodes in permitted-contents ([38881eb](https://github.com/markuplint/markuplint/commit/38881eb71e80bfef45832a8f2378d336e5bfbe09)), closes [#4488](https://github.com/markuplint/markuplint/issues/4488)
19
+ * **rules:** judge an id and a structure in the tree that it is in ([#4489](https://github.com/markuplint/markuplint/issues/4489)) ([b17db6d](https://github.com/markuplint/markuplint/commit/b17db6d813012e127beb2c2b9e3fc21a393e2d4d))
20
+ * **rules:** require as to satisfy every matched link destination ([b4dbea2](https://github.com/markuplint/markuplint/commit/b4dbea2a08d761c90c98098102d7af40cf485849))
21
+
22
+ ### Features
23
+
24
+ * **rules:** judge an attribute by what the framework renders ([3beee5a](https://github.com/markuplint/markuplint/commit/3beee5ad51e96fc3033b7930fec2889ea5bf18e6))
25
+
26
+
27
+ # [5.4.0](https://github.com/markuplint/markuplint/compare/v5.3.0...v5.4.0) (2026-10-29)
28
+
29
+ ### Bug Fixes
30
+
31
+ * **rules:** do not report descendant attributes that take part in a text alternative ([46002a7](https://github.com/markuplint/markuplint/commit/46002a75e06f877938d134ce0958a625e5d989d3)), closes [#4408](https://github.com/markuplint/markuplint/issues/4408)
32
+
33
+
6
34
  # [5.3.0](https://github.com/markuplint/markuplint/compare/v5.2.0...v5.3.0) (2026-10-21)
7
35
 
8
36
  ### Bug Fixes
package/CLAUDE.md CHANGED
@@ -65,10 +65,11 @@ broken description and detonate in someone else's PR. Run `yarn site:build` befo
65
65
 
66
66
  ## Citing a specification
67
67
 
68
- A README, a source comment, `meta.ts` and `schema.json` cite the passage that justifies a rule. `yarn spec-citations` checks every citation against the live specification (what it checks and why: the module comment of `scripts/spec-citations/cli.ts`), and `scripts/spec-citations/citations-policy.spec.ts` holds the conventions below offline. After editing a citation, run `yarn spec-citations` (read-only) and fix what it reports. Its lock file is refreshed by the scheduled workflow's PR, never by hand.
68
+ A README, a source comment, `meta.ts` and `schema.json` cite the passage that justifies a rule; so do the comments in the `src` of every package (the algorithms of `ml-spec`, the value grammars of `types`, the DOM of `ml-core`, the declarations of how a framework renders a literal in `*-spec` and `*-parser`), the `html-spec` data and the website's pages. The monitor reads the whole `src` of each package, so a citation that moves with its code to another package stays watched. `yarn spec-citations` checks every citation against the live specification, the documentation or the source of a framework (what it checks and why: the module comment of `scripts/spec-citations/cli.ts`), and `scripts/spec-citations/citations-policy.spec.ts` holds the conventions below offline. After editing a citation, run `yarn spec-citations` (read-only) and fix what it reports. Its lock file is refreshed by the scheduled workflow's PR, never by hand.
69
69
 
70
- - Quote a spec sentence verbatim, as a blockquote in a README or as `>` lines in a source comment, and name its source: in a README the `Cite: [..](url)` line after the quote (`[..](url)より引用` in Japanese), in a comment an `@see` URL in the same comment. Never quote inline in double quotes: the monitor cannot check it. `[…]` marks what the quote leaves out.
71
- - Cite a section by its fragment. A `§4.10.7` beside the link is compared with the section the fragment is in; the number of an ancestor section is accepted.
70
+ - Quote a spec sentence verbatim, as a blockquote in a README or as `>` lines in a source comment, and name its source: in a README the `Cite: [..](url)` line after the quote (`[..](url)より引用` in Japanese), in a comment an `@see` URL in the same comment. A `>` is read only at the start of a comment line (not after a list marker), and each quote is a paragraph of its own. Never quote inline in double quotes: the monitor cannot check it. `[…]` marks what the quote leaves out, and a quote is looked up in the sections the comment cites, in the order that it writes it in.
71
+ - Cite a section by its fragment. A `§4.10.7` beside the link is compared with the section the fragment is in (one before the link belongs to the link that follows it, one after a link on the same line, as in `@see <url> — Name §4.3.2`, to that link); the number of an ancestor section is accepted. Prefer the id of a heading to a text fragment (`#:~:text=`) alone: a text fragment with no `#fragment` is searched in the whole page, and stops matching when the passage is reworded (HTML-AAM's tables were rebuilt into one section per element, and `#:~:text=Comments-,article,-aside` went with them).
72
+ - A source file of a framework is cited by `https://github.com/<owner>/<repo>/blob/<default branch>/<path>#<name>`, where `<name>` is a top-level declaration of the file (a function, a variable, a class, a type, or `exports.<name> =`). It is compared as the declaration alone, so an edit elsewhere in the file reports nothing and a renamed declaration is `anchor-missing`. Only the repositories listed in `scripts/spec-citations/collect.ts` are monitored, and a link without a name or with a line range (`#L11-L52`) is not checked.
72
73
  - `README.ja.md` cites the HTML Living Standard through its translation (`https://momdo.github.io/html/…`, the same page and anchor as the English README) and quotes the translation's own text. That text stays English where the translation is behind the original, and the monitor reports it once the translation catches up. For WCAG, the translation is waic.jp's.
73
74
  - Cite W3C specs at an edition-less URL (`https://w3c.github.io/accname/`, `https://w3c.github.io/aria/`, `https://www.w3.org/TR/WCAG/`, `https://www.w3.org/TR/html-aria/`). WCAG's Techniques and Understanding pages have no edition-less URL; cite the latest edition (`https://www.w3.org/WAI/WCAG22/…`). Not `w3c.github.io/html-aria/`: that is unrendered ReSpec source whose anchors exist only after a browser builds them.
74
75
 
@@ -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 { checkingNoGlobalProp } from '../wai-aria/checkings/no-global-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
  export default createRule({
8
8
  meta,
@@ -14,7 +14,7 @@ export default createRule({
14
14
  return;
15
15
  if (!elSpec.globalAttrs['#ARIAAttrs'])
16
16
  return;
17
- const propAttrs = el.attributes.filter(attr => isAriaAttr(attr));
17
+ const propAttrs = getAriaAttrs(el);
18
18
  if (propAttrs.length === 0)
19
19
  return;
20
20
  const ariaVersion = el.rule.options?.version ?? document.ruleCommonSettings?.ariaVersion ?? ARIA_RECOMMENDED_VERSION;
@@ -41,8 +41,29 @@ export type AttrEligibility = {
41
41
  * @param attr - The attribute that is being resolved
42
42
  */
43
43
  export declare function resolveAttrEligibility<T extends RuleConfigValue, O extends PlainData>(name: string, node: Element<T, O>, attrSpecs: readonly Attribute[], attr?: Attr<T, O>): AttrEligibility;
44
+ type PlainEnum = {
45
+ readonly enum: readonly [string, ...string[]];
46
+ readonly caseInsensitive?: boolean;
47
+ readonly disallowToSurroundBySpaces?: boolean;
48
+ readonly invalidValueDefault?: string;
49
+ };
50
+ /**
51
+ * `invalidValueDefault` is not carried over: a fallback of one set may be a
52
+ * value another set forbids. A case-sensitive spelling is kept when an earlier
53
+ * entry only has another case. Case and surrounding spaces follow the stricter
54
+ * side, because the value has to satisfy every entry.
55
+ *
56
+ * An empty intersection cannot be an enum (`enum` has at least one token).
57
+ * A pattern that matches nothing keeps every value invalid. That pattern is
58
+ * not a keyword list an author is meant to satisfy.
59
+ *
60
+ * @param enums - The enum entries that matched, in spec order. At least two
61
+ * @returns The intersection, or a pattern that matches nothing when the sets are disjoint
62
+ */
63
+ export declare function intersectEnums(enums: readonly PlainEnum[]): Attribute['type'];
44
64
  /**
45
65
  * Formats the "did you mean" suffix shared by `no-unknown-attr`'s two typo
46
66
  * paths (spec-driven candidate, case-sensitivity mismatch).
47
67
  */
48
68
  export declare function suggestionSuffix(t: Translator, candidate: string | undefined): string;
69
+ export {};
@@ -1,5 +1,5 @@
1
1
  import { isConditionalAttributeTypeArray } from '@markuplint/ml-spec';
2
- import { getCandidate } from '@markuplint/types';
2
+ import { check, getCandidate } from '@markuplint/types';
3
3
  import { attrMatches, conditionDependsOnDynamicAttr, findAttrSpec, namespaceKeyOf } from './helpers.js';
4
4
  /**
5
5
  * Attribute namespaces that are exempt from spec-based name/eligibility
@@ -43,14 +43,11 @@ attr) {
43
43
  }
44
44
  // Resolve ConditionalAttributeType[] to a concrete type based on element matching (#3598).
45
45
  if (isConditionalAttributeTypeArray(spec.type)) {
46
- const matched = spec.type.find(entry => {
46
+ const matched = spec.type.filter(entry => {
47
47
  const cond = typeof entry.condition === 'string' ? entry.condition : entry.condition.join(',');
48
48
  return node.matches(cond);
49
49
  });
50
- // Fallback to 'Any' when no condition matches: input types without an explicit
51
- // entry (text, search, tel, password, hidden, checkbox, radio, file, submit,
52
- // image, reset, button) have no value constraints per the HTML spec.
53
- spec = { ...spec, type: matched ? matched.type : 'Any' };
50
+ spec = { ...spec, type: combineMatchedConditionalTypes(matched) };
54
51
  }
55
52
  if (spec.condition != null &&
56
53
  !node.hasSpreadAttr &&
@@ -60,6 +57,89 @@ attr) {
60
57
  }
61
58
  return { status: 'ok', spec };
62
59
  }
60
+ /**
61
+ * Enum entries that match together are simultaneous constraints: `link[as]`
62
+ * has to be a preload destination and a module preload destination when `rel`
63
+ * contains both keywords. The message names that intersection and does not
64
+ * offer a keyword another entry forbids. Token order follows the entries:
65
+ * a spelling is kept from the earliest entry whose own token satisfies every
66
+ * entry, which is the first entry's order when that spelling is valid there.
67
+ *
68
+ * A non-enum overlap stays on the first match. Those entries are ordered
69
+ * alternatives: an empty `input[value]` other than `type=range` is
70
+ * unconstrained even though a later type-specific entry also matches, and
71
+ * `email` with `multiple` matches both the address-list type and the
72
+ * single-address type, of which the list is the requirement.
73
+ */
74
+ function combineMatchedConditionalTypes(matched) {
75
+ const first = matched[0];
76
+ // No condition matched. Input types without an explicit entry (text, search,
77
+ // tel, password, hidden, checkbox, radio, file, submit, image, reset, button)
78
+ // have no value constraints per the HTML spec.
79
+ if (!first) {
80
+ return 'Any';
81
+ }
82
+ const enums = [];
83
+ for (const entry of matched) {
84
+ if (!isPlainEnum(entry.type)) {
85
+ return first.type;
86
+ }
87
+ enums.push(entry.type);
88
+ }
89
+ if (enums.length === 1) {
90
+ return first.type;
91
+ }
92
+ return intersectEnums(enums);
93
+ }
94
+ function isPlainEnum(type) {
95
+ if (typeof type === 'string' || Array.isArray(type)) {
96
+ return false;
97
+ }
98
+ return 'enum' in type && Array.isArray(type.enum) && type.enum.length > 0;
99
+ }
100
+ /**
101
+ * `invalidValueDefault` is not carried over: a fallback of one set may be a
102
+ * value another set forbids. A case-sensitive spelling is kept when an earlier
103
+ * entry only has another case. Case and surrounding spaces follow the stricter
104
+ * side, because the value has to satisfy every entry.
105
+ *
106
+ * An empty intersection cannot be an enum (`enum` has at least one token).
107
+ * A pattern that matches nothing keeps every value invalid. That pattern is
108
+ * not a keyword list an author is meant to satisfy.
109
+ *
110
+ * @param enums - The enum entries that matched, in spec order. At least two
111
+ * @returns The intersection, or a pattern that matches nothing when the sets are disjoint
112
+ */
113
+ export function intersectEnums(enums) {
114
+ const caseInsensitive = enums.every(item => item.caseInsensitive !== false);
115
+ const tokens = [];
116
+ for (const item of enums) {
117
+ for (const token of item.enum) {
118
+ if (!enums.every(other => check(token, other).matched)) {
119
+ continue;
120
+ }
121
+ if (tokens.some(existing => sameToken(existing, token, caseInsensitive))) {
122
+ continue;
123
+ }
124
+ tokens.push(token);
125
+ }
126
+ }
127
+ const [head, ...rest] = tokens;
128
+ if (head === undefined) {
129
+ return { pattern: '/(?!)/' };
130
+ }
131
+ const result = {
132
+ enum: [head, ...rest],
133
+ ...(caseInsensitive ? {} : { caseInsensitive: false }),
134
+ ...(enums.every(item => item.disallowToSurroundBySpaces === false)
135
+ ? { disallowToSurroundBySpaces: false }
136
+ : {}),
137
+ };
138
+ return result;
139
+ }
140
+ function sameToken(a, b, caseInsensitive) {
141
+ return caseInsensitive ? a.toLowerCase() === b.toLowerCase() : a === b;
142
+ }
63
143
  /**
64
144
  * Formats the "did you mean" suffix shared by `no-unknown-attr`'s two typo
65
145
  * paths (spec-driven candidate, case-sensitivity mismatch).
@@ -229,7 +229,8 @@ export default createRule({
229
229
  let cachedValue;
230
230
  let cachedCompiled;
231
231
  await document.walkOn('Element', node => {
232
- const attrs = node.getAttributeTokens();
232
+ // An attribute that a pretender made is not in the source, so it has no place in the order of it
233
+ const attrs = node.getAttributeTokens().filter(attr => !attr.isPretended);
233
234
  if (attrs.length <= 1) {
234
235
  return;
235
236
  }
@@ -14,7 +14,7 @@ export default createRule({
14
14
  async verify({ document, report, t }) {
15
15
  await document.walkOn('Attr', attr => {
16
16
  const message = t('{0} is must {1} on {2}', 'Attribute value', 'quote', `${attr.rule.value} quotation mark`);
17
- if (attr.isDynamicValue || attr.isDirective || !attr.equal || attr.equal?.raw === '') {
17
+ if (attr.isExpressionValue || attr.isDirective || !attr.equal || attr.equal?.raw === '') {
18
18
  return;
19
19
  }
20
20
  const quote = attr.startQuote?.raw;
@@ -18,7 +18,7 @@ export default createRule({
18
18
  meta,
19
19
  defaultSeverity: 'warning',
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()) {
@@ -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 { checkingElementSupportsAriaProp } from '../wai-aria/checkings/element-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 ARIA in
@@ -25,7 +25,7 @@ export default createRule({
25
25
  return;
26
26
  if (!elSpec.globalAttrs['#ARIAAttrs'])
27
27
  return;
28
- const propAttrs = el.attributes.filter(attr => isAriaAttr(attr));
28
+ const propAttrs = getAriaAttrs(el);
29
29
  if (propAttrs.length === 0)
30
30
  return;
31
31
  const ariaVersion = el.rule.options?.version ?? document.ruleCommonSettings?.ariaVersion ?? ARIA_RECOMMENDED_VERSION;
@@ -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
  * Form-associated elements (HTML LS §4.10.2). Listed elements may carry
@@ -47,8 +48,7 @@ export default createRule({
47
48
  return;
48
49
  if (formAttr.isDynamicValue)
49
50
  return;
50
- const targetId = formAttr.value;
51
- const target = [...document.querySelectorAll('[id]')].find(candidate => candidate.getAttribute('id') === targetId);
51
+ const target = getElementByIdInTree(el, formAttr.value, formAttr.isExpressionValue);
52
52
  // Existence is checked by `no-refer-to-non-existent-id`. If the ID does not resolve
53
53
  // here, leave that diagnostic to the dedicated rule and stay silent.
54
54
  if (!target)
package/lib/helpers.d.ts CHANGED
@@ -1,7 +1,36 @@
1
1
  import type { FixToken, IRuleFixer, PlainData, TextEdit } from '@markuplint/ml-config';
2
- import type { Attr, Element, RuleConfigValue, Document } from '@markuplint/ml-core';
2
+ import type { Attr, Element, RuleConfigValue } from '@markuplint/ml-core';
3
3
  import type { Attribute } from '@markuplint/ml-spec';
4
4
  import type { WritableDeep } from 'type-fest';
5
+ import { getDOMValue } from '@markuplint/ml-core';
6
+ export { getDOMValue };
7
+ /**
8
+ * The element that an id reference written in an attribute of `el` resolves to: the first element
9
+ * of the tree `el` is in whose id is the reference. The content of a `template` element and a
10
+ * declarative shadow root are trees of their own, so a reference there does not reach an id of the
11
+ * document, nor the other way around.
12
+ *
13
+ * The reference is compared with the character references in the markup resolved, as the id is.
14
+ *
15
+ * @template T - The rule configuration value type
16
+ * @template O - The rule options type
17
+ * @param el - The element that has the attribute
18
+ * @param reference - The value of the attribute, or one token of it, as written in the markup
19
+ * @param isExpression - Whether the value is written as code, which has no character reference in it
20
+ * @returns The element, or `null` when the tree has none, or `el` is in no tree
21
+ * @see https://html.spec.whatwg.org/multipage/dom.html#the-id-attribute
22
+ */
23
+ export declare function getElementByIdInTree<T extends RuleConfigValue, O extends PlainData>(el: Element<T, O>, reference: string, isExpression?: boolean): Element<T, O> | null;
24
+ /**
25
+ * The elements that match the selector in the tree `el` is in (see {@link getElementByIdInTree}).
26
+ *
27
+ * @template T - The rule configuration value type
28
+ * @template O - The rule options type
29
+ * @param el - An element of the tree
30
+ * @param selectors - A selector
31
+ * @returns The elements in the order of the markup, none when `el` is in no tree
32
+ */
33
+ export declare function querySelectorAllInTree<T extends RuleConfigValue, O extends PlainData>(el: Element<T, O>, selectors: string): readonly Element<T, O>[];
5
34
  /** A `null`/`undefined` condition means the attribute applies unconditionally. */
6
35
  export declare function attrMatches<T extends RuleConfigValue, O extends PlainData>(node: Element<T, O>, condition: Attribute['condition']): boolean;
7
36
  /**
@@ -60,6 +89,16 @@ export declare function namespaceKeyOf(attr: Pick<Attr<RuleConfigValue, PlainDat
60
89
  readonly elementType: string;
61
90
  };
62
91
  }): string;
92
+ /**
93
+ * The `aria-*` attributes that the element has. An attribute that the framework does not output
94
+ * (`aria-label={null}`, `MLAttr#isAbsent`) is not one of them: the DOM does not have it, so what a rule
95
+ * says of an attribute of the element (it is unsupported, deprecated, redundant, or its value is
96
+ * invalid) is not said of it.
97
+ *
98
+ * @param el - The element
99
+ * @returns The `aria-*` attributes that are output
100
+ */
101
+ export declare function getAriaAttrs<T extends RuleConfigValue, O extends PlainData>(el: Element<T, O>): Attr<T, O>[];
63
102
  /**
64
103
  * Whether the attribute is an `aria-*` one, told by {@link namespaceKeyOf}.
65
104
  *
@@ -117,7 +156,7 @@ export declare function match(needle: string, pattern: string): boolean;
117
156
  */
118
157
  export declare const rePCENChar: string;
119
158
  export declare function toNormalizedValue(value: string, spec: Attribute): string;
120
- export declare function accnameMayBeMutable(el: Element<any, any>, document: Document<any, any>): boolean;
159
+ export declare function accnameMayBeMutable(el: Element<any, any>): boolean;
121
160
  /**
122
161
  * Whether the accessible name of `el` may be rendered by content the pretender
123
162
  * does not know: an unknown entry (`{ dynamic: true }`) in the `contents` of
@@ -129,9 +168,9 @@ export declare function accnameMayBeMutable(el: Element<any, any>, document: Doc
129
168
  * must stay checked (and reported as redundant by `no-redundant-accessible-name`).
130
169
  * Call it only once the static name has been computed and found empty.
131
170
  */
132
- export declare function accnameMayComeFromUnknownContent(el: Element<any, any>, document: Document<any, any>): boolean;
171
+ export declare function accnameMayComeFromUnknownContent(el: Element<any, any>): boolean;
133
172
  export declare const labelable: string[];
134
- export declare function getOwnedLabel<V extends RuleConfigValue, O extends PlainData>(el: Element<V, O>, document: Document<V, O>): Element<V, O> | null;
173
+ export declare function getOwnedLabel<V extends RuleConfigValue, O extends PlainData>(el: Element<V, O>): Element<V, O> | null;
135
174
  /** `null`/`undefined` items are silently dropped on add. */
136
175
  export declare class Collection<T> {
137
176
  #private;
@@ -181,4 +220,3 @@ export declare function createIgnoreFeaturesLookup(tagNameCaseSensitive: boolean
181
220
  /** Whether the attribute of the element is ignored. */
182
221
  isAttrIgnored<T extends RuleConfigValue, O extends PlainData>(elementOption: readonly string[] | undefined, elementName: string, attr: Attr<T, O>): boolean;
183
222
  };
184
- export {};
package/lib/helpers.js CHANGED
@@ -1,5 +1,49 @@
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
+ * The elements that match the selector in the tree `el` is in (see {@link getElementByIdInTree}).
31
+ *
32
+ * @template T - The rule configuration value type
33
+ * @template O - The rule options type
34
+ * @param el - An element of the tree
35
+ * @param selectors - A selector
36
+ * @returns The elements in the order of the markup, none when `el` is in no tree
37
+ */
38
+ export function querySelectorAllInTree(
39
+ // eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types
40
+ el, selectors) {
41
+ const root = el.getRootNode();
42
+ if (root.is(root.DOCUMENT_NODE) || root.is(root.DOCUMENT_FRAGMENT_NODE)) {
43
+ return [...root.querySelectorAll(selectors)];
44
+ }
45
+ return [];
46
+ }
3
47
  /** A `null`/`undefined` condition means the attribute applies unconditionally. */
4
48
  export function attrMatches(
5
49
  // eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types
@@ -30,9 +74,11 @@ node, condition) {
30
74
  }
31
75
  }
32
76
  }
33
- // Whether the attribute is the one that the selector names is up to the attribute (`isNamedBy`)
77
+ // Whether the attribute is the one that the selector names is up to the attribute (`isNamedBy`).
78
+ // An attribute that the framework does not output (`type={null}`) is known not to be there, so the
79
+ // condition is decided: it is not met.
34
80
  const names = [...referencedNames];
35
- return node.attributes.some(attr => attr.isDynamicValue && names.some(name => attr.isNamedBy(name)));
81
+ return node.attributes.some(attr => attr.isDynamicValue && !attr.isAbsent && names.some(name => attr.isNamedBy(name)));
36
82
  }
37
83
  /**
38
84
  * Memoizes `fn` by the identity of its argument. Use it for an object that is one object for every
@@ -118,6 +164,20 @@ attr) {
118
164
  export function namespaceKeyOf(attr) {
119
165
  return attr.ownerElement.elementType === 'authored' ? asciiLowerCase(attr.name) : attr.nameKey;
120
166
  }
167
+ /**
168
+ * The `aria-*` attributes that the element has. An attribute that the framework does not output
169
+ * (`aria-label={null}`, `MLAttr#isAbsent`) is not one of them: the DOM does not have it, so what a rule
170
+ * says of an attribute of the element (it is unsupported, deprecated, redundant, or its value is
171
+ * invalid) is not said of it.
172
+ *
173
+ * @param el - The element
174
+ * @returns The `aria-*` attributes that are output
175
+ */
176
+ export function getAriaAttrs(
177
+ // eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types
178
+ el) {
179
+ return el.attributes.filter(attr => !attr.isAbsent && isAriaAttr(attr));
180
+ }
121
181
  /**
122
182
  * Whether the attribute is an `aria-*` one, told by {@link namespaceKeyOf}.
123
183
  *
@@ -261,13 +321,11 @@ export function toNormalizedValue(value, spec) {
261
321
  }
262
322
  export function accnameMayBeMutable(
263
323
  // eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types
264
- el,
265
- // eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types
266
- document) {
324
+ el) {
267
325
  if (el.hasMutableAttributes() || el.hasMutableChildren(true)) {
268
326
  return true;
269
327
  }
270
- const ownedLabel = getOwnedLabel(el, document);
328
+ const ownedLabel = getOwnedLabel(el);
271
329
  if (ownedLabel && (ownedLabel.hasMutableAttributes() || ownedLabel.hasMutableChildren(true))) {
272
330
  return true;
273
331
  }
@@ -286,31 +344,33 @@ document) {
286
344
  */
287
345
  export function accnameMayComeFromUnknownContent(
288
346
  // 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) {
347
+ el) {
292
348
  if (hasUnknownContent(el)) {
293
349
  return true;
294
350
  }
295
- const labelledby = el.getAttribute('aria-labelledby');
351
+ const labelledbyAttr = el.getAttributeNode('aria-labelledby');
352
+ const labelledby = labelledbyAttr?.value;
296
353
  if (labelledby) {
297
354
  const referenced = labelledby
298
355
  .trim()
299
356
  .split(/\s+/)
300
- .map(id => document.querySelector(`#${escapeCSS(id)}`));
357
+ .map(id => getElementByIdInTree(el, id, labelledbyAttr.isExpressionValue));
301
358
  if (referenced.some(target => target && hasUnknownContent(target))) {
302
359
  return true;
303
360
  }
304
361
  }
305
- const ownedLabel = getOwnedLabel(el, document);
362
+ const ownedLabel = getOwnedLabel(el);
306
363
  return !!ownedLabel && hasUnknownContent(ownedLabel);
307
364
  }
308
365
  /**
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).
366
+ * Walks the children {@link getRenderedChildNodes} returns. A hidden child is
367
+ * skipped because the name computation never reads it (`isHidden` in that resolver).
368
+ *
369
+ * A `template` whose shadow root is only maybe attached returns `true` before
370
+ * that walk. Its content is omitted from the computed name, so an ancestor
371
+ * whose computed name is empty is not reported: the rule calls this only after
372
+ * the computed name is empty. Light DOM text that remains in the computed name
373
+ * is still a name, and the rule does not reach this function.
314
374
  */
315
375
  function hasUnknownContent(
316
376
  // eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types
@@ -319,8 +379,10 @@ el) {
319
379
  if (context?.type === 'pretender' && context.slotContent?.mutable) {
320
380
  return true;
321
381
  }
322
- const given = context?.type === 'pretender' ? [...context.as.childNodes] : [...el.childNodes];
323
- const children = context?.type === 'pretender' && context.slotContent ? context.slotContent.fill(given) : given;
382
+ if (context?.type !== 'pretender' && el.isHTMLTemplateElement && el.declarativeShadowRootAttachment === 'unknown') {
383
+ return true;
384
+ }
385
+ const children = [...getRenderedChildNodes(el)];
324
386
  return children.some(child => child.is(child.ELEMENT_NODE) &&
325
387
  child.getAttribute('aria-hidden') !== 'true' &&
326
388
  !child.hasAttribute('hidden') &&
@@ -329,17 +391,17 @@ el) {
329
391
  export const labelable = ['button', 'input:not([type=hidden])', 'meter', 'output', 'progress', 'select', 'textarea'];
330
392
  export function getOwnedLabel(
331
393
  // 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) {
394
+ el) {
335
395
  if (!labelable.some(cond => el.matches(cond))) {
336
396
  return null;
337
397
  }
338
398
  let ownedLabel = el.closest('label');
339
399
  if (!ownedLabel) {
340
- const id = el.getAttribute('id');
400
+ const id = getDOMValue(el.getAttributeNode('id'));
341
401
  if (id) {
342
- ownedLabel = document.querySelector(`label[for="${id}"]`);
402
+ // The label is looked for in the tree of the element, and the two values are compared as the DOM has them
403
+ ownedLabel =
404
+ querySelectorAllInTree(el, 'label[for]').find(label => getDOMValue(label.getAttributeNode('for')) === id) ?? null;
343
405
  }
344
406
  }
345
407
  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)
@@ -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,21 +29,37 @@ 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
65
  message: t('{0} must {1}', t('the {0}', 'element'), 'belong to an item via an ancestor `itemscope` or be referenced by an `itemref`'),
@@ -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)