@markuplint/rules 5.0.0 → 5.1.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.
package/CHANGELOG.md CHANGED
@@ -3,6 +3,24 @@
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.1.0](https://github.com/markuplint/markuplint/compare/v5.0.1...v5.1.0) (2026-10-08)
7
+
8
+ ### Bug Fixes
9
+
10
+ - **rules:** count owned elements rendered by a pretended component in require-owned-elements ([87ae63d](https://github.com/markuplint/markuplint/commit/87ae63d65196829c3908f4c3c74ceffb2fedc60d)), closes [#4068](https://github.com/markuplint/markuplint/issues/4068)
11
+ - **rules:** hold permitted-contents condition cache weakly by specs ([9b3200e](https://github.com/markuplint/markuplint/commit/9b3200e49cab314f978b8753c56463a2025d8bd0))
12
+ - **rules:** skip require-accessible-name when unknown pretender content may be the name ([f82b254](https://github.com/markuplint/markuplint/commit/f82b25451f4e1f1da67a568444fce9d51709378e)), closes [#4069](https://github.com/markuplint/markuplint/issues/4069)
13
+
14
+ ### Features
15
+
16
+ - **rules:** evaluate pretender slot content in permitted-contents ([1da4b30](https://github.com/markuplint/markuplint/commit/1da4b30d7997ee6e1e7500e3b6602c46ea576548)), closes [#4054](https://github.com/markuplint/markuplint/issues/4054)
17
+
18
+ ## [5.0.1](https://github.com/markuplint/markuplint/compare/v5.0.0...v5.0.1) (2026-09-22)
19
+
20
+ ### Bug Fixes
21
+
22
+ - **rules:** skip framework directives in no-event-handler-attr ([1da97ed](https://github.com/markuplint/markuplint/commit/1da97ed5ff6a17aeabd9dbe7077ebe057a3b674d)), closes [#4044](https://github.com/markuplint/markuplint/issues/4044)
23
+
6
24
  # [5.0.0](https://github.com/markuplint/markuplint/compare/v5.0.0-rc.7...v5.0.0) (2026-09-11)
7
25
 
8
26
  ### Bug Fixes
package/CLAUDE.md CHANGED
@@ -2,16 +2,16 @@
2
2
 
3
3
  ## Adding a New Rule (checklist — steps 2–4 have NO enforcing test; forgetting them fails silently)
4
4
 
5
- 1. `src/<rule-name>/`: `index.ts`, `meta.ts`, `schema.json`, spec file(s), `README.md` **and** `README.ja.md` — both languages are website source and MUST stay in sync; updating only one is a recurring mistake
5
+ 1. `src/<rule-name>/`: `index.ts`, `meta.ts`, `schema.json`, spec file(s), `README.md` **and** `README.ja.md` — both languages are website source and must stay in sync; updating only one is a recurring mistake
6
6
  - `meta.ts`'s `category` must be one of the v5 9-category scheme: `syntax`, `structure`, `attributes`, `references`, `forms`, `a11y`, `style`, `maintainability`, `compat` (see the [Categories section](https://markuplint.dev/docs/migration/v4-to-v5/rules/rule-names#categories) of the website's rule-names guide for what each covers)
7
7
  - `meta.ts` should also declare `specConformance` (`sources`/`level`/`cites`) once you know the rule's governing spec and requirement strength — `level: 'must'` or `'should'` requires a non-empty `cites`. The rollout across pre-existing rules is incremental and partial by design (enforced only for rules that declare it — see `spec-conformance.spec.ts` and `@markuplint/config-presets`'s `html-standard-entries.spec.ts`), but a **new** rule should declare it from the start
8
8
  2. Register the rule in `src/index.ts` (import + registry entry)
9
9
  3. Add a `$ref` entry to `packages/@markuplint/rules/schema.json` — this file is a **manually maintained registry** (no generator produces it, despite looking generated)
10
10
  4. If the rule belongs in a preset, update `packages/@markuplint/config-presets/src/`
11
11
 
12
- ## Test ID Convention (MANDATORY)
12
+ ## Test ID Convention
13
13
 
14
- Every `test()` block in rule spec files — `src/<rule-name>/**/*.spec.ts` — MUST have a unique ID prefix:
14
+ Every `test()` block in rule spec files — `src/<rule-name>/**/*.spec.ts` — has a unique ID prefix:
15
15
 
16
16
  ```
17
17
  [rule-name-category-NNN] description
@@ -37,7 +37,42 @@ Repo-wide meta specs directly under `src/` (e.g. `mirrors-parse-error-codes.spec
37
37
  - When adding a new test, assign the next available number in the appropriate category
38
38
  - Run `/list-rule-test` (or `node .claude/skills/list-rule-test/scripts/list-rule-test.mjs --no-id`) to check for missing IDs — the only expected hits are the exempt meta specs noted above
39
39
 
40
- ## Assertion Convention (MANDATORY)
40
+ ## Sample identifiers in specs
41
+
42
+ <!-- cspell:ignore myform mymap -->
43
+
44
+ Sample `id` / `name` values in test markup must be kebab-case (`my-form`), suffixed with a digit
45
+ (`form1`), or a plain dictionary word (`target`). Never a camelCase coinage (`myform`, `mymap`,
46
+ `targetEl`): CSpell decomposes compound words only when every part is at least three characters,
47
+ so a two-letter prefix glued to a word is looked up whole, misses the dictionary, and fails the
48
+ lint — on the `dev` line the same one-line fix has been made in four separate PRs (#3826, #3831,
49
+ #3833, #3932). The v6 line has no CSpell, so a coinage introduced there surfaces only once the
50
+ branch merges somewhere that does.
51
+
52
+ ## Writing a rule's README and `schema.json` description
53
+
54
+ Both files feed the website build (`website/scripts/prebuild/rule-content.mts` injects the
55
+ description into MDX), which breaks in two ways that no rule test catches:
56
+
57
+ - **An attribute-bearing tag must be wrapped in backticks.** `escapeMdx` only escapes bare tags
58
+ (`<head>`); `<input type="button">` reaches the MDX parser as unclosed JSX and crashes the
59
+ Docusaurus build.
60
+ - **A YAML `description:` may not start with a backtick** — `gray-matter` fails with "stream/document
61
+ separator expected". Wrap the whole value in single quotes instead:
62
+
63
+ ```yaml
64
+ description: '`<input type="button">` must not have a value attribute'
65
+ ```
66
+
67
+ Deliberately malformed markup in an ❌ example needs `<!-- prettier-ignore -->` immediately before
68
+ the fence: oxfmt formats ` ```html ` blocks as real HTML and will close the tag you left open,
69
+ turning the incorrect example into a copy of the correct one. Apply it to `README.md` and
70
+ `README.ja.md` separately, and read the diff after `yarn lint` to confirm the breakage survived.
71
+
72
+ The CI website check only runs when a PR already touches `website/`, so a new rule can land with a
73
+ broken description and detonate in someone else's PR. Run `yarn site:build` before pushing.
74
+
75
+ ## Assertion Convention
41
76
 
42
77
  When asserting reported violations in rule specs, use `toStrictEqual` with the exact violation object `{ severity, line, col, message, raw }`. Never loose or partial matchers for violation objects. (Assertions on other values — counts, fixed code strings, registry invariants — use whatever matcher fits.)
43
78
 
package/lib/helpers.d.ts CHANGED
@@ -30,6 +30,18 @@ export declare function match(needle: string, pattern: string): boolean;
30
30
  export declare const rePCENChar: string;
31
31
  export declare function toNormalizedValue(value: string, spec: Attribute): string;
32
32
  export declare function accnameMayBeMutable(el: Element<any, any>, document: Document<any, any>): boolean;
33
+ /**
34
+ * Whether the accessible name of `el` may be rendered by content the pretender
35
+ * does not know: an unknown entry (`{ dynamic: true }`) in the `contents` of
36
+ * `el`, of a pretended descendant among the children the name computation
37
+ * traverses, of an element `aria-labelledby` refers to, or of the owned label.
38
+ *
39
+ * Not folded into {@link accnameMayBeMutable}: a component with an unknown
40
+ * entry and a static name (`aria-label`, static text) is still decidable and
41
+ * must stay checked (and reported as redundant by `no-redundant-accessible-name`).
42
+ * Call it only once the static name has been computed and found empty.
43
+ */
44
+ export declare function accnameMayComeFromUnknownContent(el: Element<any, any>, document: Document<any, any>): boolean;
33
45
  export declare const labelable: string[];
34
46
  export declare function getOwnedLabel<V extends RuleConfigValue, O extends PlainData>(el: Element<V, O>, document: Document<V, O>): Element<V, O> | null;
35
47
  /** `null`/`undefined` items are silently dropped on add. */
package/lib/helpers.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { escapeCSS } from '@markuplint/ml-spec';
1
2
  /** A `null`/`undefined` condition means the attribute applies unconditionally. */
2
3
  export function attrMatches(
3
4
  // eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types
@@ -119,6 +120,59 @@ document) {
119
120
  }
120
121
  return false;
121
122
  }
123
+ /**
124
+ * Whether the accessible name of `el` may be rendered by content the pretender
125
+ * does not know: an unknown entry (`{ dynamic: true }`) in the `contents` of
126
+ * `el`, of a pretended descendant among the children the name computation
127
+ * traverses, of an element `aria-labelledby` refers to, or of the owned label.
128
+ *
129
+ * Not folded into {@link accnameMayBeMutable}: a component with an unknown
130
+ * entry and a static name (`aria-label`, static text) is still decidable and
131
+ * must stay checked (and reported as redundant by `no-redundant-accessible-name`).
132
+ * Call it only once the static name has been computed and found empty.
133
+ */
134
+ export function accnameMayComeFromUnknownContent(
135
+ // eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types
136
+ el,
137
+ // eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types
138
+ document) {
139
+ if (hasUnknownContent(el)) {
140
+ return true;
141
+ }
142
+ const labelledby = el.getAttribute('aria-labelledby');
143
+ if (labelledby) {
144
+ const referenced = labelledby
145
+ .trim()
146
+ .split(/\s+/)
147
+ .map(id => document.querySelector(`#${escapeCSS(id)}`));
148
+ if (referenced.some(target => target && hasUnknownContent(target))) {
149
+ return true;
150
+ }
151
+ }
152
+ const ownedLabel = getOwnedLabel(el, document);
153
+ return !!ownedLabel && hasUnknownContent(ownedLabel);
154
+ }
155
+ /**
156
+ * Walks the same children the accname resolver of `@markuplint/ml-core` does:
157
+ * for a pretended element, the children the component renders (`as.childNodes`,
158
+ * placed into `contents`), so that a nested component contributes its own
159
+ * `contents`; otherwise the children as written. A hidden child is skipped
160
+ * because the name computation never reads it (`isHidden` in that resolver).
161
+ */
162
+ function hasUnknownContent(
163
+ // eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types
164
+ el) {
165
+ const context = el.pretenderContext;
166
+ if (context?.type === 'pretender' && context.slotContent?.mutable) {
167
+ return true;
168
+ }
169
+ const given = context?.type === 'pretender' ? [...context.as.childNodes] : [...el.childNodes];
170
+ const children = context?.type === 'pretender' && context.slotContent ? context.slotContent.fill(given) : given;
171
+ return children.some(child => child.is(child.ELEMENT_NODE) &&
172
+ child.getAttribute('aria-hidden') !== 'true' &&
173
+ !child.hasAttribute('hidden') &&
174
+ hasUnknownContent(child));
175
+ }
122
176
  export const labelable = ['button', 'input:not([type=hidden])', 'meter', 'output', 'progress', 'select', 'textarea'];
123
177
  export function getOwnedLabel(
124
178
  // eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types
@@ -29,6 +29,13 @@ export default createRule({
29
29
  if (attr.ownerElement.elementType !== 'html') {
30
30
  return;
31
31
  }
32
+ // Framework directives that resolve to an `onXxx` name (Vue `@click`, Alpine
33
+ // `x-on:click`, htmx `hx-on:click`, Svelte `on:click`) are excluded: they never
34
+ // reach the DOM as a literal inline-handler attribute, and each framework
35
+ // documents them as its recommended event-binding idiom.
36
+ if (attr.isDirective) {
37
+ return;
38
+ }
32
39
  const ignoreList = Array.isArray(attr.rule.options.ignore)
33
40
  ? attr.rule.options.ignore
34
41
  : attr.rule.options.ignore
@@ -1,4 +1,5 @@
1
1
  import { getContentModel } from '@markuplint/ml-spec';
2
+ import { getContentOwner, getSlotContent } from './slot-content.js';
2
3
  import { start } from './start.js';
3
4
  /**
4
5
  * Top-level entry point for content model validation of a single element.
@@ -17,7 +18,19 @@ import { start } from './start.js';
17
18
  export function contentModel(
18
19
  // eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types
19
20
  el, rules, options, mode) {
20
- const { model, specs } = createModel(el, rules, mode);
21
+ const slotContent = getSlotContent(el, mode);
22
+ if (slotContent && !slotContent.wrapper) {
23
+ // Several slot wrappers: which one a given child belongs to is unknown.
24
+ return [
25
+ {
26
+ type: 'MATCHED',
27
+ scope: el,
28
+ query: '*',
29
+ hint: {},
30
+ },
31
+ ];
32
+ }
33
+ const { model, specs } = createModel(getContentOwner(el, mode), rules, mode);
21
34
  if (model == null) {
22
35
  return [
23
36
  {
@@ -2,6 +2,7 @@ import { createRule } from '@markuplint/ml-core';
2
2
  import { contentModel } from './content-model.js';
3
3
  import meta from './meta.js';
4
4
  import { transparentMode } from './represent-transparent-nodes.js';
5
+ import { getContentOwner, getReportScope, isContentMutable } from './slot-content.js';
5
6
  export default createRule({
6
7
  meta: meta,
7
8
  defaultValue: [],
@@ -37,7 +38,9 @@ export default createRule({
37
38
  }
38
39
  case 'MISSING_NODE_ONE_OR_MORE': {
39
40
  if (scope.rule.options.ignoreHasMutableChildren &&
40
- (!scope.is(scope.ELEMENT_NODE) || scope.hasMutableChildren())) {
41
+ (!scope.is(scope.ELEMENT_NODE) ||
42
+ scope.hasMutableChildren() ||
43
+ isContentMutable(el, mode))) {
41
44
  break;
42
45
  }
43
46
  message =
@@ -55,7 +58,9 @@ export default createRule({
55
58
  }
56
59
  case 'MISSING_NODE_REQUIRED': {
57
60
  if (scope.rule.options.ignoreHasMutableChildren &&
58
- (!scope.is(scope.ELEMENT_NODE) || scope.hasMutableChildren())) {
61
+ (!scope.is(scope.ELEMENT_NODE) ||
62
+ scope.hasMutableChildren() ||
63
+ isContentMutable(el, mode))) {
59
64
  break;
60
65
  }
61
66
  message =
@@ -76,10 +81,10 @@ export default createRule({
76
81
  message =
77
82
  message ||
78
83
  (transparentMode.has(scope)
79
- ? t('{0} is not allowed in {1} through the transparent model in this context', name(not, t, mode), name(el, t, mode))
80
- : t('{0} is not allowed in {1} in this context', name(not, t, mode), name(el, t, mode)));
84
+ ? t('{0} is not allowed in {1} through the transparent model in this context', name(not, t, mode), name(getContentOwner(el, mode), t, mode))
85
+ : t('{0} is not allowed in {1} in this context', name(not, t, mode), name(getContentOwner(el, mode), t, mode)));
81
86
  report({
82
- scope: not,
87
+ scope: getReportScope(not),
83
88
  message,
84
89
  });
85
90
  break;
@@ -88,7 +93,7 @@ export default createRule({
88
93
  const not = hint.not ?? scope;
89
94
  const tp = hint.transparent ?? el;
90
95
  report({
91
- scope: not,
96
+ scope: getReportScope(not),
92
97
  message: t('{0} is {1} but {2}', name(tp, t, mode), t('a {0}', 'transparent model'), t('also disallows {0} in this context', name(not, t, mode))),
93
98
  });
94
99
  break;
@@ -190,8 +190,12 @@ const conditionWithoutSpecs = {
190
190
  /**
191
191
  * Cache of parsed conditions keyed by specs instance and query string.
192
192
  * Prevents redundant category expansion for the same query across multiple elements.
193
+ *
194
+ * Weakly keyed because each parsed document builds its own specs object: a
195
+ * strong map would gain one entry per parse and never release it, growing
196
+ * without bound in long-lived processes (language server, watch mode).
193
197
  */
194
- const optConditionSpecsBaseCaches = new Map();
198
+ const optConditionSpecsBaseCaches = new WeakMap();
195
199
  /**
196
200
  * Parses a content model query string into a Condition object by expanding
197
201
  * content model category references (e.g., `#phrasing`, `:model(flow)`) into
@@ -1,6 +1,7 @@
1
1
  import { resolveContentModel } from './content-model.js';
2
2
  import { cmLog } from './debug.js';
3
3
  import { order } from './order.js';
4
+ import { expandChildren, getContentOwner, getSlotContent } from './slot-content.js';
4
5
  import { Collection, isTransparent, matches } from './utils.js';
5
6
  /**
6
7
  * A map tracking child nodes that are currently being evaluated through
@@ -96,6 +97,16 @@ childNodes, rules, specs, options, mode) {
96
97
  }
97
98
  continue;
98
99
  }
100
+ // A component whose children sit inside an inner wrapper element (or one of
101
+ // several) cannot be looked through as transparent content: the children
102
+ // belong to the wrapper, not to the outermost element that is being matched.
103
+ const childSlot = getSlotContent(childNode, mode);
104
+ if (childSlot && (!childSlot.wrapper || getContentOwner(childNode, mode) !== childNode)) {
105
+ for (const p of patterns) {
106
+ p.push(childNode);
107
+ }
108
+ continue;
109
+ }
99
110
  const models = resolveContentModel(childNode, rules, specs, mode);
100
111
  if (models == null || typeof models === 'boolean') {
101
112
  for (const p of patterns) {
@@ -111,8 +122,10 @@ childNodes, rules, specs, options, mode) {
111
122
  continue;
112
123
  }
113
124
  const childNodesPatterns = options.evaluateConditionalChildNodes
114
- ? childNode.conditionalChildNodes().map(childNodes => [...childNodes])
115
- : [[...childNode.childNodes].filter(child => !(child.is(child.TEXT_NODE) && child.isWhitespace()))];
125
+ ? childNode.conditionalChildNodes().map(childNodes => [...expandChildren(childNode, [...childNodes], mode)])
126
+ : [
127
+ [...expandChildren(childNode, [...childNode.childNodes], mode)].filter(child => !(child.is(child.TEXT_NODE) && child.isWhitespace())),
128
+ ];
116
129
  const branchGroups = [];
117
130
  for (const branchChildNodes of childNodesPatterns) {
118
131
  const collection = new Collection([...branchChildNodes]);
@@ -0,0 +1,40 @@
1
+ import type { Mode } from './types.js';
2
+ import type { PlainData } from '@markuplint/ml-config';
3
+ import type { ChildNode, Element, PretenderSlotContent, RuleConfigValue } from '@markuplint/ml-core';
4
+ /**
5
+ * Returns the slot-content description of a pretended component, which exists
6
+ * only in `'pretended'` mode: the `'origin'` mode evaluates user tag rules keyed
7
+ * on the component name against the children as written at the usage site.
8
+ *
9
+ * The helpers in this file are generic over the rule's value and options types
10
+ * because `require-owned-elements` shares them, so that both rules read a
11
+ * pretended component the same way.
12
+ *
13
+ * @see PretenderSlotContent in `@markuplint/ml-core`
14
+ */
15
+ export declare function getSlotContent<T extends RuleConfigValue, O extends PlainData>(el: Element<T, O>, mode: Mode): PretenderSlotContent<Element<T, O>, T, O> | null;
16
+ /**
17
+ * The element whose content model governs the children of `el`.
18
+ * It is the slot wrapper element when the pretender declares exactly one, and
19
+ * `el` itself otherwise (including the outermost virtual element, which `el`
20
+ * already delegates its name and attributes to).
21
+ */
22
+ export declare function getContentOwner<T extends RuleConfigValue, O extends PlainData>(el: Element<T, O>, mode: Mode): Element<T, O>;
23
+ /**
24
+ * The children of `el` to validate: the nodes given at the usage site, passed
25
+ * through the pretender's `contents` when it declares any, with the children of
26
+ * fragment pretenders (`element: '#fragment'`) expanded in place because such a
27
+ * component contributes its contents directly to its parent.
28
+ */
29
+ export declare function expandChildren<T extends RuleConfigValue, O extends PlainData>(el: Element<T, O>, given: readonly ChildNode<T, O>[], mode: Mode): readonly ChildNode<T, O>[];
30
+ /**
31
+ * Whether a required child of `el` may be rendered by something the pretender
32
+ * does not know: an unknown entry in its own `contents`, or in the `contents` of
33
+ * a fragment pretender among its children (which stands in the parent).
34
+ */
35
+ export declare function isContentMutable<T extends RuleConfigValue, O extends PlainData>(el: Element<T, O>, mode: Mode): boolean;
36
+ /**
37
+ * A violation on an element that only the pretender renders (`contents`) is reported
38
+ * on the component that renders it: the element does not exist in the source.
39
+ */
40
+ export declare function getReportScope<T extends RuleConfigValue, O extends PlainData>(node: ChildNode<T, O>): ChildNode<T, O>;
@@ -0,0 +1,87 @@
1
+ /**
2
+ * Returns the slot-content description of a pretended component, which exists
3
+ * only in `'pretended'` mode: the `'origin'` mode evaluates user tag rules keyed
4
+ * on the component name against the children as written at the usage site.
5
+ *
6
+ * The helpers in this file are generic over the rule's value and options types
7
+ * because `require-owned-elements` shares them, so that both rules read a
8
+ * pretended component the same way.
9
+ *
10
+ * @see PretenderSlotContent in `@markuplint/ml-core`
11
+ */
12
+ export function getSlotContent(
13
+ // eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types
14
+ el, mode) {
15
+ if (mode !== 'pretended') {
16
+ return null;
17
+ }
18
+ const context = el.pretenderContext;
19
+ return context?.type === 'pretender' ? (context.slotContent ?? null) : null;
20
+ }
21
+ /**
22
+ * The element whose content model governs the children of `el`.
23
+ * It is the slot wrapper element when the pretender declares exactly one, and
24
+ * `el` itself otherwise (including the outermost virtual element, which `el`
25
+ * already delegates its name and attributes to).
26
+ */
27
+ export function getContentOwner(
28
+ // eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types
29
+ el, mode) {
30
+ const wrapper = getSlotContent(el, mode)?.wrapper;
31
+ const context = el.pretenderContext;
32
+ if (wrapper && context?.type === 'pretender' && wrapper !== context.as) {
33
+ return wrapper;
34
+ }
35
+ return el;
36
+ }
37
+ /**
38
+ * The children of `el` to validate: the nodes given at the usage site, passed
39
+ * through the pretender's `contents` when it declares any, with the children of
40
+ * fragment pretenders (`element: '#fragment'`) expanded in place because such a
41
+ * component contributes its contents directly to its parent.
42
+ */
43
+ export function expandChildren(
44
+ // eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types
45
+ el, given, mode) {
46
+ const slotContent = getSlotContent(el, mode);
47
+ const children = slotContent ? slotContent.fill(given) : given;
48
+ return children.flatMap(child => expandFragment(child, mode));
49
+ }
50
+ /**
51
+ * Whether a required child of `el` may be rendered by something the pretender
52
+ * does not know: an unknown entry in its own `contents`, or in the `contents` of
53
+ * a fragment pretender among its children (which stands in the parent).
54
+ */
55
+ export function isContentMutable(
56
+ // eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types
57
+ el, mode) {
58
+ if (getSlotContent(el, mode)?.mutable) {
59
+ return true;
60
+ }
61
+ return [...el.childNodes].some(child => isFragmentPretender(child, mode) && isContentMutable(child, mode));
62
+ }
63
+ function isFragmentPretender(
64
+ // eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types
65
+ node, mode) {
66
+ return (mode === 'pretended' &&
67
+ node.is(node.ELEMENT_NODE) &&
68
+ node.pretenderContext?.type === 'pretender' &&
69
+ node.localName === '#fragment');
70
+ }
71
+ function expandFragment(
72
+ // eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types
73
+ node, mode) {
74
+ return isFragmentPretender(node, mode) ? expandChildren(node, [...node.childNodes], mode) : [node];
75
+ }
76
+ /**
77
+ * A violation on an element that only the pretender renders (`contents`) is reported
78
+ * on the component that renders it: the element does not exist in the source.
79
+ */
80
+ export function getReportScope(
81
+ // eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types
82
+ node) {
83
+ if (node.is(node.ELEMENT_NODE) && node.pretenderContext?.type === 'origin') {
84
+ return node.pretenderContext.origin;
85
+ }
86
+ return node;
87
+ }
@@ -1,5 +1,6 @@
1
1
  import { order } from './order.js';
2
2
  import { representTransparentNodes } from './represent-transparent-nodes.js';
3
+ import { expandChildren } from './slot-content.js';
3
4
  /**
4
5
  * Entry point for validating an element's child nodes against a content model definition.
5
6
  * Handles the three forms of content model: `false` (no content allowed), `true` (any content
@@ -18,9 +19,15 @@ import { representTransparentNodes } from './represent-transparent-nodes.js';
18
19
  export function start(contents,
19
20
  // eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types
20
21
  el, rules, specs, options, mode) {
21
- const childNodesPatterns = options.evaluateConditionalChildNodes
22
+ const givenPatterns = options.evaluateConditionalChildNodes
22
23
  ? el.conditionalChildNodes().map(childNodes => [...childNodes])
23
- : [[...el.childNodes].filter(child => !(child.is(child.TEXT_NODE) && child.isWhitespace()))];
24
+ : [[...el.childNodes]];
25
+ const childNodesPatterns = givenPatterns.map(given => {
26
+ const children = expandChildren(el, given, mode);
27
+ return options.evaluateConditionalChildNodes
28
+ ? [...children]
29
+ : children.filter(child => !(child.is(child.TEXT_NODE) && child.isWhitespace()));
30
+ });
24
31
  return childNodesPatterns.flatMap(childNodes => {
25
32
  if (contents === false) {
26
33
  if (childNodes.length > 0) {
@@ -1,6 +1,6 @@
1
1
  import { createRule, getRoleSpec, getComputedRole } from '@markuplint/ml-core';
2
2
  import { ARIA_RECOMMENDED_VERSION, isExposed } from '@markuplint/ml-spec';
3
- import { accnameMayBeMutable } from '../helpers.js';
3
+ import { accnameMayBeMutable, accnameMayComeFromUnknownContent } from '../helpers.js';
4
4
  import meta from './meta.js';
5
5
  export default createRule({
6
6
  meta: meta,
@@ -25,9 +25,14 @@ export default createRule({
25
25
  return;
26
26
  }
27
27
  const hasAccessibleName = !!el.getAccessibleName(ariaVersion).trim();
28
- if (!hasAccessibleName) {
29
- report({ scope: el, message: t('Require {0}', 'accessible name') });
28
+ if (hasAccessibleName) {
29
+ return;
30
+ }
31
+ // The static scan cannot decide whether the unknown content is the name.
32
+ if (accnameMayComeFromUnknownContent(el, document)) {
33
+ return;
30
34
  }
35
+ report({ scope: el, message: t('Require {0}', 'accessible name') });
31
36
  });
32
37
  },
33
38
  });
@@ -1,5 +1,6 @@
1
1
  import { createRule, getComputedRole, getSpec } from '@markuplint/ml-core';
2
2
  import { ARIA_RECOMMENDED_VERSION } from '@markuplint/ml-spec';
3
+ import { getContentOwner } from '../permitted-contents/slot-content.js';
3
4
  import { checkingRequiredOwnedElements } from '../wai-aria/checkings/required-owned-elements.js';
4
5
  import { defaultOptions } from '../wai-aria/default-options.js';
5
6
  import meta from './meta.js';
@@ -16,6 +17,17 @@ export default createRule({
16
17
  const ariaVersion = el.rule.options?.version ?? document.ruleCommonSettings?.ariaVersion ?? ARIA_RECOMMENDED_VERSION;
17
18
  const computed = getComputedRole(document.specs, el, ariaVersion);
18
19
  report(checkingRequiredOwnedElements({ el, role: computed.role }));
20
+ /**
21
+ * The slot wrapper of a pretended component is a virtual element that is not in
22
+ * the document, so the walk never reaches it, yet it is the element that owns
23
+ * the children given to the component. Its role stands on its own: the
24
+ * component is the one that places it, so no context is asked of its parent.
25
+ */
26
+ const wrapper = getContentOwner(el, 'pretended');
27
+ if (wrapper !== el) {
28
+ const wrapperComputed = getComputedRole(document.specs, wrapper, ariaVersion, true);
29
+ report(checkingRequiredOwnedElements({ el: wrapper, role: wrapperComputed.role }));
30
+ }
19
31
  });
20
32
  },
21
33
  });
@@ -9,6 +9,8 @@ import type { ARIARole } from '@markuplint/ml-spec';
9
9
  * For example, a `list` role must own at least one element with the `listitem` role.
10
10
  * This checker respects `aria-busy="true"` (which signals that content is still loading),
11
11
  * preprocessor blocks, and mutable children from template engines.
12
+ * For a pretended component, the owned elements are the ones the component renders
13
+ * itself (`contents`) together with the children given to it, not the usage site alone.
12
14
  *
13
15
  * @see https://w3c.github.io/aria/#mustContain
14
16
  * @param el - The element node to inspect for allowed accessibility child roles.
@@ -1,4 +1,5 @@
1
1
  import { ARIA_RECOMMENDED_VERSION, getComputedRole, isRequiredOwnedElement, isTransparentForOwnership, } from '@markuplint/ml-spec';
2
+ import { expandChildren, isContentMutable } from '../../permitted-contents/slot-content.js';
2
3
  /**
3
4
  * Checks whether an element with a role that has "Allowed Accessibility Child Roles"
4
5
  * (called "Required Owned Elements" in ARIA 1.2) actually contains children
@@ -7,6 +8,8 @@ import { ARIA_RECOMMENDED_VERSION, getComputedRole, isRequiredOwnedElement, isTr
7
8
  * For example, a `list` role must own at least one element with the `listitem` role.
8
9
  * This checker respects `aria-busy="true"` (which signals that content is still loading),
9
10
  * preprocessor blocks, and mutable children from template engines.
11
+ * For a pretended component, the owned elements are the ones the component renders
12
+ * itself (`contents`) together with the children given to it, not the usage site alone.
10
13
  *
11
14
  * @see https://w3c.github.io/aria/#mustContain
12
15
  * @param el - The element node to inspect for allowed accessibility child roles.
@@ -38,7 +41,7 @@ export const checkingRequiredOwnedElements = ({ el, role }) => t => {
38
41
  // should also be considered as owned elements per the ARIA specification.
39
42
  const ariaVersion = el.rule.options?.version ?? el.ownerMLDocument.ruleCommonSettings?.ariaVersion ?? ARIA_RECOMMENDED_VERSION;
40
43
  const children = classifyChildren(el, role, ariaVersion);
41
- if (children.some(([, type]) => type === 'BUSY')) {
44
+ if (children.some(([, type]) => type === 'BUSY' || type === 'UNKNOWN')) {
42
45
  return;
43
46
  }
44
47
  /**
@@ -66,19 +69,21 @@ export const checkingRequiredOwnedElements = ({ el, role }) => t => {
66
69
  * </table>
67
70
  * ```
68
71
  */
69
- if (el.hasMutableChildren(true)) {
72
+ // A virtual slot wrapper stands for the component, whose nodes are the ones given to it.
73
+ const component = el.pretenderContext?.type === 'origin' ? el.pretenderContext.origin : el;
74
+ if (component.hasMutableChildren(true) || isContentMutable(component, 'pretended')) {
70
75
  return;
71
76
  }
72
- if (mayBeBeforeCreated(el)) {
77
+ if (mayBeBeforeCreated(getOwnedChildNodes(el) ?? [])) {
73
78
  return {
74
- scope: el,
79
+ scope: component,
75
80
  message: t('{0}. Or, {1}', t('{0} requires {1}', t('the {0}', 'child element'), role.allowedAccessibilityChildRoles.length === 1 && role.allowedAccessibilityChildRoles[0]
76
81
  ? t('the "{0*}" {1}', role.allowedAccessibilityChildRoles[0], 'role')
77
82
  : t('the {0}', 'roles') + `: ${t(role.allowedAccessibilityChildRoles)}`), t('require {0}', 'aria-busy="true"')),
78
83
  };
79
84
  }
80
85
  return {
81
- scope: el,
86
+ scope: component,
82
87
  message: t('{0} expects {1}', t('the "{0*}" {1}', role.name, 'role'), role.allowedAccessibilityChildRoles.length === 1 && role.allowedAccessibilityChildRoles[0]
83
88
  ? t('the "{0*}" {1}', role.allowedAccessibilityChildRoles[0], 'role')
84
89
  : t('the {0}', 'roles') + `: ${t(role.allowedAccessibilityChildRoles)}`),
@@ -91,15 +96,13 @@ export const checkingRequiredOwnedElements = ({ el, role }) => t => {
91
96
  * @param el - The element to inspect.
92
97
  * @returns `true` if the element is empty or only contains script/template children.
93
98
  */
94
- function mayBeBeforeCreated(
95
- // eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types
96
- el) {
97
- if (el.isEmpty()) {
99
+ function mayBeBeforeCreated(childNodes) {
100
+ if (childNodes.every(child => child.is(child.TEXT_NODE) && child.textContent?.trim() === '')) {
98
101
  return true;
99
102
  }
100
- return [...el.children].every(child => {
101
- return ['script', 'template'].includes(child.localName);
102
- });
103
+ return childNodes
104
+ .filter(child => child.is(child.ELEMENT_NODE))
105
+ .every(child => ['script', 'template'].includes(child.localName));
103
106
  }
104
107
  /**
105
108
  * Classifies the child nodes of an element for "Allowed Accessibility Child Roles" validation.
@@ -116,19 +119,34 @@ el) {
116
119
  function classifyChildren(
117
120
  // eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types
118
121
  el, role, version) {
122
+ const childNodes = getOwnedChildNodes(el);
123
+ if (!childNodes) {
124
+ return [[el, 'UNKNOWN']];
125
+ }
119
126
  const result = [];
120
- for (const child of el.childNodes) {
127
+ for (const child of childNodes) {
121
128
  if (child.is(child.ELEMENT_NODE)) {
122
129
  if (child.matches('[aria-busy="true" i]')) {
123
130
  result.push([child, 'BUSY']);
124
131
  continue;
125
132
  }
126
- const computedChild = getComputedRole(child.ownerMLDocument.specs, child, version);
133
+ /**
134
+ * The role of a child is resolved against its DOM parent. A child that a pretender
135
+ * places somewhere other than under its DOM parent (a node given at the usage site
136
+ * that the component renders inside its slot wrapper, or an element a fragment
137
+ * component renders in place) would be judged against the wrong parent and lose
138
+ * its role, so it is resolved on its own.
139
+ */
140
+ const computedChild = getComputedRole(child.ownerMLDocument.specs, child, version, child.parentElement !== el);
127
141
  if (role.allowedAccessibilityChildRoles.some(ownedRole => isRequiredOwnedElement(computedChild.el, computedChild.role, ownedRole, child.ownerMLDocument.specs, version))) {
128
142
  result.push([child, 'REQUIRED']);
129
143
  continue;
130
144
  }
131
145
  if (isTransparentForOwnership(computedChild.role?.name, version)) {
146
+ if (isContentMutable(child, 'pretended')) {
147
+ result.push([child, 'UNKNOWN']);
148
+ continue;
149
+ }
132
150
  result.push(...classifyChildren(child, role, version));
133
151
  continue;
134
152
  }
@@ -143,3 +161,43 @@ el, role, version) {
143
161
  }
144
162
  return result;
145
163
  }
164
+ /**
165
+ * The child nodes that `el` owns as the page renders them.
166
+ *
167
+ * For a pretended component this is what the component renders, not what is written
168
+ * at the usage site: the nodes it renders itself (`contents`) around the nodes given
169
+ * to it, `slotContent.fill(given)`. The nodes "given" are `as.childNodes`, which is
170
+ * empty when the component never renders its children (`slots: null`).
171
+ *
172
+ * - A single slot wrapper that differs from the outermost element sits between them:
173
+ * the outermost element owns the wrapper, and the wrapper owns the filled nodes.
174
+ * - Several slot wrappers leave the owner of each node unknown, so `null` is returned
175
+ * and nothing is claimed about the element.
176
+ *
177
+ * @param el - The element, or the virtual slot wrapper of a pretended component.
178
+ * @returns The child nodes, or `null` when they cannot be known.
179
+ */
180
+ function getOwnedChildNodes(
181
+ // eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types
182
+ el) {
183
+ const context = el.pretenderContext;
184
+ if (context?.type === 'pretender') {
185
+ const wrapper = context.slotContent?.wrapper;
186
+ if (wrapper === null) {
187
+ return null;
188
+ }
189
+ if (wrapper && wrapper !== context.as) {
190
+ return [wrapper];
191
+ }
192
+ return expandChildren(el, [...context.as.childNodes], 'pretended');
193
+ }
194
+ // The virtual slot wrapper has no child nodes of its own.
195
+ if (context?.type === 'origin') {
196
+ const origin = context.origin;
197
+ const originContext = origin.pretenderContext;
198
+ if (originContext?.type === 'pretender' && originContext.slotContent?.wrapper === el) {
199
+ return expandChildren(origin, [...originContext.as.childNodes], 'pretended');
200
+ }
201
+ }
202
+ return expandChildren(el, [...el.childNodes], 'pretended');
203
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@markuplint/rules",
3
- "version": "5.0.0",
3
+ "version": "5.1.0",
4
4
  "description": "Built-in rules of markuplint",
5
5
  "repository": {
6
6
  "type": "git",
@@ -31,13 +31,13 @@
31
31
  "./lib/permitted-contents/debug.js": "./lib/permitted-contents/debug.browser.js"
32
32
  },
33
33
  "dependencies": {
34
- "@markuplint/html-spec": "5.0.0",
35
- "@markuplint/ml-config": "5.0.0",
36
- "@markuplint/ml-core": "5.0.0",
37
- "@markuplint/ml-spec": "5.0.0",
38
- "@markuplint/selector": "5.0.0",
39
- "@markuplint/shared": "5.0.0",
40
- "@markuplint/types": "5.0.0",
34
+ "@markuplint/html-spec": "5.1.0",
35
+ "@markuplint/ml-config": "5.1.0",
36
+ "@markuplint/ml-core": "5.1.0",
37
+ "@markuplint/ml-spec": "5.1.0",
38
+ "@markuplint/selector": "5.1.0",
39
+ "@markuplint/shared": "5.1.0",
40
+ "@markuplint/types": "5.1.0",
41
41
  "@mdn/browser-compat-data": "^7.3.2",
42
42
  "@types/debug": "4.1.13",
43
43
  "ansi-colors": "4.1.3",
@@ -47,5 +47,5 @@
47
47
  "image-size": "^2.0.2",
48
48
  "type-fest": "5.6.0"
49
49
  },
50
- "gitHead": "c99706fda4c186aeb87b521eaabc5208ac144bbd"
50
+ "gitHead": "3a061784a8211034ad0c1cbd64798c0213414f65"
51
51
  }