@markuplint/vue-parser 5.0.0 → 5.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -3,6 +3,22 @@
3
3
  All notable changes to this project will be documented in this file.
4
4
  See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
5
5
 
6
+ # [5.1.0](https://github.com/markuplint/markuplint/compare/v5.0.1...v5.1.0) (2026-10-08)
7
+
8
+ ### Bug Fixes
9
+
10
+ - **vue-parser:** merge a static and a bound attribute of the same name ([4adc718](https://github.com/markuplint/markuplint/commit/4adc718e99768a06deee3dae2b1a92dd86d1f9fa)), closes [#4058](https://github.com/markuplint/markuplint/issues/4058)
11
+
12
+ ### Features
13
+
14
+ - **vue-parser:** report a bound attribute as dynamic under its own name ([8246612](https://github.com/markuplint/markuplint/commit/824661259b3893971beb3acf0f2eb5581d56a5e6)), closes [#4058](https://github.com/markuplint/markuplint/issues/4058)
15
+ - **vue-parser:** report the expression, the spreads of the root, and whether it has siblings ([b811d81](https://github.com/markuplint/markuplint/commit/b811d81363bea86e57a2652086e13dbb5f5d90e0))
16
+ - **vue-parser:** report the slot wrappers and the direct children of the root ([7930ee7](https://github.com/markuplint/markuplint/commit/7930ee7b352292cbcc975e89c4976f101b19882b)), closes [#4054](https://github.com/markuplint/markuplint/issues/4054)
17
+
18
+ ## [5.0.1](https://github.com/markuplint/markuplint/compare/v5.0.0...v5.0.1) (2026-09-22)
19
+
20
+ **Note:** Version bump only for package @markuplint/vue-parser
21
+
6
22
  # [5.0.0](https://github.com/markuplint/markuplint/compare/v5.0.0-rc.7...v5.0.0) (2026-09-11)
7
23
 
8
24
  **Note:** Version bump only for package @markuplint/vue-parser
@@ -5,17 +5,49 @@ export interface ComponentScanResult {
5
5
  readonly rootElement: string | null;
6
6
  readonly attrs: readonly ComponentScanAttr[];
7
7
  readonly hasSlots: boolean;
8
+ readonly slotWrappers?: readonly ComponentScanSlotWrapper[];
9
+ readonly rootContents?: readonly ComponentScanContent[];
8
10
  readonly scriptSource?: ComponentScanScriptSource;
11
+ readonly spreads?: readonly string[];
12
+ readonly hasSiblingRoots?: true;
9
13
  readonly namespace?: 'svg';
10
14
  readonly line?: number;
11
15
  readonly col?: number;
12
16
  }
13
17
  /**
14
- * A static attribute extracted from a component's root element.
18
+ * An attribute extracted from an element of a component: a static one with its value, or
19
+ * a `dynamic` one whose value is an expression.
15
20
  */
16
21
  export interface ComponentScanAttr {
17
22
  readonly name: string;
18
23
  readonly value?: string;
24
+ /** The attribute is present and its value is an expression, unknown at scan time. `value` is omitted. */
25
+ readonly dynamic?: true;
26
+ /** The source of the expression of a `dynamic` attribute that is written as one expression. */
27
+ readonly expression?: string;
28
+ }
29
+ /**
30
+ * A direct child of an element that wraps a slot. A native element is itself
31
+ * (its own content is not tracked), the slot is its position, and anything
32
+ * else (an expression, a block, a component) is unknown content.
33
+ */
34
+ export type ComponentScanContent = {
35
+ readonly element: string;
36
+ readonly attrs?: readonly ComponentScanAttr[];
37
+ } | {
38
+ readonly slot: true;
39
+ } | {
40
+ readonly dynamic: true;
41
+ };
42
+ /**
43
+ * An element that directly wraps a slot. A component has no `attrs` and no
44
+ * `contents`: what it renders is unknown.
45
+ */
46
+ export interface ComponentScanSlotWrapper {
47
+ readonly element: string;
48
+ readonly isRoot: boolean;
49
+ readonly attrs: readonly ComponentScanAttr[];
50
+ readonly contents: readonly ComponentScanContent[];
19
51
  }
20
52
  /**
21
53
  * A script/ESM source block extracted from a component file.
@@ -1,33 +1,223 @@
1
1
  import { parser } from './parser.js';
2
2
  function extractComponentInfo(doc) {
3
- const root = doc.nodeList.find((n) => n.type === 'starttag' && n.depth === 0 && !n.isFragment);
3
+ const root = findRoot(doc);
4
4
  if (!root) {
5
5
  return null;
6
6
  }
7
+ const spreads = extractSpreads(root);
8
+ return {
9
+ rootElement: root.nodeName,
10
+ attrs: extractAttrs(root),
11
+ ...(spreads.length > 0 ? { spreads } : {}),
12
+ ...(hasSiblingRoots(doc, root) ? { hasSiblingRoots: true } : {}),
13
+ namespace: root.namespace === 'http://www.w3.org/2000/svg' ? 'svg' : undefined,
14
+ line: root.line,
15
+ col: root.col,
16
+ };
17
+ }
18
+ /**
19
+ * The expressions of `v-bind="expr"` (without an argument), which gives the element the
20
+ * attributes of an object. `v-bind="$attrs"` is what the component receives and does not
21
+ * declare as props.
22
+ */
23
+ function extractSpreads(el) {
24
+ return el.attributes.flatMap(attr => attr.type === 'attr' && attr.nodeName === 'v-bind' && attr.value.raw.trim() !== ''
25
+ ? [attr.value.raw.trim()]
26
+ : []);
27
+ }
28
+ /**
29
+ * Another root beside the root element: an element (`v-else-if` / `v-else` continue the
30
+ * `v-if` before them and are not another root), text, or an interpolation such as `{{ x }}`.
31
+ */
32
+ function hasSiblingRoots(doc, root) {
33
+ return doc.nodeList.some(n => {
34
+ if (n.uuid === root.uuid) {
35
+ return false;
36
+ }
37
+ if (n.type === 'text' || n.type === 'psblock') {
38
+ return n.depth === 0 && n.raw.trim() !== '';
39
+ }
40
+ return (n.type === 'starttag' &&
41
+ n.depth === 0 &&
42
+ !n.isFragment &&
43
+ !n.attributes.some(attr => attr.type === 'attr' && /^v-else(?:-if)?$/.test(attr.nodeName)));
44
+ });
45
+ }
46
+ function isSlotNode(n) {
47
+ return n.type === 'starttag' && n.nodeName === 'slot';
48
+ }
49
+ function detectSlots(doc) {
50
+ return doc.nodeList.some(n => n.type !== 'doctype' && isSlotNode(n));
51
+ }
52
+ function findRoot(doc) {
53
+ return doc.nodeList.find((n) => n.type === 'starttag' && n.depth === 0 && !n.isFragment);
54
+ }
55
+ function isElement(n) {
56
+ return n.type === 'starttag';
57
+ }
58
+ /**
59
+ * A binding (`:type`, `v-bind:type`) is an attribute the element has, and its value is an
60
+ * expression: it is `dynamic` under its own name. Event handlers, `v-bind` objects, the other
61
+ * directives, and bindings without a name known here (modifiers, `[name]`) are not
62
+ * attributes by name and are left out.
63
+ */
64
+ const BINDING = /^(?:v-bind:|:)([^.[]+)$/;
65
+ const NOT_ATTRIBUTE = /^(?:v-|[@:.#])/;
66
+ const SPECIAL_PROPS = new Set(['key', 'ref', 'is']);
67
+ function extractAttrs(el) {
7
68
  const attrs = [];
8
- for (const attr of root.attributes) {
69
+ for (const attr of el.attributes) {
9
70
  if (attr.type !== 'attr') {
10
71
  continue;
11
72
  }
73
+ const binding = BINDING.exec(attr.nodeName)?.[1]?.toLowerCase();
74
+ if (binding) {
75
+ if (!SPECIAL_PROPS.has(binding)) {
76
+ const expression = attr.value.raw.trim();
77
+ addAttr(attrs, { name: binding, dynamic: true, ...(expression === '' ? {} : { expression }) });
78
+ }
79
+ continue;
80
+ }
81
+ if (NOT_ATTRIBUTE.test(attr.nodeName)) {
82
+ continue;
83
+ }
12
84
  const value = attr.value.raw;
13
85
  if (value === '') {
14
- attrs.push({ name: attr.nodeName });
86
+ addAttr(attrs, { name: attr.nodeName });
15
87
  }
16
88
  else {
17
- attrs.push({ name: attr.nodeName, value });
89
+ addAttr(attrs, { name: attr.nodeName, value });
90
+ }
91
+ }
92
+ return attrs;
93
+ }
94
+ /**
95
+ * One name written in two forms (`class="btn" :class="x"`) is one attribute: dynamic
96
+ * when any of the forms is, in the position of the first. It is not one expression any
97
+ * more, so it has no `expression`.
98
+ */
99
+ function addAttr(attrs, attr) {
100
+ const index = attrs.findIndex(existing => existing.name === attr.name);
101
+ const existing = attrs[index];
102
+ if (!existing) {
103
+ attrs.push(attr);
104
+ }
105
+ else if (existing.dynamic || attr.dynamic) {
106
+ attrs[index] = { name: attr.name, dynamic: true };
107
+ }
108
+ }
109
+ function containsSlot(n) {
110
+ return isSlotNode(n) || ('childNodes' in n && n.childNodes.some(child => containsSlot(child)));
111
+ }
112
+ /**
113
+ * The nearest element above the node. Blocks, fragments and `<template>` are
114
+ * looked through: they are not elements the component renders.
115
+ */
116
+ function findWrapper(n, nodes) {
117
+ let current = n.parentNodeUuid ? nodes.get(n.parentNodeUuid) : undefined;
118
+ while (current) {
119
+ if (isElement(current) && !current.isFragment && current.nodeName !== 'template') {
120
+ return current;
121
+ }
122
+ current = current.parentNodeUuid ? nodes.get(current.parentNodeUuid) : undefined;
123
+ }
124
+ return null;
125
+ }
126
+ function isWithin(n, root, nodes) {
127
+ let current = n;
128
+ while (current) {
129
+ if (current.uuid === root.uuid) {
130
+ return true;
131
+ }
132
+ current = current.parentNodeUuid ? nodes.get(current.parentNodeUuid) : undefined;
133
+ }
134
+ return false;
135
+ }
136
+ function extractContents(el) {
137
+ const entries = [];
138
+ for (const child of el.childNodes) {
139
+ if (child.type === 'endtag' || child.type === 'comment') {
140
+ continue;
141
+ }
142
+ if (child.type === 'text') {
143
+ if (child.raw.trim() !== '') {
144
+ entries.push({ text: true });
145
+ }
146
+ continue;
147
+ }
148
+ if (isElement(child) &&
149
+ child.attributes.some(attr => attr.type === 'attr' && /^v-(?:if|else-if|else|for)$/.test(attr.nodeName))) {
150
+ // Whether it exists, and how many times, is decided at render time
151
+ entries.push({ dynamic: true });
152
+ continue;
153
+ }
154
+ if (containsSlot(child)) {
155
+ entries.push({ slot: true });
156
+ continue;
157
+ }
158
+ if (child.type === 'psblock') {
159
+ // Closing tags of blocks (`{/if}`) render nothing
160
+ if (!child.nodeName.startsWith('#ps:/')) {
161
+ entries.push({ dynamic: true });
162
+ }
163
+ continue;
164
+ }
165
+ if (isElement(child) && child.elementType === 'html' && child.nodeName !== 'template') {
166
+ const attrs = extractAttrs(child);
167
+ entries.push({ element: child.nodeName, ...(attrs.length > 0 ? { attrs } : {}) });
168
+ continue;
169
+ }
170
+ entries.push({ dynamic: true });
171
+ }
172
+ // Text beside elements can still satisfy a content model (`<ruby>漢<rt>kan</rt></ruby>`),
173
+ // so it is unknown content there. Text alone cannot be what a required child needs.
174
+ const hasElement = entries.some(entry => 'element' in entry);
175
+ const contents = [];
176
+ for (const entry of entries) {
177
+ if (!('text' in entry)) {
178
+ contents.push(entry);
179
+ continue;
180
+ }
181
+ const last = contents.at(-1);
182
+ if (hasElement && !(last && 'dynamic' in last)) {
183
+ contents.push({ dynamic: true });
184
+ }
185
+ }
186
+ return contents;
187
+ }
188
+ /**
189
+ * Finds the element that directly wraps each slot inside the root element and
190
+ * what surrounds the slots: the direct children of the root.
191
+ */
192
+ function scanSlotContent(doc) {
193
+ const root = findRoot(doc);
194
+ if (!root) {
195
+ return {};
196
+ }
197
+ const nodes = new Map(doc.nodeList.filter((n) => n.type !== 'doctype').map(n => [n.uuid, n]));
198
+ const wrappers = new Map();
199
+ for (const n of nodes.values()) {
200
+ if (!isSlotNode(n)) {
201
+ continue;
202
+ }
203
+ const wrapper = findWrapper(n, nodes);
204
+ if (wrapper && isWithin(wrapper, root, nodes)) {
205
+ wrappers.set(wrapper.uuid, wrapper);
18
206
  }
19
207
  }
20
208
  return {
21
- rootElement: root.nodeName,
22
- attrs,
23
- namespace: root.namespace === 'http://www.w3.org/2000/svg' ? 'svg' : undefined,
24
- line: root.line,
25
- col: root.col,
209
+ slotWrappers: [...wrappers.values()].map(wrapper => {
210
+ const isNative = wrapper.elementType === 'html';
211
+ return {
212
+ element: wrapper.nodeName,
213
+ isRoot: wrapper.uuid === root.uuid,
214
+ attrs: isNative ? extractAttrs(wrapper) : [],
215
+ contents: isNative ? extractContents(wrapper) : [],
216
+ };
217
+ }),
218
+ rootContents: extractContents(root),
26
219
  };
27
220
  }
28
- function detectSlots(doc) {
29
- return doc.nodeList.some(n => n.type === 'starttag' && n.nodeName === 'slot');
30
- }
31
221
  function extractVueScriptSetup(source) {
32
222
  const re = /<script\s[^>]*?\bsetup\b[^>]*>/i;
33
223
  const match = re.exec(source);
@@ -72,7 +262,7 @@ export const componentScanner = {
72
262
  }
73
263
  const hasSlots = detectSlots(doc);
74
264
  const scriptSource = extractVueScriptSetup(sourceCode) ?? undefined;
75
- return { ...info, hasSlots, scriptSource };
265
+ return { ...info, hasSlots, ...scanSlotContent(doc), scriptSource };
76
266
  },
77
267
  extractScriptSource(sourceCode) {
78
268
  return extractVueScriptSetup(sourceCode);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@markuplint/vue-parser",
3
- "version": "5.0.0",
3
+ "version": "5.1.0",
4
4
  "description": "Vue parser for markuplint",
5
5
  "repository": {
6
6
  "type": "git",
@@ -32,10 +32,10 @@
32
32
  "clean": "tsc --build --clean tsconfig.build.json"
33
33
  },
34
34
  "dependencies": {
35
- "@markuplint/html-parser": "5.0.0",
36
- "@markuplint/ml-ast": "5.0.0",
37
- "@markuplint/parser-utils": "5.0.0",
35
+ "@markuplint/html-parser": "5.1.0",
36
+ "@markuplint/ml-ast": "5.1.0",
37
+ "@markuplint/parser-utils": "5.1.0",
38
38
  "vue-eslint-parser": "10.4.0"
39
39
  },
40
- "gitHead": "c99706fda4c186aeb87b521eaabc5208ac144bbd"
40
+ "gitHead": "3a061784a8211034ad0c1cbd64798c0213414f65"
41
41
  }