@markuplint/rules 5.4.0 → 5.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (116) hide show
  1. package/CHANGELOG.md +34 -0
  2. package/CLAUDE.md +4 -3
  3. package/lib/aria-prop-requires-role/index.js +2 -2
  4. package/lib/attr-eligibility.d.ts +21 -0
  5. package/lib/attr-eligibility.js +86 -6
  6. package/lib/attr-order/index.js +2 -1
  7. package/lib/attr-value-quotes/index.js +2 -2
  8. package/lib/consistent-table-row-length/index.js +1 -1
  9. package/lib/create-message.js +6 -5
  10. package/lib/element-supports-aria-prop/index.js +2 -2
  11. package/lib/form-attr-references-form/index.js +3 -3
  12. package/lib/helpers.d.ts +96 -5
  13. package/lib/helpers.js +187 -29
  14. package/lib/input-list-references-datalist/index.js +3 -3
  15. package/lib/itemprop-requires-itemscope/index.js +28 -11
  16. package/lib/label-for-references-labelable/index.js +3 -3
  17. package/lib/label-has-control/index.d.ts +17 -0
  18. package/lib/label-has-control/index.js +28 -1
  19. package/lib/label-no-multiple-controls/index.d.ts +4 -5
  20. package/lib/label-no-multiple-controls/index.js +8 -8
  21. package/lib/landmark-roles/roles.d.ts +13 -1
  22. package/lib/landmark-roles/roles.js +22 -0
  23. package/lib/link-types/index.js +8 -8
  24. package/lib/map-id-name-match/index.js +1 -1
  25. package/lib/meter-value-bounds/index.js +3 -3
  26. package/lib/no-ambiguous-navigable-target-names/index.js +5 -1
  27. package/lib/no-aria-hidden-on-hidden-until-found/index.js +1 -1
  28. package/lib/no-aria-on-presentational-children/index.js +4 -0
  29. package/lib/no-aria-on-unsupported-element/index.js +2 -2
  30. package/lib/no-boolean-attr-value/index.js +2 -1
  31. package/lib/no-broken-fragment-link/index.d.ts +5 -0
  32. package/lib/no-broken-fragment-link/index.js +8 -2
  33. package/lib/no-consecutive-br/index.js +1 -1
  34. package/lib/no-content-after-body/index.js +2 -2
  35. package/lib/no-contradictory-aria-prop/index.js +2 -2
  36. package/lib/no-default-aria-value/index.js +2 -2
  37. package/lib/no-default-value/index.js +4 -0
  38. package/lib/no-deprecated-aria-prop/index.js +2 -2
  39. package/lib/no-disallowed-ancestor/index.d.ts +16 -1
  40. package/lib/no-disallowed-ancestor/index.js +68 -5
  41. package/lib/no-disallowed-attr/index.js +5 -1
  42. package/lib/no-duplicate-autofocus/index.js +1 -1
  43. package/lib/no-duplicate-dt/index.js +1 -1
  44. package/lib/no-duplicate-h1/index.js +18 -14
  45. package/lib/no-duplicate-id/index.d.ts +9 -0
  46. package/lib/no-duplicate-id/index.js +23 -4
  47. package/lib/no-duplicate-sibling-attr/index.js +1 -1
  48. package/lib/no-duplicate-visible-main/index.js +1 -1
  49. package/lib/no-empty-table-track/index.js +1 -1
  50. package/lib/no-extra-selected-options/index.js +1 -1
  51. package/lib/no-ineffective-attr/index.js +4 -0
  52. package/lib/no-input-file-value/index.js +1 -1
  53. package/lib/no-invalid-aria-prop-value/index.js +3 -3
  54. package/lib/no-invalid-attr-value/index.js +12 -0
  55. package/lib/no-malformed-character-reference/index.js +1 -1
  56. package/lib/no-nested-top-level-landmark/index.js +20 -17
  57. package/lib/no-prohibited-naming/index.js +2 -2
  58. package/lib/no-redundant-accessible-name/index.js +15 -8
  59. package/lib/no-redundant-aria-prop/index.js +2 -2
  60. package/lib/no-refer-to-non-existent-id/index.d.ts +15 -0
  61. package/lib/no-refer-to-non-existent-id/index.js +47 -16
  62. package/lib/no-restricted-attr/index.js +2 -1
  63. package/lib/no-restricted-element/index.js +4 -2
  64. package/lib/no-skipped-heading-level/index.js +9 -4
  65. package/lib/no-stray-head-or-body-tag/index.d.ts +2 -1
  66. package/lib/no-stray-head-or-body-tag/index.js +4 -3
  67. package/lib/no-table-cell-overlap/index.js +3 -2
  68. package/lib/no-table-span-overflow/index.js +1 -1
  69. package/lib/no-translate.d.ts +17 -0
  70. package/lib/no-translate.js +29 -0
  71. package/lib/no-unclosed-element-at-eof/index.js +1 -1
  72. package/lib/no-unescaped-char/index.js +1 -1
  73. package/lib/no-unsupported-browser-features/index.d.ts +9 -0
  74. package/lib/no-unsupported-browser-features/index.js +10 -7
  75. package/lib/permitted-contents/content-model.d.ts +14 -0
  76. package/lib/permitted-contents/content-model.js +21 -1
  77. package/lib/permitted-contents/index.js +6 -8
  78. package/lib/permitted-contents/matches-selector.js +32 -5
  79. package/lib/permitted-contents/represent-transparent-nodes.js +100 -68
  80. package/lib/permitted-contents/slot-content.d.ts +18 -1
  81. package/lib/permitted-contents/slot-content.js +49 -2
  82. package/lib/permitted-contents/start.d.ts +8 -1
  83. package/lib/permitted-contents/start.js +39 -6
  84. package/lib/permitted-contents/utils.d.ts +11 -0
  85. package/lib/permitted-contents/utils.js +15 -0
  86. package/lib/placeholder-label-option/index.js +4 -26
  87. package/lib/progress-value-bounds/index.js +2 -2
  88. package/lib/require-accessible-name/index.js +2 -2
  89. package/lib/require-adjacent-popover/index.js +4 -2
  90. package/lib/require-ancestor/index.d.ts +37 -1
  91. package/lib/require-ancestor/index.js +82 -9
  92. package/lib/require-ancestor/meta.d.ts +5 -0
  93. package/lib/require-ancestor/meta.js +5 -0
  94. package/lib/require-attr/index.js +4 -1
  95. package/lib/require-datetime/index.js +3 -2
  96. package/lib/require-dialog-autofocus/index.js +8 -6
  97. package/lib/require-element/index.js +8 -2
  98. package/lib/require-h1/index.js +4 -8
  99. package/lib/require-landmark-label/index.js +17 -15
  100. package/lib/role-supports-aria-prop/index.js +2 -2
  101. package/lib/sizes-auto-requires-lazy-loading/index.d.ts +4 -2
  102. package/lib/sizes-auto-requires-lazy-loading/index.js +4 -2
  103. package/lib/usemap-references-map/index.js +12 -3
  104. package/lib/valid-speculation-rules/index.js +1 -1
  105. package/lib/wai-aria/checkings/contradictory-aria-prop.js +2 -2
  106. package/lib/wai-aria/checkings/interaction-in-hidden.js +6 -2
  107. package/lib/wai-aria/checkings/presentational-children.js +38 -17
  108. package/lib/wai-aria/checkings/redundant-aria-prop.js +1 -1
  109. package/lib/wai-aria/checkings/required-accessibility-parent-role.js +4 -2
  110. package/lib/wai-aria/checkings/required-owned-elements.d.ts +2 -0
  111. package/lib/wai-aria/checkings/required-owned-elements.js +175 -21
  112. package/lib/wai-aria/checkings/required-prop.js +2 -1
  113. package/lib/wai-aria/checkings/tab-requires-tabpanel.js +10 -6
  114. package/lib/wai-aria/checkings/value.d.ts +1 -4
  115. package/lib/wai-aria/checkings/value.js +8 -19
  116. package/package.json +14 -11
@@ -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.hasMutableChildren() ||
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.hasMutableChildren() ||
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 comment', 'the doctype', 'the code block').
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.isWhitespace()) {
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.isWhitespace()) {
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
- const result = matches(selector, childNode, specs, mode);
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
- const selector = query.replace(/^:model\(([^)]+)\)|^#([a-z-]+)/, (_, $model, _model) => {
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
- // 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)) {
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) ? expandChildren(node, [...node.childNodes], mode) : [node];
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
- const childNodesPatterns = givenPatterns.map(given => {
26
- const children = expandChildren(el, given, mode);
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.isWhitespace()));
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
- return childNodesPatterns.flatMap(childNodes => {
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.