@markuplint/rules 5.0.1 → 5.1.1
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 +18 -0
- package/lib/helpers.d.ts +12 -0
- package/lib/helpers.js +54 -0
- package/lib/permitted-contents/content-model.js +14 -1
- package/lib/permitted-contents/index.js +11 -6
- package/lib/permitted-contents/matches-selector.js +34 -2
- package/lib/permitted-contents/represent-transparent-nodes.js +15 -2
- package/lib/permitted-contents/slot-content.d.ts +40 -0
- package/lib/permitted-contents/slot-content.js +87 -0
- package/lib/permitted-contents/start.js +9 -2
- package/lib/require-accessible-name/index.js +8 -3
- package/lib/require-owned-elements/index.js +12 -0
- package/lib/wai-aria/checkings/required-owned-elements.d.ts +2 -0
- package/lib/wai-aria/checkings/required-owned-elements.js +72 -14
- package/package.json +9 -9
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.1](https://github.com/markuplint/markuplint/compare/v5.1.0...v5.1.1) (2026-10-09)
|
|
7
|
+
|
|
8
|
+
### Bug Fixes
|
|
9
|
+
|
|
10
|
+
- **rules:** check pretenders against content models that include #custom ([d70aa26](https://github.com/markuplint/markuplint/commit/d70aa2666c2318daaab4585aed62db00e5fd9a45))
|
|
11
|
+
|
|
12
|
+
# [5.1.0](https://github.com/markuplint/markuplint/compare/v5.0.1...v5.1.0) (2026-10-08)
|
|
13
|
+
|
|
14
|
+
### Bug Fixes
|
|
15
|
+
|
|
16
|
+
- **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)
|
|
17
|
+
- **rules:** hold permitted-contents condition cache weakly by specs ([9b3200e](https://github.com/markuplint/markuplint/commit/9b3200e49cab314f978b8753c56463a2025d8bd0))
|
|
18
|
+
- **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)
|
|
19
|
+
|
|
20
|
+
### Features
|
|
21
|
+
|
|
22
|
+
- **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)
|
|
23
|
+
|
|
6
24
|
## [5.0.1](https://github.com/markuplint/markuplint/compare/v5.0.0...v5.0.1) (2026-09-22)
|
|
7
25
|
|
|
8
26
|
### Bug Fixes
|
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
|
|
@@ -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
|
|
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) ||
|
|
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) ||
|
|
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;
|
|
@@ -109,7 +109,7 @@ childNode, specs, depth, mode) {
|
|
|
109
109
|
};
|
|
110
110
|
}
|
|
111
111
|
if (childNode.is(childNode.ELEMENT_NODE)) {
|
|
112
|
-
if (
|
|
112
|
+
if (hasCustom && isTreatedAsCustom(childNode, mode)) {
|
|
113
113
|
nodeLog('%s.matches(%s) => CustomElement', childNode.raw, query);
|
|
114
114
|
return {
|
|
115
115
|
type: 'MATCHED',
|
|
@@ -164,6 +164,34 @@ childNode, specs, depth, mode) {
|
|
|
164
164
|
hint: {},
|
|
165
165
|
};
|
|
166
166
|
}
|
|
167
|
+
/**
|
|
168
|
+
* Whether an element is accepted by the `#custom` entry of a content model
|
|
169
|
+
* category without being checked against that category's tag list.
|
|
170
|
+
*
|
|
171
|
+
* `elementType` comes from the parser and does not change when the element is
|
|
172
|
+
* pretended, so a component that stands for `<section>` would still look
|
|
173
|
+
* authored and slip through `<p>`, `<span>` and `<button>`. In `'pretended'`
|
|
174
|
+
* mode such an element is judged by what it stands for instead, exactly as if
|
|
175
|
+
* the user had written that element. The pretender target is always created
|
|
176
|
+
* with `elementType: 'html'`, so a target that is itself a custom element
|
|
177
|
+
* (its name contains a hyphen) is recognised by name.
|
|
178
|
+
*
|
|
179
|
+
* In `'origin'` mode the pretender context is suppressed while matching (see
|
|
180
|
+
* {@link matches}) and the element is seen under its authored name, so it stays
|
|
181
|
+
* accepted as a custom element.
|
|
182
|
+
*/
|
|
183
|
+
function isTreatedAsCustom(
|
|
184
|
+
// eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types
|
|
185
|
+
element, mode) {
|
|
186
|
+
if (element.elementType === 'html') {
|
|
187
|
+
return false;
|
|
188
|
+
}
|
|
189
|
+
const pretenderContext = element.pretenderContext;
|
|
190
|
+
if (mode === 'pretended' && pretenderContext?.type === 'pretender') {
|
|
191
|
+
return pretenderContext.as.localName.includes('-');
|
|
192
|
+
}
|
|
193
|
+
return true;
|
|
194
|
+
}
|
|
167
195
|
/**
|
|
168
196
|
* Pre-computed conditions for well-known queries that do not depend on specs.
|
|
169
197
|
*/
|
|
@@ -190,8 +218,12 @@ const conditionWithoutSpecs = {
|
|
|
190
218
|
/**
|
|
191
219
|
* Cache of parsed conditions keyed by specs instance and query string.
|
|
192
220
|
* Prevents redundant category expansion for the same query across multiple elements.
|
|
221
|
+
*
|
|
222
|
+
* Weakly keyed because each parsed document builds its own specs object: a
|
|
223
|
+
* strong map would gain one entry per parse and never release it, growing
|
|
224
|
+
* without bound in long-lived processes (language server, watch mode).
|
|
193
225
|
*/
|
|
194
|
-
const optConditionSpecsBaseCaches = new
|
|
226
|
+
const optConditionSpecsBaseCaches = new WeakMap();
|
|
195
227
|
/**
|
|
196
228
|
* Parses a content model query string into a Condition object by expanding
|
|
197
229
|
* 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
|
-
: [
|
|
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
|
|
22
|
+
const givenPatterns = options.evaluateConditionalChildNodes
|
|
22
23
|
? el.conditionalChildNodes().map(childNodes => [...childNodes])
|
|
23
|
-
: [[...el.childNodes]
|
|
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 (
|
|
29
|
-
|
|
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
|
-
|
|
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:
|
|
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:
|
|
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
|
-
|
|
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
|
|
101
|
-
|
|
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
|
|
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
|
-
|
|
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.
|
|
3
|
+
"version": "5.1.1",
|
|
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.
|
|
35
|
-
"@markuplint/ml-config": "5.
|
|
36
|
-
"@markuplint/ml-core": "5.
|
|
37
|
-
"@markuplint/ml-spec": "5.
|
|
38
|
-
"@markuplint/selector": "5.
|
|
39
|
-
"@markuplint/shared": "5.
|
|
40
|
-
"@markuplint/types": "5.
|
|
34
|
+
"@markuplint/html-spec": "5.1.1",
|
|
35
|
+
"@markuplint/ml-config": "5.1.1",
|
|
36
|
+
"@markuplint/ml-core": "5.1.1",
|
|
37
|
+
"@markuplint/ml-spec": "5.1.1",
|
|
38
|
+
"@markuplint/selector": "5.1.1",
|
|
39
|
+
"@markuplint/shared": "5.1.1",
|
|
40
|
+
"@markuplint/types": "5.1.1",
|
|
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": "
|
|
50
|
+
"gitHead": "a940c8526e971d590d97395ce924910221208820"
|
|
51
51
|
}
|