@poveste/plugin-svelte 0.10.0 → 0.12.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.
@@ -0,0 +1,2 @@
1
+ // https://github.com/microsoft/TypeScript/issues/33079
2
+ export * from './dist/client/auto-props'
@@ -1 +1 @@
1
- import{watch as e}from"@poveste/vendors/vue";import{getContext as t,onDestroy as n}from"svelte";import"svelte/internal/disclose-version";import"svelte/internal/flags/legacy";import*as r from"svelte/internal/client";var i=r.from_html(`<!> <!>`,1);function a(a,o){let s=r.sanitize_slots(o);r.push(o,!1);let c=t(`__pvtStory`),l=r.mutable_source(t(`__pvtVariant`)),u=t(`__pvtSlot`),d=t(`__pvtIndex`),f=c.variants[d.value];d.value++;let p=r.get(l).id===f.id,m=r.prop(o,`source`,8,null),h=r.prop(o,`initState`,8,null);h()&&p&&!r.get(l).__pvtStateSeeded&&(r.mutate(l,r.get(l).__pvtStateSeeded=!0),r.mutate(l,r.get(l).state={...r.get(l).state,...h()()})),!h()&&p&&u===`controls`&&s.controls&&!r.get(l).__pvtStateWarned&&(r.mutate(l,r.get(l).__pvtStateWarned=!0),console.error([`[poveste] Variant "${f?.title??f?.id}" has a controls slot but no \`initState\`.`,`Controls will render and appear to work, but their edits cannot reach the story:`,`the story is mounted separately for each slot, so a component-local variable is not shared.`,"Pass `initState` and read state from the snippet —",`https://poveste.dev/guide/migration-from-histoire.html#svelte-story-state-moves-to-initstate`].join(` `)));let g=r.mutable_source(r.get(l)?.state),_=()=>{};p&&(_=e(()=>r.get(l)?.state,()=>{r.set(g,r.get(l)?.state)},{deep:!0})),n(()=>_()),r.legacy_pre_effect(()=>(r.deep_read_state(m()),r.get(g),r.get(l)),()=>{if(p){let e=typeof m()==`function`?m()(r.get(g)):m();e!=null&&Object.assign(r.get(l),{source:e})}}),r.legacy_pre_effect_reset(),r.init();var v=r.comment(),y=r.first_child(v),b=e=>{var t=i(),n=r.first_child(t),a=e=>{var t=r.comment(),n=r.first_child(t);r.slot(n,o,`default`,{get state(){return r.get(g)}},null),r.append(e,t)};r.if(n,e=>{u==="default"&&e(a)});var s=r.sibling(n,2),c=e=>{var t=r.comment(),n=r.first_child(t);r.slot(n,o,`controls`,{get state(){return r.get(g)}},null),r.append(e,t)};r.if(s,e=>{u===`controls`&&e(c)}),r.append(e,t)};r.if(y,e=>{p&&e(b)}),r.append(a,v),r.pop()}export{a as default};
1
+ import{shouldWarnAboutMissingInitState as e}from"../util/init-state-warning.js";import{watch as t}from"@poveste/vendors/vue";import{getContext as n,onDestroy as r}from"svelte";import"svelte/internal/disclose-version";import"svelte/internal/flags/legacy";import*as i from"svelte/internal/client";var a=i.from_html(`<!> <!>`,1);function o(o,s){let c=i.sanitize_slots(s);i.push(s,!1);let l=n(`__pvtStory`),u=i.mutable_source(n(`__pvtVariant`)),d=n(`__pvtSlot`),f=n(`__pvtIndex`),p=l.variants[f.value];f.value++;let m=i.get(u).id===p.id,h=i.prop(s,`source`,8,null),g=i.prop(s,`initState`,8,null);g()&&m&&!i.get(u).__pvtStateSeeded&&(i.mutate(u,i.get(u).__pvtStateSeeded=!0),i.mutate(u,i.get(u).state={...i.get(u).state,...g()()})),m&&e({hasInitState:!!g(),seeded:!!i.get(u).__pvtStateSeeded,slotName:d,hasControlsSlot:!!c.controls,alreadyWarned:!!i.get(u).__pvtStateWarned})&&(i.mutate(u,i.get(u).__pvtStateWarned=!0),console.error([`[poveste] Variant "${p?.title??p?.id}" has a controls slot but no \`initState\`.`,`Controls will render and appear to work, but their edits cannot reach the story:`,`the story is mounted separately for each slot, so a component-local variable is not shared.`,"Pass `initState` and read state from the snippet —",`https://poveste.dev/guide/migration-from-histoire.html#svelte-story-state-moves-to-initstate`].join(` `)));let _=i.mutable_source(i.get(u)?.state),v=()=>{};m&&(v=t(()=>i.get(u)?.state,()=>{i.set(_,i.get(u)?.state)},{deep:!0})),r(()=>v()),i.legacy_pre_effect(()=>(i.deep_read_state(h()),i.get(_),i.get(u)),()=>{if(m){let e=typeof h()==`function`?h()(i.get(_)):h();e!=null&&Object.assign(i.get(u),{source:e})}}),i.legacy_pre_effect_reset(),i.init();var y=i.comment(),b=i.first_child(y),x=e=>{var t=a(),n=i.first_child(t),r=e=>{var t=i.comment(),n=i.first_child(t);i.slot(n,s,`default`,{get state(){return i.get(_)}},null),i.append(e,t)};i.if(n,e=>{d==="default"&&e(r)});var o=i.sibling(n,2),c=e=>{var t=i.comment(),n=i.first_child(t);i.slot(n,s,`controls`,{get state(){return i.get(_)}},null),i.append(e,t)};i.if(o,e=>{d===`controls`&&e(c)}),i.append(e,t)};i.if(b,e=>{m&&e(x)}),i.append(o,y),i.pop()}export{o as default};
@@ -1,6 +1,7 @@
1
1
  <script>
2
2
  import { watch as _watch } from '@poveste/vendors/vue'
3
3
  import { getContext, onDestroy } from 'svelte'
4
+ import { shouldWarnAboutMissingInitState } from '../util/init-state-warning.js'
4
5
 
5
6
  const story = getContext('__pvtStory')
6
7
  const currentVariant = getContext('__pvtVariant')
@@ -27,9 +28,13 @@
27
28
  currentVariant.state = { ...currentVariant.state, ...initState() }
28
29
  }
29
30
 
30
- // Controls mount only — see the note in RenderStory: the two mounts do not share
31
- // a realm for iframe layouts, so this is what keeps it to one warning.
32
- if (!initState && shouldRender && slotName === 'controls' && $$slots.controls && !currentVariant.__pvtStateWarned) {
31
+ if (shouldRender && shouldWarnAboutMissingInitState({
32
+ hasInitState: Boolean(initState),
33
+ seeded: Boolean(currentVariant.__pvtStateSeeded),
34
+ slotName,
35
+ hasControlsSlot: Boolean($$slots.controls),
36
+ alreadyWarned: Boolean(currentVariant.__pvtStateWarned),
37
+ })) {
33
38
  currentVariant.__pvtStateWarned = true
34
39
  console.error([
35
40
  `[poveste] Variant "${variant?.title ?? variant?.id}" has a controls slot but no \`initState\`.`,
@@ -0,0 +1,14 @@
1
+ import type { AutoPropComponentDefinition } from '@poveste/shared';
2
+ type Values = Record<string, any>[][];
3
+ /**
4
+ * Publishes what the panel renders (`_hPropDefs`) and returns a store of what
5
+ * the reader has set, which the rewritten markup spreads onto each component.
6
+ */
7
+ export declare function autoProps(defs: AutoPropComponentDefinition[][]): import("svelte/store").Readable<Values>;
8
+ /**
9
+ * Which variant's controls this realm answers to, or -1 for none. The story's
10
+ * own `autoPropsDisabled` is read directly, because story props reach only an
11
+ * implicit variant (#466).
12
+ */
13
+ export declare function variantToDrive(story: any, variant: any): number;
14
+ export {};
@@ -0,0 +1 @@
1
+ import{watch as e}from"@poveste/vendors/vue";import{readable as t}from"svelte/store";import{applyState as n}from"@poveste/shared";import{getContext as r}from"svelte";var i=Object.freeze({});function a(a){let l=r(`__pvtStory`),u=r(`__pvtVariant`),d=c(l,u),f=a.map(e=>Array.from({length:s(e)}).fill(i));if(d<0)return o(u),t(f);let p=a[d]??[],m=()=>{let e=f.slice();return e[d]=Array.from({length:s(p)},(e,t)=>u.state?._hPropState?.[t]??i),e},h=null;function g(){!u.state||h===u.state&&u.state._hPropDefs?.length||(h=u.state,n(u.state,{_hPropDefs:p}),u.state._hPropState||n(u.state,{_hPropState:{}}))}return t(m(),t=>{let n=()=>{g(),t(m())};n();let r=[e(()=>u.state,n),e(()=>u.state?._hPropState,n,{deep:!0}),e(()=>u.state?._hPropDefs?.length,n)];return()=>r.forEach(e=>e())})}function o(e){e?.state?._hPropDefs?.length&&n(e.state,{_hPropDefs:[]})}function s(e){return e.reduce((e,t)=>Math.max(e,t.index+1),0)}function c(e,t){return!e||!t||e.autoPropsDisabled||t.autoPropsDisabled?-1:e.variants?.findIndex(e=>e.id===t.id)??-1}export{a as autoProps,c as variantToDrive};
@@ -1 +1 @@
1
- import{getContext as e,setContext as t}from"svelte";import"svelte/internal/disclose-version";import"svelte/internal/flags/legacy";import*as n from"svelte/internal/client";function r(r,i){n.push(i,!1);let a=n.prop(i,`title`,8,null),o=n.prop(i,`id`,8,null),s=n.prop(i,`group`,8,null),c=n.prop(i,`layout`,8,null),l=n.prop(i,`icon`,8,null),u=n.prop(i,`iconColor`,8,null),d=n.prop(i,`docsOnly`,8,!1),f=n.prop(i,`initState`,8,null),p=e(`__pvtAddStory`),m=e(`__pvtStoryFile`),h={id:o()??m.id,title:a()??m.fileName,group:s(),layout:c(),icon:l(),iconColor:u(),docsOnly:d(),variants:[]};p(h);let g=f()?f()():{};t(`__pvtStory`,h),t(`__pvtAddVariant`,e=>{h.variants.push(e)}),n.init();var _=n.comment(),v=n.first_child(_);n.slot(v,i,`default`,{get state(){return g}},null),n.append(r,_),n.pop()}export{r as default};
1
+ import{getContext as e,setContext as t}from"svelte";import"svelte/internal/disclose-version";import"svelte/internal/flags/legacy";import*as n from"svelte/internal/client";function r(r,i){n.push(i,!1);let a=n.prop(i,`title`,8,null),o=n.prop(i,`id`,8,null),s=n.prop(i,`group`,8,null),c=n.prop(i,`layout`,8,null),l=n.prop(i,`icon`,8,null),u=n.prop(i,`iconColor`,8,null),d=n.prop(i,`docsOnly`,8,!1),f=n.prop(i,`autoPropsDisabled`,8,null),p=n.prop(i,`initState`,8,null),m=e(`__pvtAddStory`),h=e(`__pvtStoryFile`),g={id:o()??h.id,title:a()??h.fileName,group:s(),layout:c(),icon:l(),iconColor:u(),docsOnly:d(),autoPropsDisabled:f(),variants:[]};m(g);let _=p()?p()():{};t(`__pvtStory`,g),t(`__pvtAddVariant`,e=>{g.variants.push(e)}),n.init();var v=n.comment(),y=n.first_child(v);n.slot(y,i,`default`,{get state(){return _}},null),n.append(r,v),n.pop()}export{r as default};
@@ -8,6 +8,7 @@
8
8
  export let icon = null
9
9
  export let iconColor = null
10
10
  export let docsOnly = false
11
+ export let autoPropsDisabled = null
11
12
  export let initState = null
12
13
 
13
14
  const addStory = getContext('__pvtAddStory')
@@ -21,6 +22,10 @@
21
22
  icon,
22
23
  iconColor,
23
24
  docsOnly,
25
+ // Collected rather than inherited down: `MountStory` forwards story props
26
+ // only to an implicit variant (#466). Left undefined when unset, or the
27
+ // collector's `??=` merge could never apply `defaultStoryProps`.
28
+ autoPropsDisabled,
24
29
  variants: [],
25
30
  }
26
31
 
@@ -3,13 +3,16 @@ 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 { svelteAutoProps } from './util/auto-props-vite.js';
7
+ import { svelteKitAssetsDir } from './util/kit-assets.js';
6
8
  import { listComponentFiles } from './util/list-components.js';
7
9
  import { disableStoryComponentHmr } from './util/story-hmr.js';
8
10
  export function HstSvelte() {
9
11
  return {
10
12
  name: '@poveste/plugin-svelte',
11
- defaultConfig() {
13
+ async defaultConfig() {
12
14
  const svelteClientAliases = getSvelteClientAliases();
15
+ const publicDir = await svelteKitAssetsDir(process.cwd());
13
16
  return {
14
17
  supportMatch: [
15
18
  {
@@ -31,20 +34,14 @@ export function HstSvelte() {
31
34
  viteIgnorePlugins: [
32
35
  'vite-plugin-sveltekit-compile',
33
36
  ],
34
- vite: svelteClientAliases.length
35
- ? {
36
- plugins: [
37
- disableStoryComponentHmr(),
38
- ],
39
- resolve: {
40
- alias: svelteClientAliases,
41
- },
42
- }
43
- : {
44
- plugins: [
45
- disableStoryComponentHmr(),
46
- ],
47
- },
37
+ vite: {
38
+ plugins: [
39
+ svelteAutoProps(),
40
+ disableStoryComponentHmr(),
41
+ ],
42
+ ...svelteClientAliases.length ? { resolve: { alias: svelteClientAliases } } : {},
43
+ ...publicDir ? { publicDir } : {},
44
+ },
48
45
  };
49
46
  },
50
47
  supportPlugin: {
@@ -0,0 +1,9 @@
1
+ /** Runs before `vite-plugin-svelte`: after it compiles there is nothing left to rewrite (#233). */
2
+ export declare function svelteAutoProps(): {
3
+ name: string;
4
+ enforce: "pre";
5
+ transform(this: any, code: string, id: string): Promise<{
6
+ code: string;
7
+ map: any;
8
+ }>;
9
+ };
@@ -0,0 +1,38 @@
1
+ import { readFile } from 'node:fs/promises';
2
+ import { transformStoryAutoProps } from './auto-props.js';
3
+ import { extractPropDefs } from './props.js';
4
+ // Cheaper than a parse, and unlike a filename pattern it cannot go blind when a
5
+ // project narrows `storyMatch`.
6
+ const RENDERS_A_STORY = '<Hst.Story';
7
+ const IMPORTS_A_COMPONENT = /from\s*['"][^'"]*\.svelte['"]/;
8
+ /** Runs before `vite-plugin-svelte`: after it compiles there is nothing left to rewrite (#233). */
9
+ export function svelteAutoProps() {
10
+ return {
11
+ name: 'poveste:svelte-auto-props',
12
+ enforce: 'pre',
13
+ async transform(code, id) {
14
+ if (!id.split('?')[0].endsWith('.svelte') || !code.includes(RENDERS_A_STORY) || !IMPORTS_A_COMPONENT.test(code)) {
15
+ return undefined;
16
+ }
17
+ const result = await transformStoryAutoProps(code, async (specifier) => {
18
+ const resolved = await this.resolve(specifier, id);
19
+ if (!resolved) {
20
+ return undefined;
21
+ }
22
+ const file = resolved.id.split('?')[0];
23
+ // Baked into this module, so Vite has to know it depends on the source.
24
+ this.addWatchFile(file);
25
+ try {
26
+ return extractPropDefs(await readFile(file, 'utf8'));
27
+ }
28
+ catch (error) {
29
+ // Not a failed build, but silence would look like a component that
30
+ // declares nothing.
31
+ this.warn(`[poveste] could not read props from ${file}: ${error.message}`);
32
+ return undefined;
33
+ }
34
+ });
35
+ return result === undefined ? undefined : { code: result, map: null };
36
+ },
37
+ };
38
+ }
@@ -0,0 +1,9 @@
1
+ import type { PropDefinition } from '@poveste/shared';
2
+ /** Resolves an import specifier from the story file to that component's props. */
3
+ export type PropsOf = (specifier: string) => Promise<PropDefinition[] | undefined>;
4
+ export declare function parseable(code: string): string;
5
+ /**
6
+ * Every component a variant renders takes a spread of the values its controls
7
+ * hold; the props on offer come from the component's own source (#233).
8
+ */
9
+ export declare function transformStoryAutoProps(code: string, propsOf: PropsOf): Promise<string | undefined>;
@@ -0,0 +1,230 @@
1
+ import { parse } from 'svelte/compiler';
2
+ const RUNTIME = '@poveste/plugin-svelte/auto-props';
3
+ const HOLDER = '__pvtAutoProps';
4
+ const TABLE = '__pvtAutoPropDefs';
5
+ const HST = 'Hst';
6
+ // Line-anchored and newline-free, or a match runs past its own line and blanks
7
+ // the import below it. A trailing line comment is tolerated for the same reason.
8
+ const TYPE_IMPORT = /^[ \t]*import\s+type\b[^;\n]+?\bfrom[ \t]*(['"])[^'"\n]*\1(?:[ \t]*;)?[ \t]*(?:\/\/[^\n]*)?$/gm;
9
+ const IMPORT_BRACES = /\bimport\s*\{[^}]*\}/g;
10
+ const INLINE_TYPE = /\btype\s+[A-Za-z_$][\w$]*(?:\s+as\s+[A-Za-z_$][\w$]*)?\s*,?/g;
11
+ // Blanked rather than removed, so every offset still points at the same
12
+ // character. `import type { Hst }` beside `export let Hst: Hst` reads as a
13
+ // duplicate declaration.
14
+ export function parseable(code) {
15
+ return code
16
+ .replace(TYPE_IMPORT, blank)
17
+ .replace(IMPORT_BRACES, braces => braces.replace(INLINE_TYPE, blank));
18
+ }
19
+ function blank(match) {
20
+ return ' '.repeat(match.length);
21
+ }
22
+ /**
23
+ * Every component a variant renders takes a spread of the values its controls
24
+ * hold; the props on offer come from the component's own source (#233).
25
+ */
26
+ export async function transformStoryAutoProps(code, propsOf) {
27
+ // Applied twice, the second preamble redeclares the first.
28
+ if (code.includes(RUNTIME)) {
29
+ return undefined;
30
+ }
31
+ let ast;
32
+ try {
33
+ ast = parse(parseable(code), { modern: true });
34
+ }
35
+ catch {
36
+ return undefined;
37
+ }
38
+ const instance = ast.instance;
39
+ if (!instance) {
40
+ return undefined;
41
+ }
42
+ const imported = importedComponents(instance.content?.body ?? []);
43
+ if (imported.size === 0) {
44
+ return undefined;
45
+ }
46
+ const targets = collectTargets(ast.fragment, imported);
47
+ if (targets === undefined || targets.length === 0) {
48
+ return undefined;
49
+ }
50
+ const defs = await describe(targets, propsOf);
51
+ if (defs.every(perVariant => perVariant.length === 0)) {
52
+ return undefined;
53
+ }
54
+ return splice(code, instance.content.start, targets, defs);
55
+ }
56
+ // The only shape a component can arrive in.
57
+ function importedComponents(body) {
58
+ const found = new Map();
59
+ for (const node of body) {
60
+ if (node.type !== 'ImportDeclaration' || !/\.svelte$/.test(node.source?.value ?? '')) {
61
+ continue;
62
+ }
63
+ for (const specifier of node.specifiers ?? []) {
64
+ if (specifier.type === 'ImportDefaultSpecifier') {
65
+ found.set(specifier.local.name, node.source.value);
66
+ }
67
+ }
68
+ }
69
+ return found;
70
+ }
71
+ /**
72
+ * Numbered within its variant, which is how `_hPropState` is keyed. Undefined
73
+ * means "do not touch this story": a variant a block produces is registered in
74
+ * an order no static pass can predict.
75
+ */
76
+ function collectTargets(fragment, imported) {
77
+ const story = findStory(fragment);
78
+ if (!story || blockWrapsAVariant(story.fragment)) {
79
+ return undefined;
80
+ }
81
+ // At any depth and in document order, matching how `collect/Variant.svelte`
82
+ // registers them. A story with none renders its children as variant zero.
83
+ const variants = [];
84
+ walk(story.fragment, (node) => {
85
+ if (isHst(node, 'Variant')) {
86
+ variants.push(node);
87
+ }
88
+ });
89
+ const scopes = variants.length > 0 ? variants : [story];
90
+ const targets = [];
91
+ scopes.forEach((scope, variant) => {
92
+ let index = 0;
93
+ walk(scope.fragment, (node, ancestors) => {
94
+ // The controls snippet builds the panel, not the preview. A component in
95
+ // a block is skipped on its own.
96
+ if (isControls(node)) {
97
+ return false;
98
+ }
99
+ if (node.type !== 'Component' || insideBlock(ancestors)) {
100
+ return undefined;
101
+ }
102
+ const specifier = imported.get(node.name);
103
+ if (specifier) {
104
+ targets.push({ node, variant, index: index++, specifier });
105
+ }
106
+ return undefined;
107
+ });
108
+ });
109
+ return targets;
110
+ }
111
+ function findStory(fragment) {
112
+ let found;
113
+ walk(fragment, (node) => {
114
+ if (!found && isHst(node, 'Story')) {
115
+ found = node;
116
+ }
117
+ });
118
+ return found;
119
+ }
120
+ function blockWrapsAVariant(fragment) {
121
+ let wrapped = false;
122
+ walk(fragment, (node, ancestors) => {
123
+ if (isHst(node, 'Variant') && insideBlock(ancestors)) {
124
+ wrapped = true;
125
+ }
126
+ });
127
+ return wrapped;
128
+ }
129
+ function insideBlock(ancestors) {
130
+ return ancestors.some(ancestor => ancestor.type.endsWith('Block') && ancestor.type !== 'SnippetBlock');
131
+ }
132
+ function isControls(node) {
133
+ if (node.type === 'SnippetBlock') {
134
+ return node.expression?.name === 'controls';
135
+ }
136
+ return (node.attributes ?? []).some((attribute) => attribute.name === 'slot' && attribute.value?.[0]?.data === 'controls');
137
+ }
138
+ function isHst(node, member) {
139
+ return node.type === 'Component' && (member ? node.name === `${HST}.${member}` : node.name?.startsWith(`${HST}.`));
140
+ }
141
+ // `then`/`catch`/`pending` and `fallback` are branches a variant can hide in.
142
+ const CHILD_KEYS = ['fragment', 'nodes', 'children', 'body', 'consequent', 'alternate', 'pending', 'then', 'catch', 'fallback'];
143
+ function walk(node, visit, ancestors = []) {
144
+ if (!node || typeof node !== 'object') {
145
+ return;
146
+ }
147
+ if (Array.isArray(node)) {
148
+ for (const child of node)
149
+ walk(child, visit, ancestors);
150
+ return;
151
+ }
152
+ if (typeof node.type === 'string') {
153
+ if (visit(node, ancestors) === false) {
154
+ return;
155
+ }
156
+ ancestors = [...ancestors, node];
157
+ }
158
+ for (const key of CHILD_KEYS) {
159
+ if (node[key])
160
+ walk(node[key], visit, ancestors);
161
+ }
162
+ }
163
+ async function describe(targets, propsOf) {
164
+ const sources = new Map();
165
+ for (const target of targets) {
166
+ if (!sources.has(target.specifier)) {
167
+ sources.set(target.specifier, propsOf(target.specifier));
168
+ }
169
+ }
170
+ const size = targets.reduce((max, target) => Math.max(max, target.variant + 1), 0);
171
+ const defs = Array.from({ length: size }, () => []);
172
+ for (const target of targets) {
173
+ const props = await sources.get(target.specifier);
174
+ if (props?.length) {
175
+ defs[target.variant].push({ name: target.node.name, index: target.index, props });
176
+ }
177
+ }
178
+ return defs;
179
+ }
180
+ /**
181
+ * Back to front, so an earlier insertion cannot move a later offset. The spread
182
+ * goes after the last attribute: last wins, so a prop the story binds keeps
183
+ * winning until the reader touches that control.
184
+ */
185
+ function splice(code, scriptStart, targets, defs) {
186
+ const driven = new Set(defs.flatMap((perVariant, variant) => perVariant.map(def => `${variant}:${def.index}`)));
187
+ const edits = targets
188
+ .filter(target => driven.has(`${target.variant}:${target.index}`))
189
+ .map(target => ({
190
+ at: attributesEnd(code, target.node),
191
+ text: ` {...$${HOLDER}[${target.variant}][${target.index}]}`,
192
+ }));
193
+ // One line, at the end of the `<script>` line, so nothing the story wrote moves.
194
+ edits.push({ at: scriptStart, text: preamble(defs) });
195
+ let result = code;
196
+ for (const edit of edits.sort((a, b) => b.at - a.at)) {
197
+ result = result.slice(0, edit.at) + edit.text + result.slice(edit.at);
198
+ }
199
+ return result;
200
+ }
201
+ // Serialised once and referenced, or a hundred-variant story carries a hundred
202
+ // copies of the same prop table.
203
+ function preamble(defs) {
204
+ const table = [];
205
+ const entries = defs.map(perVariant => perVariant.map((def) => {
206
+ const serialised = literal({ name: def.name, props: def.props });
207
+ let slot = table.indexOf(serialised);
208
+ if (slot < 0) {
209
+ slot = table.push(serialised) - 1;
210
+ }
211
+ return `{...${TABLE}[${slot}],index:${def.index}}`;
212
+ }));
213
+ return `import { autoProps as __pvtCreateAutoProps } from '${RUNTIME}';`
214
+ + `const ${TABLE} = [${table.join(',')}];`
215
+ + `const ${HOLDER} = __pvtCreateAutoProps([${entries.map(perVariant => `[${perVariant.join(',')}]`).join(',')}]);`;
216
+ }
217
+ // `</script>` in a default would close the block this lands in.
218
+ function literal(value) {
219
+ return JSON.stringify(value)
220
+ .replace(/</g, '\\u003C')
221
+ .replace(/\u2028/g, '\\u2028')
222
+ .replace(/\u2029/g, '\\u2029');
223
+ }
224
+ function attributesEnd(code, node) {
225
+ const attributes = node.attributes ?? [];
226
+ if (attributes.length > 0) {
227
+ return Math.max(...attributes.map((attribute) => attribute.end));
228
+ }
229
+ return code.indexOf(node.name, node.start) + node.name.length;
230
+ }
@@ -0,0 +1,20 @@
1
+ /**
2
+ * Whether to warn that a variant has controls but no `initState`.
3
+ *
4
+ * The warning is real — a controls snippet with no seeded state renders controls
5
+ * whose edits cannot reach the story, because the story is mounted separately per
6
+ * slot. But it fired on stories where the state *is* seeded, from `initState` on
7
+ * the `Story` rather than the `Variant`, and told their authors that working
8
+ * controls were broken (#462).
9
+ *
10
+ * `seeded` is the signal it was missing: `RenderStory` sets `__pvtStateSeeded`
11
+ * when it applies a story-level `initState`, on the same object this already
12
+ * holds. Only the variant-level check was wrong; the story-level one is correct.
13
+ */
14
+ export declare function shouldWarnAboutMissingInitState(options: {
15
+ hasInitState: boolean;
16
+ seeded: boolean;
17
+ slotName: string;
18
+ hasControlsSlot: boolean;
19
+ alreadyWarned: boolean;
20
+ }): boolean;
@@ -0,0 +1,22 @@
1
+ /**
2
+ * Whether to warn that a variant has controls but no `initState`.
3
+ *
4
+ * The warning is real — a controls snippet with no seeded state renders controls
5
+ * whose edits cannot reach the story, because the story is mounted separately per
6
+ * slot. But it fired on stories where the state *is* seeded, from `initState` on
7
+ * the `Story` rather than the `Variant`, and told their authors that working
8
+ * controls were broken (#462).
9
+ *
10
+ * `seeded` is the signal it was missing: `RenderStory` sets `__pvtStateSeeded`
11
+ * when it applies a story-level `initState`, on the same object this already
12
+ * holds. Only the variant-level check was wrong; the story-level one is correct.
13
+ */
14
+ export function shouldWarnAboutMissingInitState(options) {
15
+ const { hasInitState, seeded, slotName, hasControlsSlot, alreadyWarned } = options;
16
+ if (hasInitState || seeded || alreadyWarned) {
17
+ return false;
18
+ }
19
+ // Controls mount only: the two mounts do not share a realm for iframe layouts,
20
+ // so this is what keeps it to one warning.
21
+ return slotName === 'controls' && hasControlsSlot;
22
+ }
@@ -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,10 +1,11 @@
1
1
  {
2
2
  "name": "@poveste/plugin-svelte",
3
3
  "type": "module",
4
- "version": "0.10.0",
4
+ "version": "0.12.0",
5
5
  "description": "Poveste plugin for Svelte 5 and SvelteKit support",
6
6
  "author": {
7
- "name": "Sorin Gitlan"
7
+ "name": "Sorin Gitlan",
8
+ "url": "https://x.com/50rayn"
8
9
  },
9
10
  "license": "MIT",
10
11
  "homepage": "https://poveste.dev",
@@ -41,6 +42,10 @@
41
42
  "types": "./dist/collect/index.d.ts",
42
43
  "default": "./dist/collect/index.js"
43
44
  },
45
+ "./auto-props": {
46
+ "types": "./dist/client/auto-props.d.ts",
47
+ "default": "./dist/client/auto-props.js"
48
+ },
44
49
  ".": "./dist/index.node.js",
45
50
  "./*": "./*"
46
51
  },
@@ -49,6 +54,7 @@
49
54
  "types": "./dist/index.node.d.ts",
50
55
  "files": [
51
56
  "assets",
57
+ "auto-props.d.ts",
52
58
  "client.d.ts",
53
59
  "collect.d.ts",
54
60
  "dist"
@@ -59,8 +65,8 @@
59
65
  "peerDependencies": {
60
66
  "@sveltejs/kit": "^2.53.0",
61
67
  "@sveltejs/vite-plugin-svelte": "^7.0.0",
62
- "svelte": "^5.46.4",
63
- "poveste": "^0.10.0"
68
+ "poveste": "^0.12.0",
69
+ "svelte": "^5.46.4"
64
70
  },
65
71
  "peerDependenciesMeta": {
66
72
  "@sveltejs/kit": {
@@ -68,13 +74,13 @@
68
74
  }
69
75
  },
70
76
  "dependencies": {
77
+ "@poveste/controls": "^0.12.0",
78
+ "@poveste/shared": "^0.12.0",
79
+ "@poveste/vendors": "^0.12.0",
71
80
  "change-case": "^5.4.4",
72
81
  "globby": "^14.0.2",
73
82
  "launch-editor": "^2.9.1",
74
- "pathe": "^1.1.2",
75
- "@poveste/controls": "^0.10.0",
76
- "@poveste/shared": "^0.10.0",
77
- "@poveste/vendors": "^0.10.0"
83
+ "pathe": "^1.1.2"
78
84
  },
79
85
  "devDependencies": {
80
86
  "@sveltejs/vite-plugin-svelte": "^7.0.0",
@@ -82,15 +88,18 @@
82
88
  "concurrently": "^9.1.0",
83
89
  "fs-extra": "^11.2.0",
84
90
  "globby": "^14.0.2",
91
+ "poveste": "0.12.0",
85
92
  "svelte": "5.55.0",
86
93
  "svelte-preprocess": "^6.0.3",
87
94
  "typescript": "5.6.3",
88
95
  "vite": "^8.0.0",
89
- "poveste": "0.10.0"
96
+ "vitest": "^4.1.10"
90
97
  },
91
98
  "scripts": {
92
99
  "build": "rimraf dist && vite build && tsc -d -P tsconfig.build.json && pnpm run build:types",
93
100
  "build:types": "tsc --declaration --emitDeclarationOnly",
101
+ "test": "vitest run",
102
+ "test:dev": "vitest",
94
103
  "watch": "concurrently \"vite build --watch\" \"tsc -d -P tsconfig.build.json --watch\" \"pnpm run build:types --watch\""
95
104
  }
96
105
  }