@markuplint/vue-parser 5.0.1 → 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 +12 -0
- package/lib/component-scanner.d.ts +33 -1
- package/lib/component-scanner.js +203 -13
- package/package.json +5 -5
package/CHANGELOG.md
CHANGED
|
@@ -3,6 +3,18 @@
|
|
|
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
|
+
|
|
6
18
|
## [5.0.1](https://github.com/markuplint/markuplint/compare/v5.0.0...v5.0.1) (2026-09-22)
|
|
7
19
|
|
|
8
20
|
**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
|
-
*
|
|
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.
|
package/lib/component-scanner.js
CHANGED
|
@@ -1,33 +1,223 @@
|
|
|
1
1
|
import { parser } from './parser.js';
|
|
2
2
|
function extractComponentInfo(doc) {
|
|
3
|
-
const root = doc
|
|
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
|
|
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
|
|
86
|
+
addAttr(attrs, { name: attr.nodeName });
|
|
15
87
|
}
|
|
16
88
|
else {
|
|
17
|
-
attrs
|
|
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
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
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
|
|
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
|
|
36
|
-
"@markuplint/ml-ast": "5.0
|
|
37
|
-
"@markuplint/parser-utils": "5.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": "
|
|
40
|
+
"gitHead": "3a061784a8211034ad0c1cbd64798c0213414f65"
|
|
41
41
|
}
|