@markuplint/svelte-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.
@@ -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,30 +1,30 @@
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 attrs = [];
8
- for (const attr of root.attributes) {
9
- if (attr.type !== 'attr') {
10
- continue;
11
- }
12
- const value = attr.value.raw;
13
- if (value === '') {
14
- attrs.push({ name: attr.nodeName });
15
- }
16
- else {
17
- attrs.push({ name: attr.nodeName, value });
18
- }
19
- }
7
+ const spreads = extractSpreads(root);
20
8
  return {
21
9
  rootElement: root.nodeName,
22
- attrs,
10
+ attrs: extractAttrs(root),
11
+ ...(spreads.length > 0 ? { spreads } : {}),
23
12
  namespace: root.namespace === 'http://www.w3.org/2000/svg' ? 'svg' : undefined,
24
13
  line: root.line,
25
14
  col: root.col,
26
15
  };
27
16
  }
17
+ const SPREAD = /^\{\s*\.\.\.([\s\S]*)\}$/;
18
+ /**
19
+ * The expressions of `{...expr}`, which gives the element the attributes of an object
20
+ * (`{...rest}` is what the component receives and does not declare as props).
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
28
  /**
29
29
  * Slot usage can take several forms whose mapping to psblock node names is
30
30
  * not derivable from this code:
@@ -32,10 +32,214 @@ function extractComponentInfo(doc) {
32
32
  * - Svelte 5: `{@render children()}` (parsed as psblock `#ps:RenderTag`)
33
33
  * - Standard `<slot>` elements
34
34
  */
35
- function detectSlots(doc) {
36
- return doc.nodeList.some(n => (n.type === 'starttag' && n.nodeName === 'slot') ||
35
+ function isSlotNode(n) {
36
+ return ((n.type === 'starttag' && n.nodeName === 'slot') ||
37
37
  (n.type === 'psblock' && (n.nodeName === '#ps:SlotElement' || n.nodeName === '#ps:RenderTag')));
38
38
  }
39
+ function detectSlots(doc) {
40
+ return doc.nodeList.some(n => n.type !== 'doctype' && isSlotNode(n));
41
+ }
42
+ function findRoot(doc) {
43
+ return doc.nodeList.find((n) => n.type === 'starttag' && n.depth === 0 && !n.isFragment);
44
+ }
45
+ function isElement(n) {
46
+ return n.type === 'starttag';
47
+ }
48
+ /**
49
+ * An attribute whose value is an expression (`type={kind}`, the shorthand `{type}`, `bind:value`,
50
+ * or `{}` inside a quoted value) is an attribute the element has, and it is `dynamic`. The other
51
+ * directives (`on:`, `class:`, `use:`, and so on) are not attributes by name and are left out.
52
+ */
53
+ const INTERPOLATION = /\{[^}]*\}/;
54
+ const DIRECTIVE = /^(bind|on|class|style|use|animate|transition|in|out|let):(.+)$/i;
55
+ // Bindings to a value of the element that is not an attribute (`bind:clientWidth`, `bind:this`)
56
+ const BINDING_WITHOUT_ATTRIBUTE = new Set([
57
+ 'group',
58
+ 'this',
59
+ 'clientwidth',
60
+ 'clientheight',
61
+ 'offsetwidth',
62
+ 'offsetheight',
63
+ 'contentrect',
64
+ 'contentboxsize',
65
+ 'borderboxsize',
66
+ 'devicepixelcontentboxsize',
67
+ 'innerhtml',
68
+ 'innertext',
69
+ 'textcontent',
70
+ 'naturalwidth',
71
+ 'naturalheight',
72
+ 'videowidth',
73
+ 'videoheight',
74
+ 'buffered',
75
+ 'seekable',
76
+ 'played',
77
+ 'seeking',
78
+ 'ended',
79
+ 'readystate',
80
+ 'duration',
81
+ 'currenttime',
82
+ 'paused',
83
+ ]);
84
+ function extractAttrs(el) {
85
+ const attrs = [];
86
+ for (const attr of el.attributes) {
87
+ if (attr.type !== 'attr') {
88
+ continue;
89
+ }
90
+ if (attr.name.raw === '') {
91
+ if (attr.potentialName) {
92
+ // The shorthand `{type}` is `type={type}`
93
+ addAttr(attrs, { name: attr.potentialName, dynamic: true, expression: attr.potentialName });
94
+ }
95
+ continue;
96
+ }
97
+ const directive = DIRECTIVE.exec(attr.nodeName);
98
+ if (directive) {
99
+ const [, prefix, name] = directive;
100
+ if (prefix?.toLowerCase() === 'bind' && name && !BINDING_WITHOUT_ATTRIBUTE.has(name.toLowerCase())) {
101
+ addAttr(attrs, { name: name.toLowerCase(), dynamic: true });
102
+ }
103
+ continue;
104
+ }
105
+ const value = attr.value.raw;
106
+ if (attr.isDynamicValue) {
107
+ const expression = value.trim();
108
+ addAttr(attrs, { name: attr.nodeName, dynamic: true, ...(expression === '' ? {} : { expression }) });
109
+ }
110
+ else if (INTERPOLATION.test(value)) {
111
+ addAttr(attrs, { name: attr.nodeName, dynamic: true });
112
+ }
113
+ else if (value === '') {
114
+ addAttr(attrs, { name: attr.nodeName });
115
+ }
116
+ else {
117
+ addAttr(attrs, { name: attr.nodeName, value });
118
+ }
119
+ }
120
+ return attrs;
121
+ }
122
+ /**
123
+ * One name written in two forms (`class="btn" :class="x"`) is one attribute: dynamic
124
+ * when any of the forms is, in the position of the first. It is not one expression any
125
+ * more, so it has no `expression`.
126
+ */
127
+ function addAttr(attrs, attr) {
128
+ const index = attrs.findIndex(existing => existing.name === attr.name);
129
+ const existing = attrs[index];
130
+ if (!existing) {
131
+ attrs.push(attr);
132
+ }
133
+ else if (existing.dynamic || attr.dynamic) {
134
+ attrs[index] = { name: attr.name, dynamic: true };
135
+ }
136
+ }
137
+ function containsSlot(n) {
138
+ return isSlotNode(n) || ('childNodes' in n && n.childNodes.some(child => containsSlot(child)));
139
+ }
140
+ /**
141
+ * The nearest element above the node. Blocks, fragments and `<template>` are
142
+ * looked through: they are not elements the component renders.
143
+ */
144
+ function findWrapper(n, nodes) {
145
+ let current = n.parentNodeUuid ? nodes.get(n.parentNodeUuid) : undefined;
146
+ while (current) {
147
+ if (isElement(current) && !current.isFragment && current.nodeName !== 'template') {
148
+ return current;
149
+ }
150
+ current = current.parentNodeUuid ? nodes.get(current.parentNodeUuid) : undefined;
151
+ }
152
+ return null;
153
+ }
154
+ function isWithin(n, root, nodes) {
155
+ let current = n;
156
+ while (current) {
157
+ if (current.uuid === root.uuid) {
158
+ return true;
159
+ }
160
+ current = current.parentNodeUuid ? nodes.get(current.parentNodeUuid) : undefined;
161
+ }
162
+ return false;
163
+ }
164
+ function extractContents(el) {
165
+ const entries = [];
166
+ for (const child of el.childNodes) {
167
+ if (child.type === 'endtag' || child.type === 'comment') {
168
+ continue;
169
+ }
170
+ if (child.type === 'text') {
171
+ if (child.raw.trim() !== '') {
172
+ entries.push({ text: true });
173
+ }
174
+ continue;
175
+ }
176
+ if (containsSlot(child)) {
177
+ entries.push({ slot: true });
178
+ continue;
179
+ }
180
+ if (child.type === 'psblock') {
181
+ // Closing tags of blocks (`{/if}`) render nothing
182
+ if (!child.nodeName.startsWith('#ps:/')) {
183
+ entries.push({ dynamic: true });
184
+ }
185
+ continue;
186
+ }
187
+ if (isElement(child) && child.elementType === 'html' && child.nodeName !== 'template') {
188
+ const attrs = extractAttrs(child);
189
+ entries.push({ element: child.nodeName, ...(attrs.length > 0 ? { attrs } : {}) });
190
+ continue;
191
+ }
192
+ entries.push({ dynamic: true });
193
+ }
194
+ // Text beside elements can still satisfy a content model (`<ruby>漢<rt>kan</rt></ruby>`),
195
+ // so it is unknown content there. Text alone cannot be what a required child needs.
196
+ const hasElement = entries.some(entry => 'element' in entry);
197
+ const contents = [];
198
+ for (const entry of entries) {
199
+ if (!('text' in entry)) {
200
+ contents.push(entry);
201
+ continue;
202
+ }
203
+ const last = contents.at(-1);
204
+ if (hasElement && !(last && 'dynamic' in last)) {
205
+ contents.push({ dynamic: true });
206
+ }
207
+ }
208
+ return contents;
209
+ }
210
+ /**
211
+ * Finds the element that directly wraps each slot inside the root element and
212
+ * what surrounds the slots: the direct children of the root.
213
+ */
214
+ function scanSlotContent(doc) {
215
+ const root = findRoot(doc);
216
+ if (!root) {
217
+ return {};
218
+ }
219
+ const nodes = new Map(doc.nodeList.filter((n) => n.type !== 'doctype').map(n => [n.uuid, n]));
220
+ const wrappers = new Map();
221
+ for (const n of nodes.values()) {
222
+ if (!isSlotNode(n)) {
223
+ continue;
224
+ }
225
+ const wrapper = findWrapper(n, nodes);
226
+ if (wrapper && isWithin(wrapper, root, nodes)) {
227
+ wrappers.set(wrapper.uuid, wrapper);
228
+ }
229
+ }
230
+ return {
231
+ slotWrappers: [...wrappers.values()].map(wrapper => {
232
+ const isNative = wrapper.elementType === 'html';
233
+ return {
234
+ element: wrapper.nodeName,
235
+ isRoot: wrapper.uuid === root.uuid,
236
+ attrs: isNative ? extractAttrs(wrapper) : [],
237
+ contents: isNative ? extractContents(wrapper) : [],
238
+ };
239
+ }),
240
+ rootContents: extractContents(root),
241
+ };
242
+ }
39
243
  /**
40
244
  * Prefers the instance script over `<script context="module">`.
41
245
  */
@@ -89,7 +293,7 @@ export const componentScanner = {
89
293
  }
90
294
  const hasSlots = detectSlots(doc);
91
295
  const scriptSource = extractSvelteScript(sourceCode) ?? undefined;
92
- return { ...info, hasSlots, scriptSource };
296
+ return { ...info, hasSlots, ...scanSlotContent(doc), scriptSource };
93
297
  },
94
298
  extractScriptSource(sourceCode) {
95
299
  return extractSvelteScript(sourceCode);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@markuplint/svelte-parser",
3
- "version": "5.0.1",
3
+ "version": "5.1.0",
4
4
  "description": "Svelte parser for markuplint",
5
5
  "repository": {
6
6
  "type": "git",
@@ -40,10 +40,10 @@
40
40
  "clean": "tsc --build --clean tsconfig.build.json"
41
41
  },
42
42
  "dependencies": {
43
- "@markuplint/html-parser": "5.0.1",
44
- "@markuplint/ml-ast": "5.0.1",
45
- "@markuplint/parser-utils": "5.0.1",
43
+ "@markuplint/html-parser": "5.1.0",
44
+ "@markuplint/ml-ast": "5.1.0",
45
+ "@markuplint/parser-utils": "5.1.0",
46
46
  "svelte": "5.55.7"
47
47
  },
48
- "gitHead": "edbaeedca5588fb291ea9d4e21a4ab2bf41fc9cb"
48
+ "gitHead": "3a061784a8211034ad0c1cbd64798c0213414f65"
49
49
  }