@markuplint/rules 5.0.0-rc.0 → 5.0.0-rc.2

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 (113) hide show
  1. package/CHANGELOG.md +26 -0
  2. package/CLAUDE.md +27 -0
  3. package/SKILL.md +1 -0
  4. package/lib/attr-check.d.ts +3 -1
  5. package/lib/attr-check.js +10 -1
  6. package/lib/helpers.js +16 -1
  7. package/lib/index.d.ts +25 -7
  8. package/lib/index.js +36 -0
  9. package/lib/invalid-attr/index.js +12 -0
  10. package/lib/no-duplicate-autofocus/index.d.ts +7 -0
  11. package/lib/no-duplicate-autofocus/index.js +27 -0
  12. package/lib/no-duplicate-autofocus/meta.d.ts +4 -0
  13. package/lib/no-duplicate-autofocus/meta.js +3 -0
  14. package/lib/no-duplicate-visible-main/index.d.ts +8 -0
  15. package/lib/no-duplicate-visible-main/index.js +28 -0
  16. package/lib/no-duplicate-visible-main/meta.d.ts +4 -0
  17. package/lib/no-duplicate-visible-main/meta.js +3 -0
  18. package/lib/permitted-contents/choice.d.ts +5 -2
  19. package/lib/permitted-contents/choice.js +5 -2
  20. package/lib/permitted-contents/complex-branch.d.ts +5 -2
  21. package/lib/permitted-contents/complex-branch.js +6 -3
  22. package/lib/permitted-contents/content-model.d.ts +21 -2
  23. package/lib/permitted-contents/content-model.js +32 -6
  24. package/lib/permitted-contents/count-pattern.d.ts +5 -2
  25. package/lib/permitted-contents/count-pattern.js +13 -5
  26. package/lib/permitted-contents/index.d.ts +7 -1
  27. package/lib/permitted-contents/index.js +160 -65
  28. package/lib/permitted-contents/matches-selector.d.ts +2 -2
  29. package/lib/permitted-contents/matches-selector.js +43 -5
  30. package/lib/permitted-contents/order.d.ts +9 -2
  31. package/lib/permitted-contents/order.js +9 -2
  32. package/lib/permitted-contents/recursive-branch.d.ts +5 -2
  33. package/lib/permitted-contents/recursive-branch.js +7 -4
  34. package/lib/permitted-contents/represent-transparent-nodes.d.ts +2 -2
  35. package/lib/permitted-contents/represent-transparent-nodes.js +6 -6
  36. package/lib/permitted-contents/start.d.ts +2 -2
  37. package/lib/permitted-contents/start.js +3 -3
  38. package/lib/permitted-contents/types.d.ts +14 -0
  39. package/lib/permitted-contents/utils.d.ts +11 -2
  40. package/lib/permitted-contents/utils.js +62 -13
  41. package/lib/wai-aria/checkings/disallowed-prop.d.ts +2 -1
  42. package/lib/wai-aria/checkings/disallowed-prop.js +27 -5
  43. package/lib/wai-aria/checkings/permitted-roles.js +16 -3
  44. package/lib/wai-aria/checkings/required-accessibility-parent-role.js +1 -1
  45. package/lib/wai-aria/default-options.d.ts +5 -0
  46. package/lib/wai-aria/default-options.js +18 -0
  47. package/lib/wai-aria/index.js +8 -19
  48. package/lib/wai-aria-abstract-role/index.d.ts +4 -0
  49. package/lib/wai-aria-abstract-role/index.js +22 -0
  50. package/lib/wai-aria-abstract-role/meta.d.ts +4 -0
  51. package/lib/wai-aria-abstract-role/meta.js +3 -0
  52. package/lib/wai-aria-default-value/index.d.ts +4 -0
  53. package/lib/wai-aria-default-value/index.js +28 -0
  54. package/lib/wai-aria-default-value/meta.d.ts +5 -0
  55. package/lib/wai-aria-default-value/meta.js +4 -0
  56. package/lib/wai-aria-deprecated-props/index.d.ts +4 -0
  57. package/lib/wai-aria-deprecated-props/index.js +29 -0
  58. package/lib/wai-aria-deprecated-props/meta.d.ts +5 -0
  59. package/lib/wai-aria-deprecated-props/meta.js +4 -0
  60. package/lib/wai-aria-deprecated-role/index.d.ts +4 -0
  61. package/lib/wai-aria-deprecated-role/index.js +26 -0
  62. package/lib/wai-aria-deprecated-role/meta.d.ts +4 -0
  63. package/lib/wai-aria-deprecated-role/meta.js +3 -0
  64. package/lib/wai-aria-disallowed-props/index.d.ts +4 -0
  65. package/lib/wai-aria-disallowed-props/index.js +33 -0
  66. package/lib/wai-aria-disallowed-props/meta.d.ts +5 -0
  67. package/lib/wai-aria-disallowed-props/meta.js +4 -0
  68. package/lib/wai-aria-implicit-props/index.d.ts +4 -0
  69. package/lib/wai-aria-implicit-props/index.js +29 -0
  70. package/lib/wai-aria-implicit-props/meta.d.ts +5 -0
  71. package/lib/wai-aria-implicit-props/meta.js +4 -0
  72. package/lib/wai-aria-implicit-role/index.d.ts +4 -0
  73. package/lib/wai-aria-implicit-role/index.js +23 -0
  74. package/lib/wai-aria-implicit-role/meta.d.ts +4 -0
  75. package/lib/wai-aria-implicit-role/meta.js +3 -0
  76. package/lib/wai-aria-interaction-in-hidden/index.d.ts +4 -0
  77. package/lib/wai-aria-interaction-in-hidden/index.js +20 -0
  78. package/lib/wai-aria-interaction-in-hidden/meta.d.ts +5 -0
  79. package/lib/wai-aria-interaction-in-hidden/meta.js +4 -0
  80. package/lib/wai-aria-no-global-prop/index.d.ts +4 -0
  81. package/lib/wai-aria-no-global-prop/index.js +30 -0
  82. package/lib/wai-aria-no-global-prop/meta.d.ts +5 -0
  83. package/lib/wai-aria-no-global-prop/meta.js +4 -0
  84. package/lib/wai-aria-non-existent-role/index.d.ts +4 -0
  85. package/lib/wai-aria-non-existent-role/index.js +22 -0
  86. package/lib/wai-aria-non-existent-role/meta.d.ts +4 -0
  87. package/lib/wai-aria-non-existent-role/meta.js +3 -0
  88. package/lib/wai-aria-permitted-roles/index.d.ts +4 -0
  89. package/lib/wai-aria-permitted-roles/index.js +22 -0
  90. package/lib/wai-aria-permitted-roles/meta.d.ts +4 -0
  91. package/lib/wai-aria-permitted-roles/meta.js +3 -0
  92. package/lib/wai-aria-presentational-children/index.d.ts +4 -0
  93. package/lib/wai-aria-presentational-children/index.js +20 -0
  94. package/lib/wai-aria-presentational-children/meta.d.ts +5 -0
  95. package/lib/wai-aria-presentational-children/meta.js +4 -0
  96. package/lib/wai-aria-required-owned-elements/index.d.ts +4 -0
  97. package/lib/wai-aria-required-owned-elements/index.js +23 -0
  98. package/lib/wai-aria-required-owned-elements/meta.d.ts +4 -0
  99. package/lib/wai-aria-required-owned-elements/meta.js +3 -0
  100. package/lib/wai-aria-required-parent-role/index.d.ts +4 -0
  101. package/lib/wai-aria-required-parent-role/index.js +25 -0
  102. package/lib/wai-aria-required-parent-role/meta.d.ts +4 -0
  103. package/lib/wai-aria-required-parent-role/meta.js +3 -0
  104. package/lib/wai-aria-required-props/index.d.ts +4 -0
  105. package/lib/wai-aria-required-props/index.js +26 -0
  106. package/lib/wai-aria-required-props/meta.d.ts +4 -0
  107. package/lib/wai-aria-required-props/meta.js +3 -0
  108. package/lib/wai-aria-value/index.d.ts +4 -0
  109. package/lib/wai-aria-value/index.js +28 -0
  110. package/lib/wai-aria-value/meta.d.ts +5 -0
  111. package/lib/wai-aria-value/meta.js +4 -0
  112. package/package.json +15 -11
  113. package/schema.json +54 -0
@@ -1,4 +1,4 @@
1
- import type { ChildNode, Options, Result, Specs } from './types.js';
1
+ import type { ChildNode, Mode, Options, Result, Specs, TagRule } from './types.js';
2
2
  import type { PermittedContentOneOrMore, PermittedContentOptional, PermittedContentRequire, PermittedContentZeroOrMore } from '@markuplint/ml-spec';
3
3
  import type { ReadonlyDeep } from 'type-fest';
4
4
  /**
@@ -12,9 +12,12 @@ import type { ReadonlyDeep } from 'type-fest';
12
12
  *
13
13
  * @param pattern - A quantified content model pattern (require, optional, oneOrMore, or zeroOrMore).
14
14
  * @param childNodes - The child nodes to validate against the repeated pattern.
15
+ * @param rules - User-defined tag rules. Threaded through for transparent-model recursion;
16
+ * not consulted here directly. See `order` for the rationale.
15
17
  * @param specs - The resolved spec data for content model lookups.
16
18
  * @param options - Validation behavior options.
17
19
  * @param depth - The current recursion depth, used for debug logging and nested evaluation.
20
+ * @param mode - Whether we are evaluating the element's `'origin'` or `'pretended'` identity.
18
21
  * @returns A result indicating whether the required count of matches was achieved.
19
22
  */
20
- export declare function countPattern(pattern: ReadonlyDeep<PermittedContentOneOrMore> | ReadonlyDeep<PermittedContentOptional> | ReadonlyDeep<PermittedContentRequire> | ReadonlyDeep<PermittedContentZeroOrMore>, childNodes: readonly ChildNode[], specs: Specs, options: Options, depth: number): Result;
23
+ export declare function countPattern(pattern: ReadonlyDeep<PermittedContentOneOrMore> | ReadonlyDeep<PermittedContentOptional> | ReadonlyDeep<PermittedContentRequire> | ReadonlyDeep<PermittedContentZeroOrMore>, childNodes: readonly ChildNode[], rules: readonly TagRule[], specs: Specs, options: Options, depth: number, mode: Mode): Result;
@@ -13,14 +13,17 @@ const cLog = cmLog.extend('countCompereResult');
13
13
  *
14
14
  * @param pattern - A quantified content model pattern (require, optional, oneOrMore, or zeroOrMore).
15
15
  * @param childNodes - The child nodes to validate against the repeated pattern.
16
+ * @param rules - User-defined tag rules. Threaded through for transparent-model recursion;
17
+ * not consulted here directly. See `order` for the rationale.
16
18
  * @param specs - The resolved spec data for content model lookups.
17
19
  * @param options - Validation behavior options.
18
20
  * @param depth - The current recursion depth, used for debug logging and nested evaluation.
21
+ * @param mode - Whether we are evaluating the element's `'origin'` or `'pretended'` identity.
19
22
  * @returns A result indicating whether the required count of matches was achieved.
20
23
  */
21
24
  export function countPattern(pattern,
22
25
  // eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types
23
- childNodes, specs, options, depth) {
26
+ childNodes, rules, specs, options, depth, mode) {
24
27
  const ptLog = cmLog.extend(`countPattern#${depth}`);
25
28
  const collection = new Collection(childNodes);
26
29
  const { model, min, max, repeat, missingType } = normalizeModel(pattern);
@@ -31,7 +34,7 @@ childNodes, specs, options, depth) {
31
34
  while (true) {
32
35
  loopCount++;
33
36
  ptLog('Check#%s: %s', loopCount, collection);
34
- const result = recursiveBranch(model, collection.unmatched, specs, options, depth);
37
+ const result = recursiveBranch(model, collection.unmatched, rules, specs, options, depth, mode);
35
38
  const added = collection.addMatched(result.matched);
36
39
  const { matchedCount } = collection;
37
40
  if (result.type === 'UNMATCHED_SELECTOR_BUT_MAY_EMPTY') {
@@ -130,9 +133,14 @@ childNodes, specs, options, depth) {
130
133
  continue;
131
134
  }
132
135
  ptLog('%s:\n model: %s\n max: %s\n collection: %s\n matched element: %s', resultType, modelLog(model, repeat), max, collection, matchedCount);
133
- if (result.type === 'MISSING_NODE_REQUIRED' ||
134
- result.type === 'MISSING_NODE_ONE_OR_MORE' ||
135
- result.type === 'TRANSPARENT_MODEL_DISALLOWS') {
136
+ // When min=0 and no elements were consumed, the pattern legitimately
137
+ // matched zero times — don't propagate the inner sequence's failure.
138
+ // This enables zeroOrMore([oneOrMore dt, oneOrMore dd]) to accept
139
+ // empty content (e.g., <dl></dl>). See #3592.
140
+ if ((collection.matchedCount > 0 || min > 0) &&
141
+ (result.type === 'MISSING_NODE_REQUIRED' ||
142
+ result.type === 'MISSING_NODE_ONE_OR_MORE' ||
143
+ result.type === 'TRANSPARENT_MODEL_DISALLOWS')) {
136
144
  return compereResult({
137
145
  type: result.type,
138
146
  matched: collection.matched,
@@ -9,7 +9,13 @@ import type { Options, TagRule } from './types.js';
9
9
  * For each element, it resolves the applicable content model (from the HTML spec or
10
10
  * user-defined tag rules), evaluates the element's children against that model, and
11
11
  * reports violations such as unexpected elements, missing required elements, or
12
- * disallowed content through transparent models.
12
+ * disallowed content through transparent models. It also checks forbidden ancestor
13
+ * constraints — elements like `<header>`, `<footer>`, `<main>`, and `<address>` must
14
+ * not appear as descendants of certain other elements as defined by the HTML spec.
15
+ * Additionally, it enforces required ancestor constraints (`descendantOf`) — certain
16
+ * elements must appear as descendants of specific other elements. It also validates
17
+ * sibling-unique attribute constraints (`uniqueAttrs`) — certain attributes must not
18
+ * appear on more than one element of the same type within the same parent.
13
19
  */
14
20
  declare const _default: Readonly<import("@markuplint/ml-core").RuleSeed<TagRule[], Options>>;
15
21
  export default _default;
@@ -12,7 +12,13 @@ import { transparentMode } from './represent-transparent-nodes.js';
12
12
  * For each element, it resolves the applicable content model (from the HTML spec or
13
13
  * user-defined tag rules), evaluates the element's children against that model, and
14
14
  * reports violations such as unexpected elements, missing required elements, or
15
- * disallowed content through transparent models.
15
+ * disallowed content through transparent models. It also checks forbidden ancestor
16
+ * constraints — elements like `<header>`, `<footer>`, `<main>`, and `<address>` must
17
+ * not appear as descendants of certain other elements as defined by the HTML spec.
18
+ * Additionally, it enforces required ancestor constraints (`descendantOf`) — certain
19
+ * elements must appear as descendants of specific other elements. It also validates
20
+ * sibling-unique attribute constraints (`uniqueAttrs`) — certain attributes must not
21
+ * appear on more than one element of the same type within the same parent.
16
22
  */
17
23
  export default createRule({
18
24
  meta: meta,
@@ -23,83 +29,165 @@ export default createRule({
23
29
  },
24
30
  async verify({ document, report, t }) {
25
31
  await document.walkOn('Element', el => {
26
- const results = contentModel(el, el.rule.value, el.rule.options);
27
- for (const { type, scope, query, hint } of results) {
28
- let message = '';
29
- if (hint.max != null) {
30
- message =
31
- t('there is more content than it needs') +
32
- t('. ') +
33
- t('the max number of elements required is {0}', `${hint.max}`);
34
- }
35
- switch (type) {
36
- case 'MATCHED':
37
- case 'MATCHED_ZERO': {
38
- break;
39
- }
40
- case 'MISSING_NODE_ONE_OR_MORE': {
41
- if (scope.rule.options.ignoreHasMutableChildren &&
42
- (!scope.is(scope.ELEMENT_NODE) || scope.hasMutableChildren())) {
43
- break;
44
- }
45
- message =
46
- message ||
47
- t('Require {0}', t('one or more elements')) + t('. ') + '(' + t('Need "{0*}"', query) + ')';
32
+ // Check forbidden ancestors
33
+ const elSpec = document.specs.specs.find(s => s.name === el.localName);
34
+ const forbiddenAncestors = elSpec?.contentModel?.forbiddenAncestors;
35
+ if (forbiddenAncestors && forbiddenAncestors.length > 0) {
36
+ let ancestor = el.parentElement;
37
+ while (ancestor) {
38
+ if (forbiddenAncestors.some(selector => ancestor.matches(selector))) {
48
39
  report({
49
- scope,
50
- message,
40
+ scope: el,
41
+ message: t('{0} must not appear as a descendant of {1}', t('the "{0}" {1}', el.localName, 'element'), t('the "{0}" {1}', ancestor.localName, 'element')),
51
42
  });
52
43
  break;
53
44
  }
54
- case 'MISSING_NODE_REQUIRED': {
55
- if (scope.rule.options.ignoreHasMutableChildren &&
56
- (!scope.is(scope.ELEMENT_NODE) || scope.hasMutableChildren())) {
57
- break;
58
- }
59
- message =
60
- message ||
61
- t('Require {0}', t('an {0}', 'element')) + t('. ') + '(' + t('Need "{0*}"', query) + ')';
62
- report({
63
- scope,
64
- message,
65
- });
45
+ ancestor = ancestor.parentElement;
46
+ }
47
+ }
48
+ // Check required ancestor (descendantOf)
49
+ const descendantOf = elSpec?.contentModel?.descendantOf;
50
+ if (descendantOf) {
51
+ let ancestor = el.parentElement;
52
+ let found = false;
53
+ while (ancestor) {
54
+ if (ancestor.matches(descendantOf)) {
55
+ found = true;
66
56
  break;
67
57
  }
68
- case 'UNEXPECTED_EXTRA_NODE': {
69
- const not = hint.not ?? scope;
70
- message =
71
- message ||
72
- (transparentMode.has(scope)
73
- ? t('{0} is not allowed in {1} through the transparent model in this context', name(not, t), name(el, t))
74
- : t('{0} is not allowed in {1} in this context', name(not, t), name(el, t)));
75
- report({
76
- scope: not,
77
- message,
78
- });
79
- break;
58
+ ancestor = ancestor.parentElement;
59
+ }
60
+ if (!found) {
61
+ report({
62
+ scope: el,
63
+ message: t('{0} must appear as a descendant of {1}', t('the "{0}" {1}', el.localName, 'element'), t('the "{0}" {1}', descendantOf, 'element')),
64
+ });
65
+ }
66
+ }
67
+ // Check unique attributes among siblings of the same type
68
+ const uniqueAttrs = elSpec?.contentModel?.uniqueAttrs;
69
+ if (uniqueAttrs && uniqueAttrs.length > 0) {
70
+ for (const attrName of uniqueAttrs) {
71
+ if (!el.hasAttribute(attrName)) {
72
+ continue;
80
73
  }
81
- case 'TRANSPARENT_MODEL_DISALLOWS': {
82
- const not = hint.not ?? scope;
83
- const tp = hint.transparent ?? el;
84
- report({
85
- scope: not,
86
- message: t('{0} is {1} but {2}', name(tp, t), t('a {0}', 'transparent model'), t('also disallows {0} in this context', name(not, t))),
87
- });
88
- break;
74
+ const parent = el.parentElement;
75
+ if (!parent) {
76
+ continue;
89
77
  }
90
- case 'NOTHING': {
78
+ // Only report on the second (and subsequent) element that has the attribute
79
+ const precedingSiblings = [...parent.children];
80
+ const elIndex = precedingSiblings.indexOf(el);
81
+ const hasPrecedingDuplicate = precedingSiblings
82
+ .slice(0, elIndex)
83
+ .some(child => child.localName === el.localName && child.hasAttribute(attrName));
84
+ if (hasPrecedingDuplicate) {
91
85
  report({
92
86
  scope: el,
93
- message: t('{0} disallows {1}', t('the {0}', 'element'), 'contents'),
87
+ message: t('The "{0}" attribute must not appear on more than one "{1}" element within the same parent', attrName, el.localName),
94
88
  });
95
- break;
96
89
  }
97
- default: {
98
- throw new Error('Unreachable code');
90
+ }
91
+ }
92
+ // Build the mode list. `'pretended'` always runs and preserves the historical
93
+ // behaviour. `'origin'` additionally fires when the element is pretending and
94
+ // the user has declared a content model keyed on its original AST name, so
95
+ // that user-defined custom-tag rules are not silently bypassed by pretending
96
+ // (see issue #3739).
97
+ const modes = ['pretended'];
98
+ if (el.pretenderContext?.type === 'pretender' && el.rule.value.some(r => r.tag === el.rawName)) {
99
+ modes.push('origin');
100
+ }
101
+ for (const mode of modes) {
102
+ const results = contentModel(el, el.rule.value, el.rule.options, mode);
103
+ for (const { type, scope, query, hint } of results) {
104
+ let message = '';
105
+ if (hint.max != null) {
106
+ message =
107
+ t('there is more content than it needs') +
108
+ t('. ') +
109
+ t('the max number of elements required is {0}', `${hint.max}`);
110
+ }
111
+ switch (type) {
112
+ case 'MATCHED':
113
+ case 'MATCHED_ZERO': {
114
+ break;
115
+ }
116
+ case 'MISSING_NODE_ONE_OR_MORE': {
117
+ if (scope.rule.options.ignoreHasMutableChildren &&
118
+ (!scope.is(scope.ELEMENT_NODE) || scope.hasMutableChildren())) {
119
+ break;
120
+ }
121
+ message =
122
+ message ||
123
+ t('Require {0}', t('one or more elements')) +
124
+ t('. ') +
125
+ '(' +
126
+ t('Need "{0*}"', query) +
127
+ ')';
128
+ report({
129
+ scope,
130
+ message,
131
+ });
132
+ break;
133
+ }
134
+ case 'MISSING_NODE_REQUIRED': {
135
+ if (scope.rule.options.ignoreHasMutableChildren &&
136
+ (!scope.is(scope.ELEMENT_NODE) || scope.hasMutableChildren())) {
137
+ break;
138
+ }
139
+ message =
140
+ message ||
141
+ t('Require {0}', t('an {0}', 'element')) +
142
+ t('. ') +
143
+ '(' +
144
+ t('Need "{0*}"', query) +
145
+ ')';
146
+ report({
147
+ scope,
148
+ message,
149
+ });
150
+ break;
151
+ }
152
+ case 'UNEXPECTED_EXTRA_NODE': {
153
+ const not = hint.not ?? scope;
154
+ message =
155
+ message ||
156
+ (transparentMode.has(scope)
157
+ ? t('{0} is not allowed in {1} through the transparent model in this context', name(not, t, mode), name(el, t, mode))
158
+ : t('{0} is not allowed in {1} in this context', name(not, t, mode), name(el, t, mode)));
159
+ report({
160
+ scope: not,
161
+ message,
162
+ });
163
+ break;
164
+ }
165
+ case 'TRANSPARENT_MODEL_DISALLOWS': {
166
+ const not = hint.not ?? scope;
167
+ const tp = hint.transparent ?? el;
168
+ report({
169
+ scope: not,
170
+ 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))),
171
+ });
172
+ break;
173
+ }
174
+ case 'NOTHING': {
175
+ report({
176
+ scope: el,
177
+ message: t('{0} disallows {1}', t('the {0}', 'element'), 'contents'),
178
+ });
179
+ break;
180
+ }
181
+ default: {
182
+ throw new Error('Unreachable code');
183
+ }
99
184
  }
100
185
  }
186
+ // Clear the per-element transparent-model tracker between passes so that
187
+ // errors reported in one mode do not carry the "through the transparent
188
+ // model" phrasing into the other mode's diagnostics.
189
+ transparentMode.clear();
101
190
  }
102
- transparentMode.clear();
103
191
  });
104
192
  },
105
193
  });
@@ -108,15 +196,22 @@ export default createRule({
108
196
  * Used in error messages to describe the offending node (e.g., 'the "div" element',
109
197
  * 'the text node', 'the comment', 'the doctype', 'the code block').
110
198
  *
199
+ * In `'origin'` mode, uses {@link MLElement.rawName} so that the diagnostic refers
200
+ * to the original AST identity (e.g. the component name `"Breadcrumbs"`) rather
201
+ * than the pretender target (`"nav"`). `'pretended'` mode preserves the historical
202
+ * behaviour and prints the visible `localName`.
203
+ *
111
204
  * @param scope - The child node to generate a name for.
112
205
  * @param t - The translator function for localized message formatting.
206
+ * @param mode - Which identity to surface in the message.
113
207
  * @returns A localized string describing the node.
114
208
  */
115
209
  function name(
116
210
  // eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types
117
- scope, t) {
211
+ scope, t, mode) {
118
212
  if (scope.is(scope.ELEMENT_NODE)) {
119
- return t('the "{0}" {1}', scope.localName, 'element');
213
+ const displayName = mode === 'origin' ? scope.rawName : scope.localName;
214
+ return t('the "{0}" {1}', displayName, 'element');
120
215
  }
121
216
  if (scope.is(scope.TEXT_NODE)) {
122
217
  return t('the {0}', 'text node');
@@ -1,4 +1,4 @@
1
- import type { ChildNode, Result, Specs } from './types.js';
1
+ import type { ChildNode, Mode, Result, Specs } from './types.js';
2
2
  /**
3
3
  * Extended result type for selector matching that includes additional
4
4
  * intermediate states: a selector that did not match but allows empty content,
@@ -19,4 +19,4 @@ export type SelectorResult = Result<'UNMATCHED_SELECTOR_BUT_MAY_EMPTY' | 'MISSIN
19
19
  * @param depth - The current recursion depth, used for debug logging namespacing.
20
20
  * @returns A selector result indicating match status with diagnostic hints.
21
21
  */
22
- export declare function matchesSelector(query: string, childNode: ChildNode | undefined, specs: Specs, depth: number): SelectorResult;
22
+ export declare function matchesSelector(query: string, childNode: ChildNode | undefined, specs: Specs, depth: number, mode: Mode): SelectorResult;
@@ -17,9 +17,9 @@ import { cleanObject, matches } from './utils.js';
17
17
  */
18
18
  export function matchesSelector(query,
19
19
  // eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types
20
- childNode, specs, depth) {
20
+ childNode, specs, depth, mode) {
21
21
  const nodeLog = cmLog.extend(`node#${depth}`);
22
- const { selector, hasText, hasCustom } = optCondition(query, specs);
22
+ const { selector, hasText, hasNonEmptyText, hasCustom } = optCondition(query, specs);
23
23
  if (childNode == null) {
24
24
  if (hasText) {
25
25
  nodeLog('<#text>.matches(%s) => ""', query);
@@ -32,6 +32,7 @@ childNode, specs, depth) {
32
32
  hint: {},
33
33
  };
34
34
  }
35
+ // #nonEmptyText requires actual non-empty text — missing node is not acceptable
35
36
  return {
36
37
  type: 'MISSING_NODE',
37
38
  matched: [],
@@ -42,6 +43,28 @@ childNode, specs, depth) {
42
43
  };
43
44
  }
44
45
  if (childNode.is(childNode.TEXT_NODE)) {
46
+ if (hasNonEmptyText) {
47
+ if (childNode.isWhitespace()) {
48
+ nodeLog('<#nonEmptyText>.matches(%s) => WHITESPACE (rejected)', query);
49
+ return {
50
+ type: 'MISSING_NODE',
51
+ matched: [],
52
+ unmatched: [],
53
+ zeroMatch: false,
54
+ query,
55
+ hint: {},
56
+ };
57
+ }
58
+ nodeLog('<#nonEmptyText>.matches(%s) => "%s"', query, childNode.raw.trim());
59
+ return {
60
+ type: 'MATCHED',
61
+ matched: [childNode],
62
+ unmatched: [],
63
+ zeroMatch: false,
64
+ query,
65
+ hint: {},
66
+ };
67
+ }
45
68
  if (hasText) {
46
69
  nodeLog('<#text>.matches(%s) => "%s"', query, childNode.raw.trim());
47
70
  return {
@@ -80,7 +103,7 @@ childNode, specs, depth) {
80
103
  type: 'MATCHED',
81
104
  matched: [childNode],
82
105
  unmatched: [],
83
- zeroMatch: !!hasText,
106
+ zeroMatch: !!(hasText || hasNonEmptyText),
84
107
  query,
85
108
  hint: {},
86
109
  };
@@ -97,7 +120,7 @@ childNode, specs, depth) {
97
120
  hint: {},
98
121
  };
99
122
  }
100
- const result = matches(selector, childNode, specs);
123
+ const result = matches(selector, childNode, specs, mode);
101
124
  nodeLog('%s.matches(%s) => %s', childNode.raw, query, result.matched);
102
125
  if (result.matched) {
103
126
  return {
@@ -149,11 +172,19 @@ const conditionWithoutSpecs = {
149
172
  selector: '#custom',
150
173
  hasCustom: true,
151
174
  hasText: false,
175
+ hasNonEmptyText: false,
152
176
  },
153
177
  '#text': {
154
178
  selector: '#text',
155
179
  hasCustom: false,
156
180
  hasText: true,
181
+ hasNonEmptyText: false,
182
+ },
183
+ '#nonEmptyText': {
184
+ selector: '#nonEmptyText',
185
+ hasCustom: false,
186
+ hasText: false,
187
+ hasNonEmptyText: true,
157
188
  },
158
189
  };
159
190
  /**
@@ -185,8 +216,14 @@ function optCondition(query, specs) {
185
216
  }
186
217
  let hasCustom = false;
187
218
  let hasText = false;
219
+ let hasNonEmptyText = false;
188
220
  const selector = query.replace(/^:model\(([^)]+)\)|^#([a-z-]+)/, (_, $model, _model) => {
189
- const _selectors = contentModelCategoryToTagNames(`#${$model ?? _model}`, specs.def);
221
+ const tag = `#${$model ?? _model}`;
222
+ if (tag === '#nonEmptyText') {
223
+ hasNonEmptyText = true;
224
+ return '';
225
+ }
226
+ const _selectors = contentModelCategoryToTagNames(tag, specs.def);
190
227
  if (_selectors.length === 0) {
191
228
  throw new Error(`${$model ?? _model} is empty`);
192
229
  }
@@ -208,6 +245,7 @@ function optCondition(query, specs) {
208
245
  selector,
209
246
  hasCustom,
210
247
  hasText,
248
+ hasNonEmptyText,
211
249
  };
212
250
  queryCaches.set(query, result);
213
251
  optConditionSpecsBaseCaches.set(specs, queryCaches);
@@ -1,4 +1,4 @@
1
- import type { ChildNode, Options, Result, Specs } from './types.js';
1
+ import type { ChildNode, Mode, Options, Result, Specs, TagRule } from './types.js';
2
2
  import type { PermittedContentPattern } from '@markuplint/ml-spec';
3
3
  import type { ReadonlyDeep } from 'type-fest';
4
4
  /**
@@ -13,9 +13,16 @@ import type { ReadonlyDeep } from 'type-fest';
13
13
  *
14
14
  * @param contents - An ordered array of content model patterns to match sequentially.
15
15
  * @param childNodes - The child nodes to validate against the patterns.
16
+ * @param rules - User-defined tag rules. `order` does not consult them directly; they are
17
+ * threaded through so nested helpers (especially `representTransparentNodes`)
18
+ * can resolve content models via `resolveContentModel`. Do not remove even if
19
+ * it looks unused here.
16
20
  * @param specs - The resolved spec data for content model lookups.
17
21
  * @param options - Validation behavior options.
18
22
  * @param depth - The current recursion depth, used for debug logging and nested pattern matching.
23
+ * @param mode - Whether we are evaluating the element's `'origin'` or `'pretended'` identity.
24
+ * Propagated unchanged into `complexBranch` so downstream selector matching
25
+ * honors the same view.
19
26
  * @returns A result indicating overall match status and the matched/unmatched node partitioning.
20
27
  */
21
- export declare function order(contents: ReadonlyDeep<PermittedContentPattern[]>, childNodes: readonly ChildNode[], specs: Specs, options: Options, depth: number): Result;
28
+ export declare function order(contents: ReadonlyDeep<PermittedContentPattern[]>, childNodes: readonly ChildNode[], rules: readonly TagRule[], specs: Specs, options: Options, depth: number, mode: Mode): Result;
@@ -14,14 +14,21 @@ import { Collection, mergeHints, modelLog } from './utils.js';
14
14
  *
15
15
  * @param contents - An ordered array of content model patterns to match sequentially.
16
16
  * @param childNodes - The child nodes to validate against the patterns.
17
+ * @param rules - User-defined tag rules. `order` does not consult them directly; they are
18
+ * threaded through so nested helpers (especially `representTransparentNodes`)
19
+ * can resolve content models via `resolveContentModel`. Do not remove even if
20
+ * it looks unused here.
17
21
  * @param specs - The resolved spec data for content model lookups.
18
22
  * @param options - Validation behavior options.
19
23
  * @param depth - The current recursion depth, used for debug logging and nested pattern matching.
24
+ * @param mode - Whether we are evaluating the element's `'origin'` or `'pretended'` identity.
25
+ * Propagated unchanged into `complexBranch` so downstream selector matching
26
+ * honors the same view.
20
27
  * @returns A result indicating overall match status and the matched/unmatched node partitioning.
21
28
  */
22
29
  export function order(contents,
23
30
  // eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types
24
- childNodes, specs, options, depth) {
31
+ childNodes, rules, specs, options, depth, mode) {
25
32
  const orderLog = cmLog.extend(`order#${depth}`);
26
33
  const btLog = cmLog.extend(`backtrack#${depth}`);
27
34
  const patterns = deepCopy(contents);
@@ -33,7 +40,7 @@ childNodes, specs, options, depth) {
33
40
  let afterBacktrack = false;
34
41
  const unmatchedResults = [];
35
42
  while (patterns.length > 0 && patterns[0]) {
36
- result = complexBranch(patterns[0], collection.unmatched, specs, options, depth);
43
+ result = complexBranch(patterns[0], collection.unmatched, rules, specs, options, depth, mode);
37
44
  collection.addMatched(result.matched);
38
45
  if (result.type !== 'UNEXPECTED_EXTRA_NODE' && result.type !== 'MATCHED' && result.type !== 'MATCHED_ZERO') {
39
46
  unmatchedResults.push(result);
@@ -1,5 +1,5 @@
1
1
  import type { SelectorResult } from './matches-selector.js';
2
- import type { ChildNode, Options, Specs } from './types.js';
2
+ import type { ChildNode, Mode, Options, Specs, TagRule } from './types.js';
3
3
  import type { PermittedContentPattern, Model } from '@markuplint/ml-spec';
4
4
  import type { ReadonlyDeep } from 'type-fest';
5
5
  /**
@@ -14,9 +14,12 @@ import type { ReadonlyDeep } from 'type-fest';
14
14
  *
15
15
  * @param model - Either a terminal model (selector string or array of selector strings) or a nested pattern array.
16
16
  * @param childNodes - The child nodes to validate against the model.
17
+ * @param rules - User-defined tag rules. Threaded through for transparent-model recursion;
18
+ * not consulted here directly. See `order` for the rationale.
17
19
  * @param specs - The resolved spec data for content model lookups.
18
20
  * @param options - Validation behavior options.
19
21
  * @param depth - The current recursion depth, used for debug logging and nested evaluation.
22
+ * @param mode - Whether we are evaluating the element's `'origin'` or `'pretended'` identity.
20
23
  * @returns A selector result indicating whether the first unmatched child node matches the model.
21
24
  */
22
- export declare function recursiveBranch(model: ReadonlyDeep<Model | PermittedContentPattern[]>, childNodes: readonly ChildNode[], specs: Specs, options: Options, depth: number): SelectorResult;
25
+ export declare function recursiveBranch(model: ReadonlyDeep<Model | PermittedContentPattern[]>, childNodes: readonly ChildNode[], rules: readonly TagRule[], specs: Specs, options: Options, depth: number, mode: Mode): SelectorResult;
@@ -13,24 +13,27 @@ import { Collection, isModel } from './utils.js';
13
13
  *
14
14
  * @param model - Either a terminal model (selector string or array of selector strings) or a nested pattern array.
15
15
  * @param childNodes - The child nodes to validate against the model.
16
+ * @param rules - User-defined tag rules. Threaded through for transparent-model recursion;
17
+ * not consulted here directly. See `order` for the rationale.
16
18
  * @param specs - The resolved spec data for content model lookups.
17
19
  * @param options - Validation behavior options.
18
20
  * @param depth - The current recursion depth, used for debug logging and nested evaluation.
21
+ * @param mode - Whether we are evaluating the element's `'origin'` or `'pretended'` identity.
19
22
  * @returns A selector result indicating whether the first unmatched child node matches the model.
20
23
  */
21
24
  export function recursiveBranch(model,
22
25
  // eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types
23
- childNodes, specs, options, depth) {
26
+ childNodes, rules, specs, options, depth, mode) {
24
27
  if (!isModel(model)) {
25
- return order(model, childNodes, specs, options, depth + 1);
28
+ return order(model, childNodes, rules, specs, options, depth + 1, mode);
26
29
  }
27
30
  if (typeof model === 'string') {
28
- return matchesSelector(model, childNodes[0], specs, depth);
31
+ return matchesSelector(model, childNodes[0], specs, depth, mode);
29
32
  }
30
33
  const collection = new Collection(childNodes);
31
34
  let lastUnmatched = null;
32
35
  for (const query of model) {
33
- const result = matchesSelector(query, collection.unmatched[0], specs, depth);
36
+ const result = matchesSelector(query, collection.unmatched[0], specs, depth, mode);
34
37
  collection.addMatched(result.matched);
35
38
  if (result.type === 'MATCHED' || result.type === 'MATCHED_ZERO') {
36
39
  return {
@@ -1,4 +1,4 @@
1
- import type { ChildNode, Options, Result, Specs } from './types.js';
1
+ import type { ChildNode, Mode, Options, Result, Specs, TagRule } from './types.js';
2
2
  /**
3
3
  * A map tracking child nodes that are currently being evaluated through
4
4
  * a transparent content model. Used to prevent infinite recursion and
@@ -58,5 +58,5 @@ type TransparentNode = {
58
58
  * @returns An array of possible transparent node resolutions, each with flattened nodes and accumulated errors.
59
59
  * @see https://github.com/markuplint/markuplint/issues/3249
60
60
  */
61
- export declare function representTransparentNodes(childNodes: readonly ChildNode[], specs: Specs, options: Options): TransparentNode[];
61
+ export declare function representTransparentNodes(childNodes: readonly ChildNode[], rules: readonly TagRule[], specs: Specs, options: Options, mode: Mode): TransparentNode[];
62
62
  export {};
@@ -1,4 +1,4 @@
1
- import { getContentModel } from '@markuplint/ml-spec';
1
+ import { resolveContentModel } from './content-model.js';
2
2
  import { order } from './order.js';
3
3
  import { Collection, isTransparent, matches } from './utils.js';
4
4
  /**
@@ -82,10 +82,10 @@ const MAX_PATTERNS = 1024;
82
82
  */
83
83
  export function representTransparentNodes(
84
84
  // eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types
85
- childNodes, specs, options) {
85
+ childNodes, rules, specs, options, mode) {
86
86
  const parentElement = childNodes[0]?.parentElement;
87
87
  const parentResults = parentElement
88
- ? representTransparentNodes([parentElement], specs, options)
88
+ ? representTransparentNodes([parentElement], rules, specs, options, mode)
89
89
  : [{ nodes: [], errors: [] }];
90
90
  let patterns = [[]];
91
91
  for (const childNode of childNodes) {
@@ -95,7 +95,7 @@ childNodes, specs, options) {
95
95
  }
96
96
  continue;
97
97
  }
98
- const models = getContentModel(childNode, specs.specs);
98
+ const models = resolveContentModel(childNode, rules, specs, mode);
99
99
  if (models == null || typeof models === 'boolean') {
100
100
  for (const p of patterns) {
101
101
  p.push(childNode);
@@ -117,7 +117,7 @@ childNodes, specs, options) {
117
117
  const collection = new Collection([...branchChildNodes]);
118
118
  let unmatched;
119
119
  if (noTransparentModels.length > 0) {
120
- const result = order(noTransparentModels, collection.unmatched, specs, options, Number.POSITIVE_INFINITY);
120
+ const result = order(noTransparentModels, collection.unmatched, rules, specs, options, Number.POSITIVE_INFINITY, mode);
121
121
  unmatched = result.unmatched;
122
122
  }
123
123
  else {
@@ -135,7 +135,7 @@ childNodes, specs, options) {
135
135
  }
136
136
  transparentMode.set(child, true);
137
137
  if (child.is(child.ELEMENT_NODE)) {
138
- const transparentCondMatched = matches(transparent.transparent, child, specs);
138
+ const transparentCondMatched = matches(transparent.transparent, child, specs, mode);
139
139
  if (!transparentCondMatched.matched) {
140
140
  branchChildren.push({
141
141
  type: 'TRANSPARENT_MODEL_DISALLOWS',