@poveste/plugin-svelte 0.9.0 → 0.11.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.
@@ -3,13 +3,15 @@ import { createRequire } from 'node:module';
3
3
  import { dirname, join } from 'pathe';
4
4
  import { defaultColors } from 'poveste';
5
5
  import generateStoryCommand from './commands/generate-story.server.js';
6
+ import { svelteKitAssetsDir } from './util/kit-assets.js';
6
7
  import { listComponentFiles } from './util/list-components.js';
7
8
  import { disableStoryComponentHmr } from './util/story-hmr.js';
8
9
  export function HstSvelte() {
9
10
  return {
10
11
  name: '@poveste/plugin-svelte',
11
- defaultConfig() {
12
+ async defaultConfig() {
12
13
  const svelteClientAliases = getSvelteClientAliases();
14
+ const publicDir = await svelteKitAssetsDir(process.cwd());
13
15
  return {
14
16
  supportMatch: [
15
17
  {
@@ -31,20 +33,13 @@ export function HstSvelte() {
31
33
  viteIgnorePlugins: [
32
34
  'vite-plugin-sveltekit-compile',
33
35
  ],
34
- vite: svelteClientAliases.length
35
- ? {
36
- plugins: [
37
- disableStoryComponentHmr(),
38
- ],
39
- resolve: {
40
- alias: svelteClientAliases,
41
- },
42
- }
43
- : {
44
- plugins: [
45
- disableStoryComponentHmr(),
46
- ],
47
- },
36
+ vite: {
37
+ plugins: [
38
+ disableStoryComponentHmr(),
39
+ ],
40
+ ...svelteClientAliases.length ? { resolve: { alias: svelteClientAliases } } : {},
41
+ ...publicDir ? { publicDir } : {},
42
+ },
48
43
  };
49
44
  },
50
45
  supportPlugin: {
@@ -0,0 +1,10 @@
1
+ /**
2
+ * Where a SvelteKit project keeps its static assets, or undefined when this is
3
+ * not one.
4
+ *
5
+ * `vite-plugin-sveltekit-compile` is what sets Vite's `publicDir` to
6
+ * `kit.files.assets`, and poveste drops that plugin through `viteIgnorePlugins`
7
+ * — so a book served none of a Kit project's `static/`, in dev or in the build
8
+ * (#463). This restores the one value that went with it.
9
+ */
10
+ export declare function svelteKitAssetsDir(cwd: string): Promise<string | undefined>;
@@ -0,0 +1,51 @@
1
+ import { existsSync, readFileSync } from 'node:fs';
2
+ import { pathToFileURL } from 'node:url';
3
+ import { isAbsolute, join } from 'pathe';
4
+ /**
5
+ * Where a SvelteKit project keeps its static assets, or undefined when this is
6
+ * not one.
7
+ *
8
+ * `vite-plugin-sveltekit-compile` is what sets Vite's `publicDir` to
9
+ * `kit.files.assets`, and poveste drops that plugin through `viteIgnorePlugins`
10
+ * — so a book served none of a Kit project's `static/`, in dev or in the build
11
+ * (#463). This restores the one value that went with it.
12
+ */
13
+ export async function svelteKitAssetsDir(cwd) {
14
+ if (!declaresKit(cwd)) {
15
+ return undefined;
16
+ }
17
+ const assets = await configuredAssetsDir(cwd) ?? 'static';
18
+ return isAbsolute(assets) ? assets : join(cwd, assets);
19
+ }
20
+ // The project's own manifest, not `require.resolve`: resolution walks up out of
21
+ // the project and finds Kit through a sibling in a workspace store, which
22
+ // reported a plain Svelte book as SvelteKit and pointed its `publicDir` at a
23
+ // `static/` that does not exist — losing the `public/` it actually had.
24
+ function declaresKit(cwd) {
25
+ try {
26
+ const manifest = JSON.parse(readFileSync(join(cwd, 'package.json'), 'utf8'));
27
+ return ['dependencies', 'devDependencies', 'peerDependencies']
28
+ .some(field => manifest[field]?.['@sveltejs/kit']);
29
+ }
30
+ catch {
31
+ return false;
32
+ }
33
+ }
34
+ // Kit reads this from `svelte.config.js` and defaults it to `static`. Reading it
35
+ // rather than assuming the default is what makes a project that moved its assets
36
+ // work; a config that will not load is not this plugin's failure to report, so
37
+ // the default stands in.
38
+ async function configuredAssetsDir(cwd) {
39
+ const configPath = join(cwd, 'svelte.config.js');
40
+ if (!existsSync(configPath)) {
41
+ return undefined;
42
+ }
43
+ try {
44
+ const loaded = await import(pathToFileURL(configPath).href);
45
+ const assets = loaded.default?.kit?.files?.assets;
46
+ return typeof assets === 'string' ? assets : undefined;
47
+ }
48
+ catch {
49
+ return undefined;
50
+ }
51
+ }
@@ -0,0 +1,3 @@
1
+ import type { PropDefinition } from '@poveste/shared';
2
+ /** The props declared, or `undefined` when the source could not be read. */
3
+ export declare function extractPropDefs(source: string): PropDefinition[] | undefined;
@@ -0,0 +1,279 @@
1
+ // A compiled Svelte component keeps no record of what it accepts, the way a Vue
2
+ // vnode does, so the source is the only place its props still exist (#233).
3
+ import { parse } from 'svelte/compiler';
4
+ // Poveste injects this into every story file; no user sets it.
5
+ const INJECTED = 'Hst';
6
+ const SCRIPT_BLOCK = /<script[^>]*>[\s\S]*?<\/script>/gi;
7
+ // Line-anchored: a mention in a comment must not start a match.
8
+ const TYPE_IMPORT = /^[ \t]*import\s+type\b[^;]+?\bfrom[ \t]*(['"])[^'"]*\1(?:[ \t]*;)?[ \t]*$/gm;
9
+ const IMPORT_BRACES = /\bimport\s*\{([^}]*)\}/g;
10
+ // Script blocks only: props live nowhere else, and a preprocessed `<style>`
11
+ // never reaches the parser to throw. Type-only imports still go, both spellings
12
+ // — `import type { Hst }` beside `export let Hst: Hst` reads as a redeclaration.
13
+ function parseable(source) {
14
+ return (source.match(SCRIPT_BLOCK) ?? [])
15
+ .join('\n')
16
+ .replace(TYPE_IMPORT, '')
17
+ .replace(IMPORT_BRACES, (_, specifiers) => {
18
+ const kept = specifiers.split(',').map(s => s.trim()).filter(s => s && !/^type\s/.test(s));
19
+ return `import { ${kept.join(', ')} }`;
20
+ });
21
+ }
22
+ function nameOf(typeName) {
23
+ return typeName?.name ?? typeName?.right?.name;
24
+ }
25
+ function withoutNullish(types) {
26
+ return types.filter((m) => m.type !== 'TSUndefinedKeyword' && m.type !== 'TSNullKeyword');
27
+ }
28
+ // Slot content, which no control can author. Function props are kept — Vue's
29
+ // auto-props lists them too.
30
+ function isSnippet(node) {
31
+ if (!node) {
32
+ return false;
33
+ }
34
+ if (node.type === 'TSParenthesizedType') {
35
+ return isSnippet(node.typeAnnotation);
36
+ }
37
+ if (node.type === 'TSUnionType') {
38
+ const real = withoutNullish(node.types);
39
+ return real.length > 0 && real.every((m) => isSnippet(m));
40
+ }
41
+ return nameOf(node.typeName) === 'Snippet';
42
+ }
43
+ // Only `undefined` makes a prop omittable; `string | null` still has to be passed.
44
+ function acceptsUndefined(node) {
45
+ if (node?.type === 'TSParenthesizedType') {
46
+ return acceptsUndefined(node.typeAnnotation);
47
+ }
48
+ return node?.type === 'TSUnionType'
49
+ && node.types.some((m) => m.type === 'TSUndefinedKeyword');
50
+ }
51
+ function typesFromAnnotation(node) {
52
+ if (!node) {
53
+ return undefined;
54
+ }
55
+ switch (node.type) {
56
+ case 'TSStringKeyword': return ['string'];
57
+ case 'TSNumberKeyword': return ['number'];
58
+ case 'TSBooleanKeyword': return ['boolean'];
59
+ case 'TSArrayType':
60
+ case 'TSTupleType': return ['array'];
61
+ case 'TSTypeOperator':
62
+ case 'TSParenthesizedType': return typesFromAnnotation(node.typeAnnotation);
63
+ case 'TSTypeLiteral': return ['object'];
64
+ case 'TSLiteralType': return [typeName(literalValue(node.literal))];
65
+ case 'TSUnionType': return unionTypes(node);
66
+ case 'TSTypeReference':
67
+ return ['Array', 'ReadonlyArray'].includes(nameOf(node.typeName) ?? '') ? ['array'] : ['unknown'];
68
+ default: return ['unknown'];
69
+ }
70
+ }
71
+ function typeName(value) {
72
+ switch (typeof value) {
73
+ case 'string': return 'string';
74
+ case 'number': return 'number';
75
+ case 'boolean': return 'boolean';
76
+ default: return 'unknown';
77
+ }
78
+ }
79
+ // `unknown` sorts last because the panel reads `types[0]`, so a concrete type
80
+ // wins however the union was written.
81
+ function unionTypes(node) {
82
+ const members = withoutNullish(node.types)
83
+ .flatMap((m) => typesFromAnnotation(m) ?? []);
84
+ return [...new Set(members)].sort((a, b) => Number(a === 'unknown') - Number(b === 'unknown'));
85
+ }
86
+ function typesFromValue(value) {
87
+ const name = typeName(value);
88
+ return name === 'unknown' ? undefined : [name];
89
+ }
90
+ // The only place a definition is built, so the filters cannot be bypassed.
91
+ function propDef(name, annotation, init, optional) {
92
+ if (name === INJECTED || isSnippet(annotation)) {
93
+ return undefined;
94
+ }
95
+ const value = literalValue(init);
96
+ return {
97
+ name,
98
+ types: typesFromAnnotation(annotation) ?? typesFromValue(value),
99
+ required: !optional && init == null && !acceptsUndefined(annotation),
100
+ default: value,
101
+ };
102
+ }
103
+ function localBindings(body) {
104
+ const found = new Map();
105
+ for (const node of body) {
106
+ const declaration = node.type === 'ExportNamedDeclaration' ? node.declaration : node;
107
+ if (declaration?.type === 'VariableDeclaration') {
108
+ for (const declarator of declaration.declarations) {
109
+ if (declarator.id.type === 'Identifier') {
110
+ found.set(declarator.id.name, {
111
+ kind: declaration.kind,
112
+ annotation: declarator.id.typeAnnotation?.typeAnnotation,
113
+ init: declarator.init,
114
+ });
115
+ }
116
+ }
117
+ }
118
+ if (declaration?.type === 'FunctionDeclaration' && declaration.id) {
119
+ found.set(declaration.id.name, { kind: 'function' });
120
+ }
121
+ }
122
+ return found;
123
+ }
124
+ // `export let x` is a prop, and `export { x as y }` when `x` is a `let`. An
125
+ // accessor, a re-export and a type-only export are not.
126
+ function legacyProps(body) {
127
+ const props = [];
128
+ const locals = localBindings(body);
129
+ for (const node of body) {
130
+ if (node.type !== 'ExportNamedDeclaration' || node.exportKind === 'type' || node.source) {
131
+ continue;
132
+ }
133
+ if (!node.declaration && node.specifiers?.length) {
134
+ for (const specifier of node.specifiers) {
135
+ const local = locals.get(specifier.local?.name);
136
+ const def = local?.kind === 'let'
137
+ ? propDef(specifier.exported?.name, local.annotation, local.init, false)
138
+ : undefined;
139
+ if (def) {
140
+ props.push(def);
141
+ }
142
+ }
143
+ continue;
144
+ }
145
+ if (node.declaration?.type !== 'VariableDeclaration' || node.declaration.kind !== 'let') {
146
+ continue;
147
+ }
148
+ for (const declarator of node.declaration.declarations) {
149
+ if (declarator.id.type !== 'Identifier') {
150
+ continue;
151
+ }
152
+ const def = propDef(declarator.id.name, declarator.id.typeAnnotation?.typeAnnotation, declarator.init, false);
153
+ if (def) {
154
+ props.push(def);
155
+ }
156
+ }
157
+ }
158
+ return props;
159
+ }
160
+ function runesProps(body, declared) {
161
+ const declarator = body
162
+ .filter((node) => node.type === 'VariableDeclaration')
163
+ .flatMap((node) => node.declarations)
164
+ .find((d) => d.init?.type === 'CallExpression' && d.init.callee?.name === '$props');
165
+ return declarator ? fromDeclarator(declarator, declared) : [];
166
+ }
167
+ function declaredTypes(body) {
168
+ const found = new Map();
169
+ for (const node of body) {
170
+ const declaration = node.type === 'ExportNamedDeclaration' ? node.declaration : node;
171
+ if (declaration?.type === 'TSInterfaceDeclaration') {
172
+ found.set(declaration.id.name, { type: 'TSTypeLiteral', members: declaration.body.body });
173
+ }
174
+ if (declaration?.type === 'TSTypeAliasDeclaration') {
175
+ found.set(declaration.id.name, declaration.typeAnnotation);
176
+ }
177
+ }
178
+ return found;
179
+ }
180
+ // An alias may intersect literals and other declared names.
181
+ function membersOf(node, declared, depth = 0) {
182
+ if (!node || depth > 4) {
183
+ return undefined;
184
+ }
185
+ if (node.type === 'TSTypeLiteral') {
186
+ return node.members;
187
+ }
188
+ if (node.type === 'TSTypeReference') {
189
+ return membersOf(declared.get(nameOf(node.typeName) ?? ''), declared, depth + 1);
190
+ }
191
+ if (node.type === 'TSIntersectionType') {
192
+ const parts = node.types.flatMap((t) => membersOf(t, declared, depth + 1) ?? []);
193
+ return parts.length > 0 ? parts : undefined;
194
+ }
195
+ return undefined;
196
+ }
197
+ // The annotation and the destructuring each know something the other does not:
198
+ // the annotation carries types and optionality, the destructuring carries
199
+ // defaults and every prop an `extends` clause hides. Merge rather than choose.
200
+ function fromDeclarator(declarator, declared) {
201
+ const destructured = destructuredDefaults(declarator.id);
202
+ const members = membersOf(declarator.id.typeAnnotation?.typeAnnotation, declared);
203
+ const defs = [];
204
+ const seen = new Set();
205
+ for (const member of members ?? []) {
206
+ if (member.key?.type !== 'Identifier') {
207
+ continue;
208
+ }
209
+ // Seen before the kind check, so a method signature is not re-added below.
210
+ seen.add(member.key.name);
211
+ if (member.type !== 'TSPropertySignature') {
212
+ continue;
213
+ }
214
+ const def = propDef(member.key.name, member.typeAnnotation?.typeAnnotation, destructured.get(member.key.name), member.optional);
215
+ if (def) {
216
+ defs.push(def);
217
+ }
218
+ }
219
+ for (const [name, init] of destructured) {
220
+ // `children` is the default snippet in runes mode; with no annotation to
221
+ // read, the name is the only thing that says so.
222
+ if (seen.has(name) || name === 'children') {
223
+ continue;
224
+ }
225
+ // Unannotated, the destructuring is the only signal; annotated, an
226
+ // inherited prop's optionality is unknowable.
227
+ const def = propDef(name, undefined, init, members !== undefined);
228
+ if (def) {
229
+ defs.push(def);
230
+ }
231
+ }
232
+ return defs;
233
+ }
234
+ function destructuredDefaults(id) {
235
+ const found = new Map();
236
+ if (id.type !== 'ObjectPattern') {
237
+ return found;
238
+ }
239
+ for (const property of id.properties) {
240
+ if (property.type !== 'Property' || property.computed || property.key.type !== 'Identifier') {
241
+ continue;
242
+ }
243
+ found.set(property.key.name, property.value.type === 'AssignmentPattern' ? property.value.right : undefined);
244
+ }
245
+ return found;
246
+ }
247
+ // Only what a control can hold and JSON can carry. A `$bindable` default is one
248
+ // node deeper.
249
+ function literalValue(node) {
250
+ if (!node) {
251
+ return undefined;
252
+ }
253
+ if (node.type === 'CallExpression' && node.callee?.name === '$bindable') {
254
+ return literalValue(node.arguments?.[0]);
255
+ }
256
+ if (node.type === 'UnaryExpression' && node.operator === '-') {
257
+ const inner = literalValue(node.argument);
258
+ return typeof inner === 'number' ? -inner : undefined;
259
+ }
260
+ if (node.type !== 'Literal') {
261
+ return undefined;
262
+ }
263
+ return typeof node.value === 'bigint' || node.value instanceof RegExp ? undefined : node.value;
264
+ }
265
+ /** The props declared, or `undefined` when the source could not be read. */
266
+ export function extractPropDefs(source) {
267
+ let ast;
268
+ try {
269
+ ast = parse(parseable(source), { modern: true });
270
+ }
271
+ catch {
272
+ return undefined;
273
+ }
274
+ // Types may be declared in the module script; props never are.
275
+ const body = ast.instance?.content?.body ?? [];
276
+ const declared = declaredTypes([...(ast.module?.content?.body ?? []), ...body]);
277
+ const runes = runesProps(body, declared);
278
+ return runes.length > 0 ? runes : legacyProps(body);
279
+ }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@poveste/plugin-svelte",
3
3
  "type": "module",
4
- "version": "0.9.0",
4
+ "version": "0.11.0",
5
5
  "description": "Poveste plugin for Svelte 5 and SvelteKit support",
6
6
  "author": {
7
7
  "name": "Sorin Gitlan"
@@ -60,7 +60,7 @@
60
60
  "@sveltejs/kit": "^2.53.0",
61
61
  "@sveltejs/vite-plugin-svelte": "^7.0.0",
62
62
  "svelte": "^5.46.4",
63
- "poveste": "^0.9.0"
63
+ "poveste": "^0.11.0"
64
64
  },
65
65
  "peerDependenciesMeta": {
66
66
  "@sveltejs/kit": {
@@ -72,9 +72,9 @@
72
72
  "globby": "^14.0.2",
73
73
  "launch-editor": "^2.9.1",
74
74
  "pathe": "^1.1.2",
75
- "@poveste/controls": "^0.9.0",
76
- "@poveste/shared": "^0.9.0",
77
- "@poveste/vendors": "^0.9.0"
75
+ "@poveste/controls": "^0.11.0",
76
+ "@poveste/shared": "^0.11.0",
77
+ "@poveste/vendors": "^0.11.0"
78
78
  },
79
79
  "devDependencies": {
80
80
  "@sveltejs/vite-plugin-svelte": "^7.0.0",
@@ -86,11 +86,14 @@
86
86
  "svelte-preprocess": "^6.0.3",
87
87
  "typescript": "5.6.3",
88
88
  "vite": "^8.0.0",
89
- "poveste": "0.9.0"
89
+ "vitest": "^4.1.10",
90
+ "poveste": "0.11.0"
90
91
  },
91
92
  "scripts": {
92
93
  "build": "rimraf dist && vite build && tsc -d -P tsconfig.build.json && pnpm run build:types",
93
94
  "build:types": "tsc --declaration --emitDeclarationOnly",
95
+ "test": "vitest run",
96
+ "test:dev": "vitest",
94
97
  "watch": "concurrently \"vite build --watch\" \"tsc -d -P tsconfig.build.json --watch\" \"pnpm run build:types --watch\""
95
98
  }
96
99
  }