@markuplint/rules 5.4.0 → 5.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +34 -0
- package/CLAUDE.md +4 -3
- package/lib/aria-prop-requires-role/index.js +2 -2
- package/lib/attr-eligibility.d.ts +21 -0
- package/lib/attr-eligibility.js +86 -6
- package/lib/attr-order/index.js +2 -1
- package/lib/attr-value-quotes/index.js +2 -2
- package/lib/consistent-table-row-length/index.js +1 -1
- package/lib/create-message.js +6 -5
- package/lib/element-supports-aria-prop/index.js +2 -2
- package/lib/form-attr-references-form/index.js +3 -3
- package/lib/helpers.d.ts +96 -5
- package/lib/helpers.js +187 -29
- package/lib/input-list-references-datalist/index.js +3 -3
- package/lib/itemprop-requires-itemscope/index.js +28 -11
- package/lib/label-for-references-labelable/index.js +3 -3
- package/lib/label-has-control/index.d.ts +17 -0
- package/lib/label-has-control/index.js +28 -1
- package/lib/label-no-multiple-controls/index.d.ts +4 -5
- package/lib/label-no-multiple-controls/index.js +8 -8
- package/lib/landmark-roles/roles.d.ts +13 -1
- package/lib/landmark-roles/roles.js +22 -0
- package/lib/link-types/index.js +8 -8
- package/lib/map-id-name-match/index.js +1 -1
- package/lib/meter-value-bounds/index.js +3 -3
- package/lib/no-ambiguous-navigable-target-names/index.js +5 -1
- package/lib/no-aria-hidden-on-hidden-until-found/index.js +1 -1
- package/lib/no-aria-on-presentational-children/index.js +4 -0
- package/lib/no-aria-on-unsupported-element/index.js +2 -2
- package/lib/no-boolean-attr-value/index.js +2 -1
- package/lib/no-broken-fragment-link/index.d.ts +5 -0
- package/lib/no-broken-fragment-link/index.js +8 -2
- package/lib/no-consecutive-br/index.js +1 -1
- package/lib/no-content-after-body/index.js +2 -2
- package/lib/no-contradictory-aria-prop/index.js +2 -2
- package/lib/no-default-aria-value/index.js +2 -2
- package/lib/no-default-value/index.js +4 -0
- package/lib/no-deprecated-aria-prop/index.js +2 -2
- package/lib/no-disallowed-ancestor/index.d.ts +16 -1
- package/lib/no-disallowed-ancestor/index.js +68 -5
- package/lib/no-disallowed-attr/index.js +5 -1
- package/lib/no-duplicate-autofocus/index.js +1 -1
- package/lib/no-duplicate-dt/index.js +1 -1
- package/lib/no-duplicate-h1/index.js +18 -14
- package/lib/no-duplicate-id/index.d.ts +9 -0
- package/lib/no-duplicate-id/index.js +23 -4
- package/lib/no-duplicate-sibling-attr/index.js +1 -1
- package/lib/no-duplicate-visible-main/index.js +1 -1
- package/lib/no-empty-table-track/index.js +1 -1
- package/lib/no-extra-selected-options/index.js +1 -1
- package/lib/no-ineffective-attr/index.js +4 -0
- package/lib/no-input-file-value/index.js +1 -1
- package/lib/no-invalid-aria-prop-value/index.js +3 -3
- package/lib/no-invalid-attr-value/index.js +12 -0
- package/lib/no-malformed-character-reference/index.js +1 -1
- package/lib/no-nested-top-level-landmark/index.js +20 -17
- package/lib/no-prohibited-naming/index.js +2 -2
- package/lib/no-redundant-accessible-name/index.js +15 -8
- package/lib/no-redundant-aria-prop/index.js +2 -2
- package/lib/no-refer-to-non-existent-id/index.d.ts +15 -0
- package/lib/no-refer-to-non-existent-id/index.js +47 -16
- package/lib/no-restricted-attr/index.js +2 -1
- package/lib/no-restricted-element/index.js +4 -2
- package/lib/no-skipped-heading-level/index.js +9 -4
- package/lib/no-stray-head-or-body-tag/index.d.ts +2 -1
- package/lib/no-stray-head-or-body-tag/index.js +4 -3
- package/lib/no-table-cell-overlap/index.js +3 -2
- package/lib/no-table-span-overflow/index.js +1 -1
- package/lib/no-translate.d.ts +17 -0
- package/lib/no-translate.js +29 -0
- package/lib/no-unclosed-element-at-eof/index.js +1 -1
- package/lib/no-unescaped-char/index.js +1 -1
- package/lib/no-unsupported-browser-features/index.d.ts +9 -0
- package/lib/no-unsupported-browser-features/index.js +10 -7
- package/lib/permitted-contents/content-model.d.ts +14 -0
- package/lib/permitted-contents/content-model.js +21 -1
- package/lib/permitted-contents/index.js +6 -8
- package/lib/permitted-contents/matches-selector.js +32 -5
- package/lib/permitted-contents/represent-transparent-nodes.js +100 -68
- package/lib/permitted-contents/slot-content.d.ts +18 -1
- package/lib/permitted-contents/slot-content.js +49 -2
- package/lib/permitted-contents/start.d.ts +8 -1
- package/lib/permitted-contents/start.js +39 -6
- package/lib/permitted-contents/utils.d.ts +11 -0
- package/lib/permitted-contents/utils.js +15 -0
- package/lib/placeholder-label-option/index.js +4 -26
- package/lib/progress-value-bounds/index.js +2 -2
- package/lib/require-accessible-name/index.js +2 -2
- package/lib/require-adjacent-popover/index.js +4 -2
- package/lib/require-ancestor/index.d.ts +37 -1
- package/lib/require-ancestor/index.js +82 -9
- package/lib/require-ancestor/meta.d.ts +5 -0
- package/lib/require-ancestor/meta.js +5 -0
- package/lib/require-attr/index.js +4 -1
- package/lib/require-datetime/index.js +3 -2
- package/lib/require-dialog-autofocus/index.js +8 -6
- package/lib/require-element/index.js +8 -2
- package/lib/require-h1/index.js +4 -8
- package/lib/require-landmark-label/index.js +17 -15
- package/lib/role-supports-aria-prop/index.js +2 -2
- package/lib/sizes-auto-requires-lazy-loading/index.d.ts +4 -2
- package/lib/sizes-auto-requires-lazy-loading/index.js +4 -2
- package/lib/usemap-references-map/index.js +12 -3
- package/lib/valid-speculation-rules/index.js +1 -1
- package/lib/wai-aria/checkings/contradictory-aria-prop.js +2 -2
- package/lib/wai-aria/checkings/interaction-in-hidden.js +6 -2
- package/lib/wai-aria/checkings/presentational-children.js +38 -17
- package/lib/wai-aria/checkings/redundant-aria-prop.js +1 -1
- package/lib/wai-aria/checkings/required-accessibility-parent-role.js +4 -2
- package/lib/wai-aria/checkings/required-owned-elements.d.ts +2 -0
- package/lib/wai-aria/checkings/required-owned-elements.js +175 -21
- package/lib/wai-aria/checkings/required-prop.js +2 -1
- package/lib/wai-aria/checkings/tab-requires-tabpanel.js +10 -6
- package/lib/wai-aria/checkings/value.d.ts +1 -4
- package/lib/wai-aria/checkings/value.js +8 -19
- package/package.json +14 -11
|
@@ -2,7 +2,7 @@ import { createRule } from '@markuplint/ml-core';
|
|
|
2
2
|
import { contentModel, hasTagRule } 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
|
+
import { getContentOwner, getReportScope, hasMutableChildNodes, isContentMutable } from './slot-content.js';
|
|
6
6
|
export default createRule({
|
|
7
7
|
meta: meta,
|
|
8
8
|
defaultValue: [],
|
|
@@ -39,7 +39,7 @@ export default createRule({
|
|
|
39
39
|
case 'MISSING_NODE_ONE_OR_MORE': {
|
|
40
40
|
if (scope.rule.options.ignoreHasMutableChildren &&
|
|
41
41
|
(!scope.is(scope.ELEMENT_NODE) ||
|
|
42
|
-
scope
|
|
42
|
+
hasMutableChildNodes(scope, mode) ||
|
|
43
43
|
isContentMutable(el, mode))) {
|
|
44
44
|
break;
|
|
45
45
|
}
|
|
@@ -59,7 +59,7 @@ export default createRule({
|
|
|
59
59
|
case 'MISSING_NODE_REQUIRED': {
|
|
60
60
|
if (scope.rule.options.ignoreHasMutableChildren &&
|
|
61
61
|
(!scope.is(scope.ELEMENT_NODE) ||
|
|
62
|
-
scope
|
|
62
|
+
hasMutableChildNodes(scope, mode) ||
|
|
63
63
|
isContentMutable(el, mode))) {
|
|
64
64
|
break;
|
|
65
65
|
}
|
|
@@ -121,7 +121,8 @@ export default createRule({
|
|
|
121
121
|
/**
|
|
122
122
|
* Generates a localized, human-readable name for a child node based on its type.
|
|
123
123
|
* Used in error messages to describe the offending node (e.g., 'the "div" element',
|
|
124
|
-
* 'the text node', 'the
|
|
124
|
+
* 'the text node', 'the doctype', 'the code block'). A comment is not named: it is
|
|
125
|
+
* ignored when a content model is evaluated.
|
|
125
126
|
*
|
|
126
127
|
* In `'origin'` mode, uses {@link MLElement.rawName} so that the diagnostic refers
|
|
127
128
|
* to the original AST identity (e.g. the component name `"Breadcrumbs"`) rather
|
|
@@ -138,14 +139,11 @@ function name(
|
|
|
138
139
|
scope, t, mode) {
|
|
139
140
|
if (scope.is(scope.ELEMENT_NODE)) {
|
|
140
141
|
const displayName = mode === 'origin' ? scope.rawName : scope.localName;
|
|
141
|
-
return t('the "{0}" {1}', displayName, 'element');
|
|
142
|
+
return t('the "{0*}" {1}', displayName, 'element');
|
|
142
143
|
}
|
|
143
144
|
if (scope.is(scope.TEXT_NODE)) {
|
|
144
145
|
return t('the {0}', 'text node');
|
|
145
146
|
}
|
|
146
|
-
if (scope.is(scope.CDATA_SECTION_NODE)) {
|
|
147
|
-
return t('the {0}', 'comment');
|
|
148
|
-
}
|
|
149
147
|
if (scope.is(scope.DOCUMENT_TYPE_NODE)) {
|
|
150
148
|
return t('the {0}', 'doctype');
|
|
151
149
|
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { contentModelCategoryToTagNames } from '@markuplint/ml-spec';
|
|
2
|
+
import { contentModelFirstChildBase, isContentModelFirstChild, isInterElementWhitespace } from '@markuplint/selector';
|
|
2
3
|
import { depthLog } from './debug.js';
|
|
3
4
|
import { cleanObject, matches } from './utils.js';
|
|
4
5
|
/**
|
|
@@ -20,7 +21,7 @@ export function matchesSelector(query,
|
|
|
20
21
|
childNode, specs, depth, mode) {
|
|
21
22
|
const nodeLog = depthLog('node', depth);
|
|
22
23
|
const nodeEnabled = nodeLog.enabled;
|
|
23
|
-
const { selector, hasText, hasNonEmptyText, hasCustom } = optCondition(query, specs);
|
|
24
|
+
const { selector, selectorAsFirstChild, hasText, hasNonEmptyText, hasCustom } = optCondition(query, specs);
|
|
24
25
|
if (childNode == null) {
|
|
25
26
|
if (hasText) {
|
|
26
27
|
if (nodeEnabled) {
|
|
@@ -47,7 +48,7 @@ childNode, specs, depth, mode) {
|
|
|
47
48
|
}
|
|
48
49
|
if (childNode.is(childNode.TEXT_NODE)) {
|
|
49
50
|
if (hasNonEmptyText) {
|
|
50
|
-
if (childNode.
|
|
51
|
+
if (isInterElementWhitespace(childNode.raw)) {
|
|
51
52
|
if (nodeEnabled) {
|
|
52
53
|
nodeLog('<#nonEmptyText>.matches(%s) => WHITESPACE (rejected)', query);
|
|
53
54
|
}
|
|
@@ -85,7 +86,7 @@ childNode, specs, depth, mode) {
|
|
|
85
86
|
hint: {},
|
|
86
87
|
};
|
|
87
88
|
}
|
|
88
|
-
if (childNode.
|
|
89
|
+
if (isInterElementWhitespace(childNode.raw)) {
|
|
89
90
|
if (nodeEnabled) {
|
|
90
91
|
nodeLog('<#text>.matches(%s) => WHITESPACE', query);
|
|
91
92
|
}
|
|
@@ -135,7 +136,14 @@ childNode, specs, depth, mode) {
|
|
|
135
136
|
hint: {},
|
|
136
137
|
};
|
|
137
138
|
}
|
|
138
|
-
|
|
139
|
+
let result = matches(selector, childNode, specs, mode);
|
|
140
|
+
// The siblings are looked at only for an element that the rest does not take.
|
|
141
|
+
// The first child is that of the element's own parent, also inside a transparent
|
|
142
|
+
// element, whose content model is derived from its parent's: `style` in
|
|
143
|
+
// `<div><p></p><noscript><style>` is the first child of the noscript.
|
|
144
|
+
if (!result.matched && selectorAsFirstChild != null && isContentModelFirstChild(childNode)) {
|
|
145
|
+
result = matches(selectorAsFirstChild, childNode, specs, mode);
|
|
146
|
+
}
|
|
139
147
|
if (nodeEnabled) {
|
|
140
148
|
nodeLog('%s.matches(%s) => %s', childNode.raw, query, result.matched);
|
|
141
149
|
}
|
|
@@ -203,6 +211,10 @@ childNode, specs, depth, mode) {
|
|
|
203
211
|
*
|
|
204
212
|
* The `'pretended'` criterion is the one `MLElement.isCustomElement` gives a
|
|
205
213
|
* `:model()` selector; change them together.
|
|
214
|
+
*
|
|
215
|
+
* What a component renders is not known only when no pretender resolved it, and
|
|
216
|
+
* a web component renders as itself: `MLElement#isUnresolvedComponent` is the narrower
|
|
217
|
+
* criterion, which `start` uses to leave the components out of the evaluation.
|
|
206
218
|
*/
|
|
207
219
|
function isTreatedAsCustom(
|
|
208
220
|
// eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types
|
|
@@ -220,18 +232,21 @@ element, mode) {
|
|
|
220
232
|
const conditionWithoutSpecs = {
|
|
221
233
|
'#custom': {
|
|
222
234
|
selector: '#custom',
|
|
235
|
+
selectorAsFirstChild: null,
|
|
223
236
|
hasCustom: true,
|
|
224
237
|
hasText: false,
|
|
225
238
|
hasNonEmptyText: false,
|
|
226
239
|
},
|
|
227
240
|
'#text': {
|
|
228
241
|
selector: '#text',
|
|
242
|
+
selectorAsFirstChild: null,
|
|
229
243
|
hasCustom: false,
|
|
230
244
|
hasText: true,
|
|
231
245
|
hasNonEmptyText: false,
|
|
232
246
|
},
|
|
233
247
|
'#nonEmptyText': {
|
|
234
248
|
selector: '#nonEmptyText',
|
|
249
|
+
selectorAsFirstChild: null,
|
|
235
250
|
hasCustom: false,
|
|
236
251
|
hasText: false,
|
|
237
252
|
hasNonEmptyText: true,
|
|
@@ -271,7 +286,9 @@ function optCondition(query, specs) {
|
|
|
271
286
|
let hasCustom = false;
|
|
272
287
|
let hasText = false;
|
|
273
288
|
let hasNonEmptyText = false;
|
|
274
|
-
|
|
289
|
+
let asFirstChild = null;
|
|
290
|
+
const categoryPattern = /^:model\(([^)]+)\)|^#([a-z-]+)/;
|
|
291
|
+
const selector = query.replace(categoryPattern, (_, $model, _model) => {
|
|
275
292
|
const tag = `#${$model ?? _model}`;
|
|
276
293
|
if (tag === '#nonEmptyText') {
|
|
277
294
|
hasNonEmptyText = true;
|
|
@@ -282,6 +299,7 @@ function optCondition(query, specs) {
|
|
|
282
299
|
throw new Error(`${$model ?? _model} is empty`);
|
|
283
300
|
}
|
|
284
301
|
const selectors = [];
|
|
302
|
+
const firstChildBases = [];
|
|
285
303
|
for (const selector of _selectors) {
|
|
286
304
|
if (selector === '#custom') {
|
|
287
305
|
hasCustom = true;
|
|
@@ -291,12 +309,21 @@ function optCondition(query, specs) {
|
|
|
291
309
|
hasText = true;
|
|
292
310
|
continue;
|
|
293
311
|
}
|
|
312
|
+
const firstChildBase = contentModelFirstChildBase(selector);
|
|
313
|
+
if (firstChildBase != null) {
|
|
314
|
+
firstChildBases.push(firstChildBase);
|
|
315
|
+
continue;
|
|
316
|
+
}
|
|
294
317
|
selectors.push(selector);
|
|
295
318
|
}
|
|
319
|
+
if (firstChildBases.length > 0) {
|
|
320
|
+
asFirstChild = `:is(${firstChildBases.join(',')})`;
|
|
321
|
+
}
|
|
296
322
|
return `:is(${selectors.join(',')})`;
|
|
297
323
|
});
|
|
298
324
|
const result = {
|
|
299
325
|
selector,
|
|
326
|
+
selectorAsFirstChild: asFirstChild == null ? null : query.replace(categoryPattern, () => asFirstChild ?? ''),
|
|
300
327
|
hasCustom,
|
|
301
328
|
hasText,
|
|
302
329
|
hasNonEmptyText,
|
|
@@ -1,8 +1,9 @@
|
|
|
1
|
+
import { isInterElementWhitespace } from '@markuplint/selector';
|
|
1
2
|
import { resolveContentModel } from './content-model.js';
|
|
2
3
|
import { cmLog } from './debug.js';
|
|
3
4
|
import { order } from './order.js';
|
|
4
5
|
import { expandChildren, getContentOwner, getSlotContent } from './slot-content.js';
|
|
5
|
-
import { Collection, isTransparent, matches } from './utils.js';
|
|
6
|
+
import { Collection, isComment, isTransparent, matches } from './utils.js';
|
|
6
7
|
/**
|
|
7
8
|
* A map tracking child nodes that are currently being evaluated through
|
|
8
9
|
* a transparent content model. Used to prevent infinite recursion and
|
|
@@ -97,78 +98,13 @@ childNodes, rules, specs, options, mode) {
|
|
|
97
98
|
}
|
|
98
99
|
continue;
|
|
99
100
|
}
|
|
100
|
-
|
|
101
|
-
|
|
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)) {
|
|
101
|
+
const branchGroups = transparentBranchGroups(childNode, rules, specs, options, mode);
|
|
102
|
+
if (branchGroups == null) {
|
|
105
103
|
for (const p of patterns) {
|
|
106
104
|
p.push(childNode);
|
|
107
105
|
}
|
|
108
106
|
continue;
|
|
109
107
|
}
|
|
110
|
-
const models = resolveContentModel(childNode, rules, specs, mode);
|
|
111
|
-
if (models == null || typeof models === 'boolean') {
|
|
112
|
-
for (const p of patterns) {
|
|
113
|
-
p.push(childNode);
|
|
114
|
-
}
|
|
115
|
-
continue;
|
|
116
|
-
}
|
|
117
|
-
const noTransparentModels = models.filter(m => !isTransparent(m));
|
|
118
|
-
if (noTransparentModels.length === models.length) {
|
|
119
|
-
for (const p of patterns) {
|
|
120
|
-
p.push(childNode);
|
|
121
|
-
}
|
|
122
|
-
continue;
|
|
123
|
-
}
|
|
124
|
-
const childNodesPatterns = options.evaluateConditionalChildNodes
|
|
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
|
-
];
|
|
129
|
-
const branchGroups = [];
|
|
130
|
-
for (const branchChildNodes of childNodesPatterns) {
|
|
131
|
-
const collection = new Collection([...branchChildNodes]);
|
|
132
|
-
let unmatched;
|
|
133
|
-
if (noTransparentModels.length > 0) {
|
|
134
|
-
const result = order(noTransparentModels, collection.unmatched, rules, specs, options, Number.POSITIVE_INFINITY, mode);
|
|
135
|
-
unmatched = result.unmatched;
|
|
136
|
-
}
|
|
137
|
-
else {
|
|
138
|
-
unmatched = collection.unmatched;
|
|
139
|
-
}
|
|
140
|
-
const transparent = models.find(m => isTransparent(m));
|
|
141
|
-
if (!transparent || !isTransparent(transparent)) {
|
|
142
|
-
throw new Error('Unreachable code');
|
|
143
|
-
}
|
|
144
|
-
const branchChildren = [];
|
|
145
|
-
for (const _child of unmatched) {
|
|
146
|
-
const child = _child;
|
|
147
|
-
if (transparentMode.has(child)) {
|
|
148
|
-
continue;
|
|
149
|
-
}
|
|
150
|
-
transparentMode.set(child, true);
|
|
151
|
-
if (child.is(child.ELEMENT_NODE)) {
|
|
152
|
-
const transparentCondMatched = matches(transparent.transparent, child, specs, mode);
|
|
153
|
-
if (!transparentCondMatched.matched) {
|
|
154
|
-
branchChildren.push({
|
|
155
|
-
type: 'TRANSPARENT_MODEL_DISALLOWS',
|
|
156
|
-
matched: [],
|
|
157
|
-
unmatched: [childNode],
|
|
158
|
-
zeroMatch: false,
|
|
159
|
-
query: transparent.transparent,
|
|
160
|
-
hint: {
|
|
161
|
-
not: transparentCondMatched.not,
|
|
162
|
-
transparent: childNode,
|
|
163
|
-
},
|
|
164
|
-
});
|
|
165
|
-
continue;
|
|
166
|
-
}
|
|
167
|
-
}
|
|
168
|
-
branchChildren.push(child);
|
|
169
|
-
}
|
|
170
|
-
branchGroups.push(branchChildren);
|
|
171
|
-
}
|
|
172
108
|
// The transparent element itself is still a child of the current parent
|
|
173
109
|
// and must be evaluated against the parent's own content model (e.g., a
|
|
174
110
|
// `<button>` must reject an `<a href>` descendant even though `<a>`'s
|
|
@@ -223,3 +159,99 @@ childNodes, rules, specs, options, mode) {
|
|
|
223
159
|
});
|
|
224
160
|
return result;
|
|
225
161
|
}
|
|
162
|
+
/**
|
|
163
|
+
* The children that a transparent element stands for in its parent, one group per
|
|
164
|
+
* conditional branch, or `null` when the element is not looked through (its content
|
|
165
|
+
* model has no transparent part, or it is a component whose children belong to an
|
|
166
|
+
* inner wrapper).
|
|
167
|
+
*
|
|
168
|
+
* A child that is itself a transparent element is followed by the children it
|
|
169
|
+
* stands for, so that `<span><ins><ins><div>` puts the `<div>` in the `<span>`: the
|
|
170
|
+
* HTML Standard derives the content model of a transparent element from its parent
|
|
171
|
+
* again for each transparent element in which the content finds itself.
|
|
172
|
+
*
|
|
173
|
+
* > In some cases, where transparent elements are nested in each other, the process
|
|
174
|
+
* > has to be applied iteratively.
|
|
175
|
+
*
|
|
176
|
+
* @see https://html.spec.whatwg.org/multipage/dom.html#transparent-content-models
|
|
177
|
+
*
|
|
178
|
+
* The branches of a nested transparent element are merged into each branch of the
|
|
179
|
+
* outer one, the same over-approximation as the fallback documented on
|
|
180
|
+
* {@link MAX_PATTERNS}.
|
|
181
|
+
*/
|
|
182
|
+
function transparentBranchGroups(
|
|
183
|
+
// eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types
|
|
184
|
+
childNode, rules, specs, options, mode) {
|
|
185
|
+
// A component whose children sit inside an inner wrapper element (or one of
|
|
186
|
+
// several) cannot be looked through as transparent content: the children
|
|
187
|
+
// belong to the wrapper, not to the outermost element that is being matched.
|
|
188
|
+
const childSlot = getSlotContent(childNode, mode);
|
|
189
|
+
if (childSlot && (!childSlot.wrapper || getContentOwner(childNode, mode) !== childNode)) {
|
|
190
|
+
return null;
|
|
191
|
+
}
|
|
192
|
+
const models = resolveContentModel(childNode, rules, specs, mode);
|
|
193
|
+
if (models == null || typeof models === 'boolean') {
|
|
194
|
+
return null;
|
|
195
|
+
}
|
|
196
|
+
const noTransparentModels = models.filter(m => !isTransparent(m));
|
|
197
|
+
if (noTransparentModels.length === models.length) {
|
|
198
|
+
return null;
|
|
199
|
+
}
|
|
200
|
+
const childNodesPatterns = options.evaluateConditionalChildNodes
|
|
201
|
+
? childNode
|
|
202
|
+
.conditionalChildNodes()
|
|
203
|
+
.map(childNodes => [...expandChildren(childNode, [...childNodes], mode)].filter(child => !isComment(child)))
|
|
204
|
+
: [
|
|
205
|
+
[...expandChildren(childNode, [...childNode.childNodes], mode)].filter(child => !isComment(child) && !(child.is(child.TEXT_NODE) && isInterElementWhitespace(child.raw))),
|
|
206
|
+
];
|
|
207
|
+
const branchGroups = [];
|
|
208
|
+
for (const branchChildNodes of childNodesPatterns) {
|
|
209
|
+
const collection = new Collection([...branchChildNodes]);
|
|
210
|
+
let unmatched;
|
|
211
|
+
if (noTransparentModels.length > 0) {
|
|
212
|
+
const result = order(noTransparentModels, collection.unmatched, rules, specs, options, Number.POSITIVE_INFINITY, mode);
|
|
213
|
+
unmatched = result.unmatched;
|
|
214
|
+
}
|
|
215
|
+
else {
|
|
216
|
+
unmatched = collection.unmatched;
|
|
217
|
+
}
|
|
218
|
+
const transparent = models.find(m => isTransparent(m));
|
|
219
|
+
if (!transparent || !isTransparent(transparent)) {
|
|
220
|
+
throw new Error('Unreachable code');
|
|
221
|
+
}
|
|
222
|
+
const branchChildren = [];
|
|
223
|
+
for (const _child of unmatched) {
|
|
224
|
+
const child = _child;
|
|
225
|
+
if (transparentMode.has(child)) {
|
|
226
|
+
continue;
|
|
227
|
+
}
|
|
228
|
+
transparentMode.set(child, true);
|
|
229
|
+
if (child.is(child.ELEMENT_NODE)) {
|
|
230
|
+
const transparentCondMatched = matches(transparent.transparent, child, specs, mode);
|
|
231
|
+
if (!transparentCondMatched.matched) {
|
|
232
|
+
branchChildren.push({
|
|
233
|
+
type: 'TRANSPARENT_MODEL_DISALLOWS',
|
|
234
|
+
matched: [],
|
|
235
|
+
unmatched: [childNode],
|
|
236
|
+
zeroMatch: false,
|
|
237
|
+
query: transparent.transparent,
|
|
238
|
+
hint: {
|
|
239
|
+
not: transparentCondMatched.not,
|
|
240
|
+
transparent: childNode,
|
|
241
|
+
},
|
|
242
|
+
});
|
|
243
|
+
continue;
|
|
244
|
+
}
|
|
245
|
+
branchChildren.push(child);
|
|
246
|
+
const nested = transparentBranchGroups(child, rules, specs, options, mode);
|
|
247
|
+
if (nested != null) {
|
|
248
|
+
branchChildren.push(...nested.flat());
|
|
249
|
+
}
|
|
250
|
+
continue;
|
|
251
|
+
}
|
|
252
|
+
branchChildren.push(child);
|
|
253
|
+
}
|
|
254
|
+
branchGroups.push(branchChildren);
|
|
255
|
+
}
|
|
256
|
+
return branchGroups;
|
|
257
|
+
}
|
|
@@ -24,7 +24,8 @@ export declare function getContentOwner<T extends RuleConfigValue, O extends Pla
|
|
|
24
24
|
* The children of `el` to validate: the nodes given at the usage site, passed
|
|
25
25
|
* through the pretender's `contents` when it declares any, with the children of
|
|
26
26
|
* fragment pretenders (`element: '#fragment'`) expanded in place because such a
|
|
27
|
-
* component contributes its contents directly to its parent.
|
|
27
|
+
* component contributes its contents directly to its parent. What the framework
|
|
28
|
+
* moves to the `<head>` (`movedToHead` of the spec) is not a child of `el` in the DOM.
|
|
28
29
|
*/
|
|
29
30
|
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
|
/**
|
|
@@ -33,6 +34,22 @@ export declare function expandChildren<T extends RuleConfigValue, O extends Plai
|
|
|
33
34
|
* a fragment pretender among its children (which stands in the parent).
|
|
34
35
|
*/
|
|
35
36
|
export declare function isContentMutable<T extends RuleConfigValue, O extends PlainData>(el: Element<T, O>, mode: Mode): boolean;
|
|
37
|
+
/**
|
|
38
|
+
* Whether a child of `el` that the content model reads is a block of a preprocessor or a `slot`
|
|
39
|
+
* (`mayRenderUnknownChildren`): a required child that is missing may be what it renders.
|
|
40
|
+
* Only the children are looked at (the children of a fragment pretender included, as the content
|
|
41
|
+
* model reads them): what a descendant renders is not a child of `el`.
|
|
42
|
+
*
|
|
43
|
+
* A component that no pretender resolved is not looked at here: `start` leaves it out of each
|
|
44
|
+
* conditional branch that has one, and drops the missing children of that branch only.
|
|
45
|
+
*
|
|
46
|
+
* @template T - The rule configuration value type
|
|
47
|
+
* @template O - The rule options type
|
|
48
|
+
* @param el - The element whose children are looked at
|
|
49
|
+
* @param mode - Which identity of `el` the content model is read for
|
|
50
|
+
* @returns True if a child may stand for children that are not known
|
|
51
|
+
*/
|
|
52
|
+
export declare function hasMutableChildNodes<T extends RuleConfigValue, O extends PlainData>(el: Element<T, O>, mode: Mode): boolean;
|
|
36
53
|
/**
|
|
37
54
|
* A violation on an element that only the pretender renders (`contents`) is reported
|
|
38
55
|
* on the component that renders it: the element does not exist in the source.
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { mayRenderUnknownChildren } from '../helpers.js';
|
|
1
2
|
/**
|
|
2
3
|
* Returns the slot-content description of a pretended component, which exists
|
|
3
4
|
* only in `'pretended'` mode: the `'origin'` mode evaluates user tag rules keyed
|
|
@@ -34,14 +35,40 @@ el, mode) {
|
|
|
34
35
|
}
|
|
35
36
|
return el;
|
|
36
37
|
}
|
|
38
|
+
/**
|
|
39
|
+
* Whether the framework moves `child` out of `parent`: the spec of the framework lists the elements
|
|
40
|
+
* it places in the `<head>` wherever they are written (`movedToHead`), so what `parent` contains in
|
|
41
|
+
* the DOM does not include them. The `<head>` itself keeps them: that is where they are placed.
|
|
42
|
+
*
|
|
43
|
+
* @see MLMLSpec.movedToHead in `@markuplint/ml-spec`
|
|
44
|
+
*/
|
|
45
|
+
function isMovedToHead(
|
|
46
|
+
// eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types
|
|
47
|
+
parent,
|
|
48
|
+
// eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types
|
|
49
|
+
child) {
|
|
50
|
+
const selectors = parent.ownerMLDocument.specs.movedToHead;
|
|
51
|
+
if (!selectors || !child.is(child.ELEMENT_NODE) || parent.localName === 'head') {
|
|
52
|
+
return false;
|
|
53
|
+
}
|
|
54
|
+
return selectors.some(selector => child.matches(selector));
|
|
55
|
+
}
|
|
37
56
|
/**
|
|
38
57
|
* The children of `el` to validate: the nodes given at the usage site, passed
|
|
39
58
|
* through the pretender's `contents` when it declares any, with the children of
|
|
40
59
|
* fragment pretenders (`element: '#fragment'`) expanded in place because such a
|
|
41
|
-
* component contributes its contents directly to its parent.
|
|
60
|
+
* component contributes its contents directly to its parent. What the framework
|
|
61
|
+
* moves to the `<head>` (`movedToHead` of the spec) is not a child of `el` in the DOM.
|
|
42
62
|
*/
|
|
43
63
|
export function expandChildren(
|
|
44
64
|
// eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types
|
|
65
|
+
el, given, mode) {
|
|
66
|
+
// Only here, not in the expansion of a fragment: what a fragment contributes belongs to `el`, so
|
|
67
|
+
// it is `el` that decides whether it is moved (the `<head>` keeps it), not the fragment.
|
|
68
|
+
return fillAndExpand(el, given, mode).filter(child => !isMovedToHead(el, child));
|
|
69
|
+
}
|
|
70
|
+
function fillAndExpand(
|
|
71
|
+
// eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types
|
|
45
72
|
el, given, mode) {
|
|
46
73
|
const slotContent = getSlotContent(el, mode);
|
|
47
74
|
const children = slotContent ? slotContent.fill(given) : given;
|
|
@@ -60,6 +87,26 @@ el, mode) {
|
|
|
60
87
|
}
|
|
61
88
|
return [...el.childNodes].some(child => isFragmentPretender(child, mode) && isContentMutable(child, mode));
|
|
62
89
|
}
|
|
90
|
+
/**
|
|
91
|
+
* Whether a child of `el` that the content model reads is a block of a preprocessor or a `slot`
|
|
92
|
+
* (`mayRenderUnknownChildren`): a required child that is missing may be what it renders.
|
|
93
|
+
* Only the children are looked at (the children of a fragment pretender included, as the content
|
|
94
|
+
* model reads them): what a descendant renders is not a child of `el`.
|
|
95
|
+
*
|
|
96
|
+
* A component that no pretender resolved is not looked at here: `start` leaves it out of each
|
|
97
|
+
* conditional branch that has one, and drops the missing children of that branch only.
|
|
98
|
+
*
|
|
99
|
+
* @template T - The rule configuration value type
|
|
100
|
+
* @template O - The rule options type
|
|
101
|
+
* @param el - The element whose children are looked at
|
|
102
|
+
* @param mode - Which identity of `el` the content model is read for
|
|
103
|
+
* @returns True if a child may stand for children that are not known
|
|
104
|
+
*/
|
|
105
|
+
export function hasMutableChildNodes(
|
|
106
|
+
// eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types
|
|
107
|
+
el, mode) {
|
|
108
|
+
return expandChildren(el, [...el.getPureChildNodes()], mode).some(child => mayRenderUnknownChildren(child, false));
|
|
109
|
+
}
|
|
63
110
|
function isFragmentPretender(
|
|
64
111
|
// eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types
|
|
65
112
|
node, mode) {
|
|
@@ -71,7 +118,7 @@ node, mode) {
|
|
|
71
118
|
function expandFragment(
|
|
72
119
|
// eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types
|
|
73
120
|
node, mode) {
|
|
74
|
-
return isFragmentPretender(node, mode) ?
|
|
121
|
+
return isFragmentPretender(node, mode) ? fillAndExpand(node, [...node.childNodes], mode) : [node];
|
|
75
122
|
}
|
|
76
123
|
/**
|
|
77
124
|
* A violation on an element that only the pretender renders (`contents`) is reported
|
|
@@ -10,10 +10,17 @@ import type { ReadonlyDeep } from 'type-fest';
|
|
|
10
10
|
* When `options.evaluateConditionalChildNodes` is enabled, all conditional branches
|
|
11
11
|
* (e.g., from template directives) are evaluated independently.
|
|
12
12
|
*
|
|
13
|
+
* A component that no pretender resolved (`MLElement#isUnresolvedComponent`) renders something that is
|
|
14
|
+
* not known: it may be the element that the content model asks for, or stand for several. With
|
|
15
|
+
* `ignoreUnresolvedComponents` the children are evaluated without such components, and only what holds
|
|
16
|
+
* whatever they render is kept ({@link holdsWhateverIsRendered}). Without it they are evaluated as
|
|
17
|
+
* written, by the name of the component (`isTreatedAsCustom` of `matches-selector.ts`).
|
|
18
|
+
*
|
|
13
19
|
* @param contents - The content model definition: `false` for empty, `true` for any, or an array of patterns.
|
|
14
20
|
* @param el - The parent element whose children are being validated.
|
|
15
21
|
* @param specs - The resolved spec data for content model lookups.
|
|
16
22
|
* @param options - Validation behavior options.
|
|
23
|
+
* @param ignoreUnresolvedComponents - Whether to evaluate the children without the components that no pretender resolved.
|
|
17
24
|
* @returns An array of content model results describing any violations found.
|
|
18
25
|
*/
|
|
19
|
-
export declare function start(contents: ReadonlyDeep<ContentModel['contents']>, el: Element, rules: readonly TagRule[], specs: Specs, options: Options, mode: Mode): ContentModelResult[];
|
|
26
|
+
export declare function start(contents: ReadonlyDeep<ContentModel['contents']>, el: Element, rules: readonly TagRule[], specs: Specs, options: Options, mode: Mode, ignoreUnresolvedComponents?: boolean): ContentModelResult[];
|
|
@@ -1,6 +1,8 @@
|
|
|
1
|
+
import { isInterElementWhitespace } from '@markuplint/selector';
|
|
1
2
|
import { order } from './order.js';
|
|
2
3
|
import { representTransparentNodes } from './represent-transparent-nodes.js';
|
|
3
4
|
import { expandChildren } from './slot-content.js';
|
|
5
|
+
import { isComment } from './utils.js';
|
|
4
6
|
/**
|
|
5
7
|
* Entry point for validating an element's child nodes against a content model definition.
|
|
6
8
|
* Handles the three forms of content model: `false` (no content allowed), `true` (any content
|
|
@@ -10,25 +12,42 @@ import { expandChildren } from './slot-content.js';
|
|
|
10
12
|
* When `options.evaluateConditionalChildNodes` is enabled, all conditional branches
|
|
11
13
|
* (e.g., from template directives) are evaluated independently.
|
|
12
14
|
*
|
|
15
|
+
* A component that no pretender resolved (`MLElement#isUnresolvedComponent`) renders something that is
|
|
16
|
+
* not known: it may be the element that the content model asks for, or stand for several. With
|
|
17
|
+
* `ignoreUnresolvedComponents` the children are evaluated without such components, and only what holds
|
|
18
|
+
* whatever they render is kept ({@link holdsWhateverIsRendered}). Without it they are evaluated as
|
|
19
|
+
* written, by the name of the component (`isTreatedAsCustom` of `matches-selector.ts`).
|
|
20
|
+
*
|
|
13
21
|
* @param contents - The content model definition: `false` for empty, `true` for any, or an array of patterns.
|
|
14
22
|
* @param el - The parent element whose children are being validated.
|
|
15
23
|
* @param specs - The resolved spec data for content model lookups.
|
|
16
24
|
* @param options - Validation behavior options.
|
|
25
|
+
* @param ignoreUnresolvedComponents - Whether to evaluate the children without the components that no pretender resolved.
|
|
17
26
|
* @returns An array of content model results describing any violations found.
|
|
18
27
|
*/
|
|
19
28
|
export function start(contents,
|
|
20
29
|
// eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types
|
|
21
|
-
el, rules, specs, options, mode) {
|
|
30
|
+
el, rules, specs, options, mode, ignoreUnresolvedComponents = false) {
|
|
22
31
|
const givenPatterns = options.evaluateConditionalChildNodes
|
|
23
32
|
? el.conditionalChildNodes().map(childNodes => [...childNodes])
|
|
24
33
|
: [[...el.childNodes]];
|
|
25
|
-
|
|
26
|
-
|
|
34
|
+
// A model that is `false` does not allow a component either: what it renders may be anything
|
|
35
|
+
const ignoresComponents = ignoreUnresolvedComponents && contents !== false && contents !== true;
|
|
36
|
+
const isIgnoredComponent = (child) => ignoresComponents && child.is(child.ELEMENT_NODE) && child.isUnresolvedComponent();
|
|
37
|
+
const expandedPatterns = givenPatterns.map(given => expandChildren(el, given, mode));
|
|
38
|
+
// Per pattern: a conditional branch without a component is evaluated as it is written
|
|
39
|
+
const hasIgnoredComponents = expandedPatterns.map(expanded => expanded.some(child => isIgnoredComponent(child)));
|
|
40
|
+
const childNodesPatterns = expandedPatterns.map(expanded => {
|
|
41
|
+
const children = expanded.filter(child => !isComment(child) && !isIgnoredComponent(child));
|
|
27
42
|
return options.evaluateConditionalChildNodes
|
|
28
43
|
? [...children]
|
|
29
|
-
: children.filter(child => !(child.is(child.TEXT_NODE) && child.
|
|
44
|
+
: children.filter(child => !(child.is(child.TEXT_NODE) && isInterElementWhitespace(child.raw)));
|
|
45
|
+
});
|
|
46
|
+
return childNodesPatterns.flatMap((childNodes, index) => {
|
|
47
|
+
const results = evaluate(childNodes);
|
|
48
|
+
return hasIgnoredComponents[index] ? results.filter(result => holdsWhateverIsRendered(result)) : results;
|
|
30
49
|
});
|
|
31
|
-
|
|
50
|
+
function evaluate(childNodes) {
|
|
32
51
|
if (contents === false) {
|
|
33
52
|
if (childNodes.length > 0) {
|
|
34
53
|
return [
|
|
@@ -80,5 +99,19 @@ el, rules, specs, options, mode) {
|
|
|
80
99
|
})),
|
|
81
100
|
];
|
|
82
101
|
});
|
|
83
|
-
}
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
/**
|
|
105
|
+
* Whether a violation found without the components that no pretender resolved holds whatever
|
|
106
|
+
* they render.
|
|
107
|
+
*
|
|
108
|
+
* A component may render the child that is missing, so a missing child is not one. An element
|
|
109
|
+
* that the content model does not take is (`<div>` in `<ul><Item /><div></div></ul>`, the second
|
|
110
|
+
* `<title>` in `<head><Meta /><title /><title /></head>`): `order` reports it only after it has
|
|
111
|
+
* consumed every entry that it can, and a component adds nodes without moving or removing the ones
|
|
112
|
+
* that are written. The order is not reported either: when the elements are out of order the
|
|
113
|
+
* entry that comes first is missing, which a component may render (`<dl><Term /><dd></dd></dl>`).
|
|
114
|
+
*/
|
|
115
|
+
function holdsWhateverIsRendered(result) {
|
|
116
|
+
return result.type !== 'MISSING_NODE_REQUIRED' && result.type !== 'MISSING_NODE_ONE_OR_MORE';
|
|
84
117
|
}
|
|
@@ -1,6 +1,17 @@
|
|
|
1
1
|
import type { ChildNode, Hints, MissingNodeReason, Mode, RepeatSign, Specs } from './types.js';
|
|
2
2
|
import type { PermittedContentPattern, PermittedContentChoice, PermittedContentOneOrMore, PermittedContentOptional, PermittedContentRequire, PermittedContentTransparent, PermittedContentZeroOrMore, Model } from '@markuplint/ml-spec';
|
|
3
3
|
import type { ReadonlyDeep } from 'type-fest';
|
|
4
|
+
/**
|
|
5
|
+
* Whether a child node is a comment, which a content model does not see.
|
|
6
|
+
* Without this, a comment is matched like an element and takes the place of
|
|
7
|
+
* one the model requires (`<html><!-- c --><head>` leaves `head` unmatched).
|
|
8
|
+
*
|
|
9
|
+
* @param node - The child node to test.
|
|
10
|
+
* @returns True if the node is a comment node.
|
|
11
|
+
* @see https://html.spec.whatwg.org/multipage/dom.html#content-models
|
|
12
|
+
* > Inter-element whitespace, comment nodes, and processing instruction nodes must be ignored when establishing whether an element's contents match the element's content model or not
|
|
13
|
+
*/
|
|
14
|
+
export declare function isComment(node: ChildNode): boolean;
|
|
4
15
|
/**
|
|
5
16
|
* Determines whether a given value is a terminal model (a selector string or an array
|
|
6
17
|
* of selector strings) rather than a nested array of content model patterns.
|
|
@@ -1,6 +1,21 @@
|
|
|
1
1
|
import { createSelector } from '@markuplint/selector';
|
|
2
2
|
import { bgGreen, green, bgRed, bgBlue, blue, bgMagenta, cyan } from './debug.js';
|
|
3
3
|
import { transparentMode } from './represent-transparent-nodes.js';
|
|
4
|
+
/**
|
|
5
|
+
* Whether a child node is a comment, which a content model does not see.
|
|
6
|
+
* Without this, a comment is matched like an element and takes the place of
|
|
7
|
+
* one the model requires (`<html><!-- c --><head>` leaves `head` unmatched).
|
|
8
|
+
*
|
|
9
|
+
* @param node - The child node to test.
|
|
10
|
+
* @returns True if the node is a comment node.
|
|
11
|
+
* @see https://html.spec.whatwg.org/multipage/dom.html#content-models
|
|
12
|
+
* > Inter-element whitespace, comment nodes, and processing instruction nodes must be ignored when establishing whether an element's contents match the element's content model or not
|
|
13
|
+
*/
|
|
14
|
+
export function isComment(
|
|
15
|
+
// eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types
|
|
16
|
+
node) {
|
|
17
|
+
return node.is(node.COMMENT_NODE);
|
|
18
|
+
}
|
|
4
19
|
/**
|
|
5
20
|
* Determines whether a given value is a terminal model (a selector string or an array
|
|
6
21
|
* of selector strings) rather than a nested array of content model patterns.
|