@markuplint/astro-parser 5.0.1 → 5.1.1
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 +16 -0
- package/lib/component-scanner.d.ts +32 -1
- package/lib/component-scanner.js +184 -14
- package/package.json +4 -4
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.1](https://github.com/markuplint/markuplint/compare/v5.1.0...v5.1.1) (2026-10-09)
|
|
7
|
+
|
|
8
|
+
**Note:** Version bump only for package @markuplint/astro-parser
|
|
9
|
+
|
|
10
|
+
# [5.1.0](https://github.com/markuplint/markuplint/compare/v5.0.1...v5.1.0) (2026-10-08)
|
|
11
|
+
|
|
12
|
+
### Bug Fixes
|
|
13
|
+
|
|
14
|
+
- **astro-parser:** keep XML-namespaced attributes as static attributes ([4cc4253](https://github.com/markuplint/markuplint/commit/4cc4253c18f0b190177a79290a170996d1db8d02)), closes [#4058](https://github.com/markuplint/markuplint/issues/4058)
|
|
15
|
+
|
|
16
|
+
### Features
|
|
17
|
+
|
|
18
|
+
- **astro-parser:** report an attribute with an expression value as dynamic ([18f0985](https://github.com/markuplint/markuplint/commit/18f0985d1877be0f9b2821ba555f1896a2379307)), closes [#4058](https://github.com/markuplint/markuplint/issues/4058)
|
|
19
|
+
- **astro-parser:** report the expression of a dynamic attribute and the spreads of the root ([994b3bb](https://github.com/markuplint/markuplint/commit/994b3bb8f8a83f8cf1596d63174d1b21257625d7))
|
|
20
|
+
- **astro-parser:** report the slot wrappers and the direct children of the root ([2572e32](https://github.com/markuplint/markuplint/commit/2572e328789631df2903b3d730210aff65502d42)), closes [#4054](https://github.com/markuplint/markuplint/issues/4054)
|
|
21
|
+
|
|
6
22
|
## [5.0.1](https://github.com/markuplint/markuplint/compare/v5.0.0...v5.0.1) (2026-09-22)
|
|
7
23
|
|
|
8
24
|
**Note:** Version bump only for package @markuplint/astro-parser
|
|
@@ -5,17 +5,48 @@ 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[];
|
|
9
12
|
readonly namespace?: 'svg';
|
|
10
13
|
readonly line?: number;
|
|
11
14
|
readonly col?: number;
|
|
12
15
|
}
|
|
13
16
|
/**
|
|
14
|
-
*
|
|
17
|
+
* An attribute extracted from an element of a component: a static one with its value, or
|
|
18
|
+
* a `dynamic` one whose value is an expression.
|
|
15
19
|
*/
|
|
16
20
|
export interface ComponentScanAttr {
|
|
17
21
|
readonly name: string;
|
|
18
22
|
readonly value?: string;
|
|
23
|
+
/** The attribute is present and its value is an expression, unknown at scan time. `value` is omitted. */
|
|
24
|
+
readonly dynamic?: true;
|
|
25
|
+
/** The source of the expression of a `dynamic` attribute that is written as one expression. */
|
|
26
|
+
readonly expression?: string;
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* A direct child of an element that wraps a slot. A native element is itself
|
|
30
|
+
* (its own content is not tracked), the slot is its position, and anything
|
|
31
|
+
* else (an expression, a block, a component) is unknown content.
|
|
32
|
+
*/
|
|
33
|
+
export type ComponentScanContent = {
|
|
34
|
+
readonly element: string;
|
|
35
|
+
readonly attrs?: readonly ComponentScanAttr[];
|
|
36
|
+
} | {
|
|
37
|
+
readonly slot: true;
|
|
38
|
+
} | {
|
|
39
|
+
readonly dynamic: true;
|
|
40
|
+
};
|
|
41
|
+
/**
|
|
42
|
+
* An element that directly wraps a slot. A component has no `attrs` and no
|
|
43
|
+
* `contents`: what it renders is unknown.
|
|
44
|
+
*/
|
|
45
|
+
export interface ComponentScanSlotWrapper {
|
|
46
|
+
readonly element: string;
|
|
47
|
+
readonly isRoot: boolean;
|
|
48
|
+
readonly attrs: readonly ComponentScanAttr[];
|
|
49
|
+
readonly contents: readonly ComponentScanContent[];
|
|
19
50
|
}
|
|
20
51
|
/**
|
|
21
52
|
* A script/ESM source block extracted from a component file.
|
package/lib/component-scanner.js
CHANGED
|
@@ -1,33 +1,203 @@
|
|
|
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
|
+
namespace: root.namespace === 'http://www.w3.org/2000/svg' ? 'svg' : undefined,
|
|
13
|
+
line: root.line,
|
|
14
|
+
col: root.col,
|
|
15
|
+
};
|
|
16
|
+
}
|
|
17
|
+
const SPREAD = /^\{\s*\.\.\.([\s\S]*)\}$/;
|
|
18
|
+
/**
|
|
19
|
+
* The expressions of `{...expr}`, which gives the element the attributes of an object
|
|
20
|
+
* (`{...Astro.props}` is what the component receives).
|
|
21
|
+
*/
|
|
22
|
+
function extractSpreads(el) {
|
|
23
|
+
return el.attributes.flatMap(attr => {
|
|
24
|
+
const expression = attr.type === 'spread' ? SPREAD.exec(attr.raw.trim())?.[1]?.trim() : undefined;
|
|
25
|
+
return expression ? [expression] : [];
|
|
26
|
+
});
|
|
27
|
+
}
|
|
28
|
+
function isSlotNode(n) {
|
|
29
|
+
return n.type === 'starttag' && n.nodeName === 'slot';
|
|
30
|
+
}
|
|
31
|
+
function detectSlots(doc) {
|
|
32
|
+
return doc.nodeList.some(n => n.type !== 'doctype' && isSlotNode(n));
|
|
33
|
+
}
|
|
34
|
+
function findRoot(doc) {
|
|
35
|
+
return doc.nodeList.find((n) => n.type === 'starttag' && n.depth === 0 && !n.isFragment);
|
|
36
|
+
}
|
|
37
|
+
function isElement(n) {
|
|
38
|
+
return n.type === 'starttag';
|
|
39
|
+
}
|
|
40
|
+
// The parser flags every `prefix:name` as a directive, including the XML namespaces of SVG
|
|
41
|
+
const XML_NAMESPACED = /^(?:xlink|xml|xmlns):/;
|
|
42
|
+
/**
|
|
43
|
+
* An attribute whose value is an expression (`type={kind}`, the shorthand `{type}`, or a
|
|
44
|
+
* template literal) is an attribute the element has, and it is `dynamic`. The directives
|
|
45
|
+
* (`class:list`, `set:html`, `client:load`, and so on) are not attributes by name and are left out.
|
|
46
|
+
*/
|
|
47
|
+
function extractAttrs(el) {
|
|
7
48
|
const attrs = [];
|
|
8
|
-
for (const attr of
|
|
49
|
+
for (const attr of el.attributes) {
|
|
9
50
|
if (attr.type !== 'attr') {
|
|
10
51
|
continue;
|
|
11
52
|
}
|
|
53
|
+
if (attr.name.raw === '') {
|
|
54
|
+
if (attr.potentialName) {
|
|
55
|
+
// The shorthand `{type}` is `type={type}`
|
|
56
|
+
addAttr(attrs, { name: attr.potentialName, dynamic: true, expression: attr.potentialName });
|
|
57
|
+
}
|
|
58
|
+
continue;
|
|
59
|
+
}
|
|
60
|
+
if ((attr.isDirective || attr.nodeName.includes(':')) && !XML_NAMESPACED.test(attr.nodeName)) {
|
|
61
|
+
continue;
|
|
62
|
+
}
|
|
12
63
|
const value = attr.value.raw;
|
|
13
|
-
if (
|
|
14
|
-
|
|
64
|
+
if (attr.isDynamicValue) {
|
|
65
|
+
const expression = value.trim();
|
|
66
|
+
addAttr(attrs, { name: attr.nodeName, dynamic: true, ...(expression === '' ? {} : { expression }) });
|
|
67
|
+
}
|
|
68
|
+
else if (/^`.*`$/s.test(value)) {
|
|
69
|
+
addAttr(attrs, { name: attr.nodeName, dynamic: true });
|
|
70
|
+
}
|
|
71
|
+
else if (value === '') {
|
|
72
|
+
addAttr(attrs, { name: attr.nodeName });
|
|
15
73
|
}
|
|
16
74
|
else {
|
|
17
|
-
attrs
|
|
75
|
+
addAttr(attrs, { name: attr.nodeName, value });
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
return attrs;
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* One name written in two forms (`class="btn" :class="x"`) is one attribute: dynamic
|
|
82
|
+
* when any of the forms is, in the position of the first. It is not one expression any
|
|
83
|
+
* more, so it has no `expression`.
|
|
84
|
+
*/
|
|
85
|
+
function addAttr(attrs, attr) {
|
|
86
|
+
const index = attrs.findIndex(existing => existing.name === attr.name);
|
|
87
|
+
const existing = attrs[index];
|
|
88
|
+
if (!existing) {
|
|
89
|
+
attrs.push(attr);
|
|
90
|
+
}
|
|
91
|
+
else if (existing.dynamic || attr.dynamic) {
|
|
92
|
+
attrs[index] = { name: attr.name, dynamic: true };
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
function containsSlot(n) {
|
|
96
|
+
return isSlotNode(n) || ('childNodes' in n && n.childNodes.some(child => containsSlot(child)));
|
|
97
|
+
}
|
|
98
|
+
/**
|
|
99
|
+
* The nearest element above the node. Blocks, fragments and `<template>` are
|
|
100
|
+
* looked through: they are not elements the component renders.
|
|
101
|
+
*/
|
|
102
|
+
function findWrapper(n, nodes) {
|
|
103
|
+
let current = n.parentNodeUuid ? nodes.get(n.parentNodeUuid) : undefined;
|
|
104
|
+
while (current) {
|
|
105
|
+
if (isElement(current) && !current.isFragment && current.nodeName !== 'template') {
|
|
106
|
+
return current;
|
|
107
|
+
}
|
|
108
|
+
current = current.parentNodeUuid ? nodes.get(current.parentNodeUuid) : undefined;
|
|
109
|
+
}
|
|
110
|
+
return null;
|
|
111
|
+
}
|
|
112
|
+
function isWithin(n, root, nodes) {
|
|
113
|
+
let current = n;
|
|
114
|
+
while (current) {
|
|
115
|
+
if (current.uuid === root.uuid) {
|
|
116
|
+
return true;
|
|
117
|
+
}
|
|
118
|
+
current = current.parentNodeUuid ? nodes.get(current.parentNodeUuid) : undefined;
|
|
119
|
+
}
|
|
120
|
+
return false;
|
|
121
|
+
}
|
|
122
|
+
function extractContents(el) {
|
|
123
|
+
const entries = [];
|
|
124
|
+
for (const child of el.childNodes) {
|
|
125
|
+
if (child.type === 'endtag' || child.type === 'comment') {
|
|
126
|
+
continue;
|
|
127
|
+
}
|
|
128
|
+
if (child.type === 'text') {
|
|
129
|
+
if (child.raw.trim() !== '') {
|
|
130
|
+
entries.push({ text: true });
|
|
131
|
+
}
|
|
132
|
+
continue;
|
|
133
|
+
}
|
|
134
|
+
if (containsSlot(child)) {
|
|
135
|
+
entries.push({ slot: true });
|
|
136
|
+
continue;
|
|
137
|
+
}
|
|
138
|
+
if (child.type === 'psblock') {
|
|
139
|
+
// Closing tags of blocks (`{/if}`) render nothing
|
|
140
|
+
if (!child.nodeName.startsWith('#ps:/')) {
|
|
141
|
+
entries.push({ dynamic: true });
|
|
142
|
+
}
|
|
143
|
+
continue;
|
|
144
|
+
}
|
|
145
|
+
if (isElement(child) && child.elementType === 'html' && child.nodeName !== 'template') {
|
|
146
|
+
const attrs = extractAttrs(child);
|
|
147
|
+
entries.push({ element: child.nodeName, ...(attrs.length > 0 ? { attrs } : {}) });
|
|
148
|
+
continue;
|
|
149
|
+
}
|
|
150
|
+
entries.push({ dynamic: true });
|
|
151
|
+
}
|
|
152
|
+
// Text beside elements can still satisfy a content model (`<ruby>漢<rt>kan</rt></ruby>`),
|
|
153
|
+
// so it is unknown content there. Text alone cannot be what a required child needs.
|
|
154
|
+
const hasElement = entries.some(entry => 'element' in entry);
|
|
155
|
+
const contents = [];
|
|
156
|
+
for (const entry of entries) {
|
|
157
|
+
if (!('text' in entry)) {
|
|
158
|
+
contents.push(entry);
|
|
159
|
+
continue;
|
|
160
|
+
}
|
|
161
|
+
const last = contents.at(-1);
|
|
162
|
+
if (hasElement && !(last && 'dynamic' in last)) {
|
|
163
|
+
contents.push({ dynamic: true });
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
return contents;
|
|
167
|
+
}
|
|
168
|
+
/**
|
|
169
|
+
* Finds the element that directly wraps each slot inside the root element and
|
|
170
|
+
* what surrounds the slots: the direct children of the root.
|
|
171
|
+
*/
|
|
172
|
+
function scanSlotContent(doc) {
|
|
173
|
+
const root = findRoot(doc);
|
|
174
|
+
if (!root) {
|
|
175
|
+
return {};
|
|
176
|
+
}
|
|
177
|
+
const nodes = new Map(doc.nodeList.filter((n) => n.type !== 'doctype').map(n => [n.uuid, n]));
|
|
178
|
+
const wrappers = new Map();
|
|
179
|
+
for (const n of nodes.values()) {
|
|
180
|
+
if (!isSlotNode(n)) {
|
|
181
|
+
continue;
|
|
182
|
+
}
|
|
183
|
+
const wrapper = findWrapper(n, nodes);
|
|
184
|
+
if (wrapper && isWithin(wrapper, root, nodes)) {
|
|
185
|
+
wrappers.set(wrapper.uuid, wrapper);
|
|
18
186
|
}
|
|
19
187
|
}
|
|
20
188
|
return {
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
189
|
+
slotWrappers: [...wrappers.values()].map(wrapper => {
|
|
190
|
+
const isNative = wrapper.elementType === 'html';
|
|
191
|
+
return {
|
|
192
|
+
element: wrapper.nodeName,
|
|
193
|
+
isRoot: wrapper.uuid === root.uuid,
|
|
194
|
+
attrs: isNative ? extractAttrs(wrapper) : [],
|
|
195
|
+
contents: isNative ? extractContents(wrapper) : [],
|
|
196
|
+
};
|
|
197
|
+
}),
|
|
198
|
+
rootContents: extractContents(root),
|
|
26
199
|
};
|
|
27
200
|
}
|
|
28
|
-
function detectSlots(doc) {
|
|
29
|
-
return doc.nodeList.some(n => n.type === 'starttag' && n.nodeName === 'slot');
|
|
30
|
-
}
|
|
31
201
|
function extractAstroFrontmatter(source) {
|
|
32
202
|
const re = /^(?:\s*\n)?---\r?\n/;
|
|
33
203
|
const startMatch = re.exec(source);
|
|
@@ -71,7 +241,7 @@ export const componentScanner = {
|
|
|
71
241
|
}
|
|
72
242
|
const hasSlots = detectSlots(doc);
|
|
73
243
|
const scriptSource = extractAstroFrontmatter(sourceCode) ?? undefined;
|
|
74
|
-
return { ...info, hasSlots, scriptSource };
|
|
244
|
+
return { ...info, hasSlots, ...scanSlotContent(doc), scriptSource };
|
|
75
245
|
},
|
|
76
246
|
extractScriptSource(sourceCode) {
|
|
77
247
|
return extractAstroFrontmatter(sourceCode);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@markuplint/astro-parser",
|
|
3
|
-
"version": "5.
|
|
3
|
+
"version": "5.1.1",
|
|
4
4
|
"description": "astro parser for markuplint",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -32,12 +32,12 @@
|
|
|
32
32
|
"clean": "tsc --build --clean tsconfig.build.json"
|
|
33
33
|
},
|
|
34
34
|
"dependencies": {
|
|
35
|
-
"@markuplint/ml-ast": "5.
|
|
36
|
-
"@markuplint/parser-utils": "5.
|
|
35
|
+
"@markuplint/ml-ast": "5.1.1",
|
|
36
|
+
"@markuplint/parser-utils": "5.1.1",
|
|
37
37
|
"astro-eslint-parser": "1.4.0"
|
|
38
38
|
},
|
|
39
39
|
"devDependencies": {
|
|
40
40
|
"@astrojs/compiler": "3.0.1"
|
|
41
41
|
},
|
|
42
|
-
"gitHead": "
|
|
42
|
+
"gitHead": "a940c8526e971d590d97395ce924910221208820"
|
|
43
43
|
}
|