@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 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
- * A static attribute extracted from a component's root element.
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.
@@ -1,33 +1,203 @@
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
+ 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 root.attributes) {
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 (value === '') {
14
- attrs.push({ name: attr.nodeName });
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.push({ name: attr.nodeName, value });
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
- 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,
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.0.1",
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.0.1",
36
- "@markuplint/parser-utils": "5.0.1",
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": "edbaeedca5588fb291ea9d4e21a4ab2bf41fc9cb"
42
+ "gitHead": "a940c8526e971d590d97395ce924910221208820"
43
43
  }