@sit-onyx/storybook-utils 1.0.0-alpha.129 → 1.0.0-alpha.130

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/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@sit-onyx/storybook-utils",
3
3
  "description": "Storybook utilities for Vue",
4
- "version": "1.0.0-alpha.129",
4
+ "version": "1.0.0-alpha.130",
5
5
  "type": "module",
6
6
  "author": "Schwarz IT KG",
7
7
  "license": "Apache-2.0",
@@ -29,8 +29,8 @@
29
29
  "@storybook/theming": ">= 8.0.0",
30
30
  "@storybook/vue3": ">= 8.0.0",
31
31
  "storybook-dark-mode": ">= 4",
32
- "@sit-onyx/icons": "^0.1.0-alpha.1",
33
- "sit-onyx": "^1.0.0-alpha.126"
32
+ "sit-onyx": "^1.0.0-alpha.126",
33
+ "@sit-onyx/icons": "^0.1.0-alpha.1"
34
34
  },
35
35
  "dependencies": {
36
36
  "deepmerge-ts": "^7.0.3"
package/src/preview.ts CHANGED
@@ -154,6 +154,7 @@ export const sourceCodeTransformer = (
154
154
  Object.entries(ALL_ICONS).forEach(([iconName, iconContent]) => {
155
155
  const importName = getIconImportName(iconName);
156
156
  const singleQuotedIconContent = `'${replaceAll(iconContent, '"', "\\'")}'`;
157
+ const escapedIconContent = `"${replaceAll(iconContent, '"', '\\"')}"`;
157
158
 
158
159
  if (code.includes(iconContent)) {
159
160
  code = code.replace(new RegExp(` (\\S+)=['"]${iconContent}['"]`), ` :$1="${importName}"`);
@@ -162,18 +163,25 @@ export const sourceCodeTransformer = (
162
163
  // support icons inside objects
163
164
  code = code.replace(singleQuotedIconContent, importName);
164
165
  iconImports.push(`import ${importName} from "@sit-onyx/icons/${iconName}.svg?raw";`);
166
+ } else if (code.includes(escapedIconContent)) {
167
+ // support icons inside objects
168
+ code = code.replace(escapedIconContent, importName);
169
+ iconImports.push(`import ${importName} from "@sit-onyx/icons/${iconName}.svg?raw";`);
165
170
  }
166
171
  });
167
172
 
168
- if (iconImports.length > 0) {
169
- return `<script lang="ts" setup>
173
+ if (iconImports.length === 0) return code;
174
+
175
+ if (code.startsWith("<script")) {
176
+ const index = code.indexOf("\n");
177
+ return code.slice(0, index) + iconImports.join("\n") + "\n" + code.slice(index);
178
+ }
179
+
180
+ return `<script lang="ts" setup>
170
181
  ${iconImports.join("\n")}
171
182
  </script>
172
183
 
173
184
  ${code}`;
174
- }
175
-
176
- return code;
177
185
  };
178
186
 
179
187
  /**
@@ -6,13 +6,18 @@
6
6
  import { expect, test } from "vitest";
7
7
  import { h } from "vue";
8
8
  import {
9
- extractSlotNames,
10
9
  generatePropsSourceCode,
11
10
  generateSlotSourceCode,
11
+ generateSourceCode,
12
+ parseDocgenInfo,
13
+ type SourceCodeGeneratorContext,
12
14
  } from "./source-code-generator";
13
15
 
14
16
  test("should generate source code for props", () => {
15
- const slots = ["default", "testSlot"];
17
+ const ctx: SourceCodeGeneratorContext = {
18
+ scriptVariables: {},
19
+ imports: {},
20
+ };
16
21
 
17
22
  const code = generatePropsSourceCode(
18
23
  {
@@ -24,7 +29,7 @@ test("should generate source code for props", () => {
24
29
  f: [1, 2, 3],
25
30
  g: {
26
31
  g1: "foo",
27
- b2: 42,
32
+ g2: 42,
28
33
  },
29
34
  h: undefined,
30
35
  i: null,
@@ -32,15 +37,28 @@ test("should generate source code for props", () => {
32
37
  k: BigInt(9007199254740991),
33
38
  l: Symbol(),
34
39
  m: Symbol("foo"),
40
+ modelValue: "test-v-model",
41
+ otherModelValue: 42,
35
42
  default: "default slot",
36
43
  testSlot: "test slot",
37
44
  },
38
- slots,
45
+ ["default", "testSlot"],
46
+ ["update:modelValue", "update:otherModelValue"],
47
+ ctx,
39
48
  );
40
49
 
41
50
  expect(code).toBe(
42
- `a="foo" b='"I am double quoted"' :c="42" d :e="false" :f="[1,2,3]" :g="{'g1':'foo','b2':42}" :k="BigInt(9007199254740991)" :l="Symbol()" :m="Symbol('foo')"`,
51
+ `a="foo" b='"I am double quoted"' :c="42" d :e="false" :f="f" :g="g" :k="BigInt(9007199254740991)" :l="Symbol()" :m="Symbol('foo')" v-model="modelValue" v-model:otherModelValue="otherModelValue"`,
43
52
  );
53
+
54
+ expect(ctx.scriptVariables).toStrictEqual({
55
+ f: `[1,2,3]`,
56
+ g: `{"g1":"foo","g2":42}`,
57
+ modelValue: 'ref("test-v-model")',
58
+ otherModelValue: "ref(42)",
59
+ });
60
+
61
+ expect(Array.from(ctx.imports.vue.values())).toStrictEqual(["ref"]);
44
62
  });
45
63
 
46
64
  test("should generate source code for slots", () => {
@@ -111,7 +129,10 @@ child 2</template>
111
129
 
112
130
  <template #m>{{ BigInt(9007199254740991) }}</template>`;
113
131
 
114
- let actualCode = generateSlotSourceCode(slots, Object.keys(slots));
132
+ let actualCode = generateSlotSourceCode(slots, Object.keys(slots), {
133
+ scriptVariables: {},
134
+ imports: {},
135
+ });
115
136
  expect(actualCode).toBe(expectedCode);
116
137
 
117
138
  // should generate the same code if getters/functions are used to return the slot content
@@ -122,10 +143,70 @@ child 2</template>
122
143
  return obj;
123
144
  }, {});
124
145
 
125
- actualCode = generateSlotSourceCode(slotsWithGetters, Object.keys(slotsWithGetters));
146
+ actualCode = generateSlotSourceCode(slotsWithGetters, Object.keys(slotsWithGetters), {
147
+ scriptVariables: {},
148
+ imports: {},
149
+ });
126
150
  expect(actualCode).toBe(expectedCode);
127
151
  });
128
152
 
153
+ test("should generate source code for slots with bindings", () => {
154
+ type TestBindings = {
155
+ foo: string;
156
+ bar?: number;
157
+ };
158
+
159
+ const slots = {
160
+ a: ({ foo, bar }: TestBindings) => `Slot with bindings ${foo} and ${bar}`,
161
+ b: ({ foo }: TestBindings) => h("a", { href: foo, target: foo }, `Test link: ${foo}`),
162
+ };
163
+
164
+ const expectedCode = `<template #a="{ foo, bar }">Slot with bindings {{ foo }} and {{ bar }}</template>
165
+
166
+ <template #b="{ foo }"><a :href="foo" :target="foo">Test link: {{ foo }}</a></template>`;
167
+
168
+ const actualCode = generateSlotSourceCode(slots, Object.keys(slots), {
169
+ imports: {},
170
+ scriptVariables: {},
171
+ });
172
+ expect(actualCode).toBe(expectedCode);
173
+ });
174
+
175
+ test("should generate source code with <script setup> block", () => {
176
+ const actualCode = generateSourceCode({
177
+ title: "MyComponent",
178
+ component: {
179
+ __docgenInfo: {
180
+ slots: [{ name: "mySlot" }],
181
+ events: [{ name: "update:c" }],
182
+ },
183
+ },
184
+ args: {
185
+ a: 42,
186
+ b: "foo",
187
+ c: [1, 2, 3],
188
+ d: { bar: "baz" },
189
+ mySlot: () => h("div", { test: [1, 2], d: { nestedProp: "foo" } }),
190
+ },
191
+ });
192
+
193
+ expect(actualCode).toBe(`<script lang="ts" setup>
194
+ import { ref } from "vue";
195
+
196
+ const c = ref([1,2,3]);
197
+
198
+ const d = {"bar":"baz"};
199
+
200
+ const d1 = {"nestedProp":"foo"};
201
+
202
+ const test = [1,2];
203
+ </script>
204
+
205
+ <template>
206
+ <MyComponent :a="42" b="foo" v-model:c="c" :d="d"> <template #mySlot><div :d="d1" :test="test" /></template> </MyComponent>
207
+ </template>`);
208
+ });
209
+
129
210
  test.each([
130
211
  { __docgenInfo: "invalid-value", slotNames: [] },
131
212
  { __docgenInfo: {}, slotNames: [] },
@@ -135,7 +216,21 @@ test.each([
135
216
  __docgenInfo: { slots: [{ name: "slot-1" }, { name: "slot-2" }, { notName: "slot-3" }] },
136
217
  slotNames: ["slot-1", "slot-2"],
137
218
  },
138
- ])("should extract slots names from __docgenInfo", ({ __docgenInfo, slotNames }) => {
139
- const actualNames = extractSlotNames({ __docgenInfo });
140
- expect(actualNames).toStrictEqual(slotNames);
219
+ ])("should parse slots names from __docgenInfo", ({ __docgenInfo, slotNames }) => {
220
+ const docgenInfo = parseDocgenInfo({ __docgenInfo });
221
+ expect(docgenInfo.slotNames).toStrictEqual(slotNames);
222
+ });
223
+
224
+ test.each([
225
+ { __docgenInfo: "invalid-value", eventNames: [] },
226
+ { __docgenInfo: {}, eventNames: [] },
227
+ { __docgenInfo: { events: "invalid-value" }, eventNames: [] },
228
+ { __docgenInfo: { events: ["invalid-value"] }, eventNames: [] },
229
+ {
230
+ __docgenInfo: { events: [{ name: "event-1" }, { name: "event-2" }, { notName: "event-3" }] },
231
+ eventNames: ["event-1", "event-2"],
232
+ },
233
+ ])("should parse event names from __docgenInfo", ({ __docgenInfo, eventNames }) => {
234
+ const docgenInfo = parseDocgenInfo({ __docgenInfo });
235
+ expect(docgenInfo.eventNames).toStrictEqual(eventNames);
141
236
  });
@@ -5,33 +5,71 @@
5
5
  //
6
6
  import { SourceType } from "@storybook/docs-tools";
7
7
  import type { Args, StoryContext } from "@storybook/vue3";
8
- import type { VNode } from "vue";
9
- import { isVNode } from "vue";
8
+ import { isVNode, type VNode } from "vue";
10
9
  import { replaceAll } from "./preview";
11
10
 
11
+ /**
12
+ * Context that is passed down to nested components/slots when generating the source code for a single story.
13
+ */
14
+ export type SourceCodeGeneratorContext = {
15
+ /**
16
+ * Properties/variables that should be placed inside a `<script lang="ts" setup>` block.
17
+ * Usually contains complex property values like objects and arrays.
18
+ */
19
+ scriptVariables: Record<string, string>;
20
+ /**
21
+ * Optional imports to add inside the `<script lang="ts" setup>` block.
22
+ * e.g. to add 'import { ref } from "vue";'
23
+ *
24
+ * key = package name, values = imports
25
+ */
26
+ imports: Record<string, Set<string>>;
27
+ };
28
+
12
29
  /**
13
30
  * Generate Vue source code for the given Story.
14
31
  * @returns Source code or empty string if source code could not be generated.
15
32
  */
16
33
  export const generateSourceCode = (
17
- ctx: Pick<StoryContext, "title" | "component" | "args">,
34
+ ctx: Pick<StoryContext, "title" | "component" | "args"> & {
35
+ component?: StoryContext["component"] & { __docgenInfo?: unknown };
36
+ },
18
37
  ): string => {
19
- const componentName = ctx.component?.__name || ctx.title.split("/").at(-1)!;
38
+ const sourceCodeContext: SourceCodeGeneratorContext = {
39
+ imports: {},
40
+ scriptVariables: {},
41
+ };
20
42
 
21
- const slotNames = extractSlotNames(ctx.component);
22
- const slotSourceCode = generateSlotSourceCode(ctx.args, slotNames);
23
- const propsSourceCode = generatePropsSourceCode(ctx.args, slotNames);
43
+ const { displayName, slotNames, eventNames } = parseDocgenInfo(ctx.component);
24
44
 
25
- if (slotSourceCode) {
26
- return `<template>
27
- <${componentName} ${propsSourceCode}> ${slotSourceCode} </${componentName}>
28
- </template>`;
29
- }
45
+ const props = generatePropsSourceCode(ctx.args, slotNames, eventNames, sourceCodeContext);
46
+ const slotSourceCode = generateSlotSourceCode(ctx.args, slotNames, sourceCodeContext);
47
+ const componentName = displayName || ctx.title.split("/").at(-1)!;
30
48
 
31
49
  // prefer self closing tag if no slot content exists
32
- return `<template>
33
- <${componentName} ${propsSourceCode} />
34
- </template>`;
50
+ const templateCode = slotSourceCode
51
+ ? `<${componentName} ${props}> ${slotSourceCode} </${componentName}>`
52
+ : `<${componentName} ${props} />`;
53
+
54
+ const variablesCode = Object.entries(sourceCodeContext.scriptVariables)
55
+ .map(([name, value]) => `const ${name} = ${value};`)
56
+ .join("\n\n");
57
+
58
+ const importsCode = Object.entries(sourceCodeContext.imports)
59
+ .map(([packageName, imports]) => {
60
+ return `import { ${Array.from(imports.values()).sort().join(", ")} } from "${packageName}";`;
61
+ })
62
+ .join("\n");
63
+
64
+ const template = `<template>\n ${templateCode}\n</template>`;
65
+
66
+ if (!importsCode && !variablesCode) return template;
67
+
68
+ return `<script lang="ts" setup>
69
+ ${importsCode ? `${importsCode}\n\n${variablesCode}` : variablesCode}
70
+ </script>
71
+
72
+ ${template}`;
35
73
  };
36
74
 
37
75
  /**
@@ -60,79 +98,138 @@ export const shouldSkipSourceCodeGeneration = (context: StoryContext): boolean =
60
98
  };
61
99
 
62
100
  /**
63
- * Gets all slot names from the `__docgenInfo` of the given component if available.
101
+ * Parses the __docgenInfo of the given component.
64
102
  * Requires Storybook docs addon to be enabled.
65
103
  * Default slot will always be sorted first, remaining slots are sorted alphabetically.
66
104
  */
67
- export const extractSlotNames = (
105
+ export const parseDocgenInfo = (
68
106
  component?: StoryContext["component"] & { __docgenInfo?: unknown },
69
- ): string[] => {
70
- if (!component || !("__docgenInfo" in component)) return [];
71
-
107
+ ) => {
72
108
  // type check __docgenInfo to prevent errors
73
- if (!component.__docgenInfo || typeof component.__docgenInfo !== "object") return [];
74
109
  if (
75
- !("slots" in component.__docgenInfo) ||
76
- !component.__docgenInfo.slots ||
77
- !Array.isArray(component.__docgenInfo.slots)
110
+ !component ||
111
+ !("__docgenInfo" in component) ||
112
+ !component.__docgenInfo ||
113
+ typeof component.__docgenInfo !== "object"
78
114
  ) {
79
- return [];
115
+ return {
116
+ displayName: component?.__name,
117
+ eventNames: [],
118
+ slotNames: [],
119
+ };
80
120
  }
81
121
 
82
- return component.__docgenInfo.slots
83
- .map((slot) => slot.name)
84
- .filter((i): i is string => typeof i === "string")
85
- .sort((a, b) => {
122
+ const docgenInfo = component.__docgenInfo as Record<string, unknown>;
123
+
124
+ const displayName =
125
+ "displayName" in docgenInfo && typeof docgenInfo.displayName === "string"
126
+ ? docgenInfo.displayName
127
+ : undefined;
128
+
129
+ const parseNames = (key: "slots" | "events") => {
130
+ if (!(key in docgenInfo) || !Array.isArray(docgenInfo[key])) return [];
131
+
132
+ const values = docgenInfo[key] as unknown[];
133
+
134
+ return values
135
+ .map((i) => (i && typeof i === "object" && "name" in i ? i.name : undefined))
136
+ .filter((i): i is string => typeof i === "string");
137
+ };
138
+
139
+ return {
140
+ displayName: displayName || component.__name,
141
+ slotNames: parseNames("slots").sort((a, b) => {
86
142
  if (a === "default") return -1;
87
143
  if (b === "default") return 1;
88
144
  return a.localeCompare(b);
89
- });
145
+ }),
146
+ eventNames: parseNames("events"),
147
+ };
90
148
  };
91
149
 
92
150
  /**
93
151
  * Generates the source code for the given Vue component properties.
152
+ * Props with complex values (objects and arrays) and v-models will be added to the ctx.scriptVariables because they should be
153
+ * generated in a `<script lang="ts" setup>` block.
94
154
  *
95
155
  * @param args Story args / property values.
96
156
  * @param slotNames All slot names of the component. Needed to not generate code for args that are slots.
97
- * Can be extracted using `extractSlotNames()`.
157
+ * Can be extracted using `parseDocgenInfo()`.
158
+ * @param eventNames All event names of the component. Needed to generate v-model properties. Can be extracted using `parseDocgenInfo()`.
159
+ *
160
+ * @example `:a="42" b="Hello World" v-model="modelValue" v-model:search="search"`
98
161
  */
99
162
  export const generatePropsSourceCode = (
100
163
  args: Record<string, unknown>,
101
164
  slotNames: string[],
102
- ): string => {
103
- const props: string[] = [];
165
+ eventNames: string[],
166
+ ctx: SourceCodeGeneratorContext,
167
+ ) => {
168
+ type Property = {
169
+ /** Property name */
170
+ name: string;
171
+ /** Stringified property value */
172
+ value: string;
173
+ /**
174
+ * Function that returns the source code when used inside the `<template>`.
175
+ * If unset, the property will be generated inside the `<script lang="ts" setup>` block.
176
+ */
177
+ templateFn?: (name: string, value: string) => string;
178
+ };
179
+
180
+ const properties: Property[] = [];
104
181
 
105
182
  Object.entries(args).forEach(([propName, value]) => {
106
183
  // ignore slots
107
184
  if (slotNames.includes(propName)) return;
185
+ if (value == undefined) return; // do not render undefined/null values
108
186
 
109
187
  switch (typeof value) {
110
188
  case "string":
111
189
  if (value === "") return; // do not render empty strings
112
190
 
113
- if (value.includes('"')) {
114
- props.push(`${propName}='${value}'`);
115
- } else {
116
- props.push(`${propName}="${value}"`);
117
- }
118
-
191
+ properties.push({
192
+ name: propName,
193
+ value: value.includes('"') ? `'${value}'` : `"${value}"`,
194
+ templateFn: (name, propValue) => `${name}=${propValue}`,
195
+ });
119
196
  break;
120
197
  case "number":
121
- props.push(`:${propName}="${value}"`);
198
+ properties.push({
199
+ name: propName,
200
+ value: value.toString(),
201
+ templateFn: (name, propValue) => `:${name}="${propValue}"`,
202
+ });
122
203
  break;
123
204
  case "bigint":
124
- props.push(`:${propName}="BigInt(${value.toString()})"`);
205
+ properties.push({
206
+ name: propName,
207
+ value: `BigInt(${value.toString()})`,
208
+ templateFn: (name, propValue) => `:${name}="${propValue}"`,
209
+ });
125
210
  break;
126
211
  case "boolean":
127
- props.push(value === true ? propName : `:${propName}="false"`);
212
+ properties.push({
213
+ name: propName,
214
+ value: value ? "true" : "false",
215
+ templateFn: (name, propValue) => (propValue === "true" ? name : `:${name}="false"`),
216
+ });
128
217
  break;
129
- case "object":
130
- if (value === null) return; // do not render null values
131
- props.push(`:${propName}="${replaceAll(JSON.stringify(value), '"', "'")}"`);
218
+ case "symbol":
219
+ properties.push({
220
+ name: propName,
221
+ value: `Symbol(${value.description ? `'${value.description}'` : ""})`,
222
+ templateFn: (name, propValue) => `:${name}="${propValue}"`,
223
+ });
132
224
  break;
133
- case "symbol": {
134
- const symbol = `Symbol(${value.description ? `'${value.description}'` : ""})`;
135
- props.push(`:${propName}="${symbol}"`);
225
+ case "object": {
226
+ properties.push({
227
+ name: propName,
228
+ value: formatObject(value),
229
+ // to follow Vue best practices, complex values like object and arrays are
230
+ // usually placed inside the <script setup> block instead of inlining them in the <template>
231
+ templateFn: undefined,
232
+ });
136
233
  break;
137
234
  }
138
235
  case "function":
@@ -141,17 +238,75 @@ export const generatePropsSourceCode = (
141
238
  }
142
239
  });
143
240
 
241
+ properties.sort((a, b) => a.name.localeCompare(b.name));
242
+
243
+ /**
244
+ * List of generated source code for the props.
245
+ * @example [':a="42"', 'b="Hello World"']
246
+ */
247
+ const props: string[] = [];
248
+
249
+ // now that we have all props parsed, we will generate them either inside the `<script lang="ts" setup>` block
250
+ // or inside the `<template>`.
251
+ // we also make sure to render v-model properties accordingly (see https://vuejs.org/guide/components/v-model)
252
+ properties.forEach((prop) => {
253
+ const isVModel = eventNames.includes(`update:${prop.name}`);
254
+
255
+ if (!isVModel && prop.templateFn) {
256
+ props.push(prop.templateFn(prop.name, prop.value));
257
+ return;
258
+ }
259
+
260
+ let variableName = prop.name;
261
+
262
+ // a variable with the same name might already exist (e.g. from a parent component)
263
+ // so we need to make sure to use a unique name here to not generate multiple variables with the same name
264
+ if (variableName in ctx.scriptVariables) {
265
+ let index = 1;
266
+ do {
267
+ variableName = `${prop.name}${index}`;
268
+ index++;
269
+ } while (variableName in ctx.scriptVariables);
270
+ }
271
+
272
+ if (!isVModel) {
273
+ ctx.scriptVariables[variableName] = prop.value;
274
+ props.push(`:${prop.name}="${variableName}"`);
275
+ return;
276
+ }
277
+
278
+ // always generate v-models inside the `<script lang="ts" setup>` block
279
+ ctx.scriptVariables[variableName] = `ref(${prop.value})`;
280
+
281
+ if (!ctx.imports.vue) ctx.imports.vue = new Set();
282
+ ctx.imports.vue.add("ref");
283
+
284
+ if (prop.name === "modelValue") {
285
+ props.push(`v-model="${variableName}"`);
286
+ } else {
287
+ props.push(`v-model:${prop.name}="${variableName}"`);
288
+ }
289
+ });
290
+
144
291
  return props.join(" ");
145
292
  };
146
293
 
147
294
  /**
148
295
  * Generates the source code for the given Vue component slots.
296
+ * Supports primitive slot content (e.g. strings, numbers etc.) and nested components/VNodes (e.g. created using Vue's `h()` function).
149
297
  *
150
298
  * @param args Story args.
151
299
  * @param slotNames All slot names of the component. Needed to only generate slots and ignore props etc.
152
- * Can be extracted using `extractSlotNames()`.
300
+ * Can be extracted using `parseDocgenInfo()`.
301
+ * @param ctx Context so complex props of nested slot children will be set in the ctx.scriptVariables.
302
+ *
303
+ * @example `<template #slotName="{ foo }">Content {{ foo }}</template>`
153
304
  */
154
- export const generateSlotSourceCode = (args: Args, slotNames: string[]): string => {
305
+ export const generateSlotSourceCode = (
306
+ args: Args,
307
+ slotNames: string[],
308
+ ctx: SourceCodeGeneratorContext,
309
+ ): string => {
155
310
  /** List of slot source codes (e.g. <template #slotName>Content</template>) */
156
311
  const slotSourceCodes: string[] = [];
157
312
 
@@ -159,18 +314,19 @@ export const generateSlotSourceCode = (args: Args, slotNames: string[]): string
159
314
  const arg = args[slotName];
160
315
  if (!arg) return;
161
316
 
162
- const slotContent = generateSlotChildrenSourceCode([arg]);
317
+ const slotContent = generateSlotChildrenSourceCode([arg], ctx);
163
318
  if (!slotContent) return; // do not generate source code for empty slots
164
319
 
165
- // TODO: support generating bindings
166
- const bindings = "";
320
+ const slotBindings = typeof arg === "function" ? getFunctionParamNames(arg) : [];
167
321
 
168
- if (slotName === "default" && !bindings) {
322
+ if (slotName === "default" && !slotBindings.length) {
169
323
  // do not add unnecessary "<template #default>" tag since the default slot content without bindings
170
324
  // can be put directly into the slot without need of "<template #default>"
171
325
  slotSourceCodes.push(slotContent);
172
326
  } else {
173
- slotSourceCodes.push(`<template #${slotName}${bindings}>${slotContent}</template>`);
327
+ slotSourceCodes.push(
328
+ `<template ${slotBindingsToString(slotName, slotBindings)}>${slotContent}</template>`,
329
+ );
174
330
  }
175
331
  });
176
332
 
@@ -180,7 +336,10 @@ export const generateSlotSourceCode = (args: Args, slotNames: string[]): string
180
336
  /**
181
337
  * Generates the source code for the given slot children (the code inside <template #slotName></template>).
182
338
  */
183
- const generateSlotChildrenSourceCode = (children: unknown[]): string => {
339
+ const generateSlotChildrenSourceCode = (
340
+ children: unknown[],
341
+ ctx: SourceCodeGeneratorContext,
342
+ ): string => {
184
343
  const slotChildrenSourceCodes: string[] = [];
185
344
 
186
345
  /**
@@ -189,7 +348,7 @@ const generateSlotChildrenSourceCode = (children: unknown[]): string => {
189
348
  */
190
349
  const generateSingleChildSourceCode = (child: unknown): string => {
191
350
  if (isVNode(child)) {
192
- return generateVNodeSourceCode(child);
351
+ return generateVNodeSourceCode(child, ctx);
193
352
  }
194
353
 
195
354
  switch (typeof child) {
@@ -210,8 +369,29 @@ const generateSlotChildrenSourceCode = (children: unknown[]): string => {
210
369
  return JSON.stringify(child);
211
370
 
212
371
  case "function": {
213
- const returnValue = child();
214
- return generateSlotChildrenSourceCode([returnValue]);
372
+ const paramNames = getFunctionParamNames(child).filter(
373
+ (param) => !["{", "}"].includes(param),
374
+ );
375
+
376
+ const parameters = paramNames.reduce<Record<string, string>>((obj, param) => {
377
+ obj[param] = `{{ ${param} }}`;
378
+ return obj;
379
+ }, {});
380
+
381
+ const returnValue = child(parameters);
382
+ let slotSourceCode = generateSlotChildrenSourceCode([returnValue], ctx);
383
+
384
+ // if slot bindings are used for properties of other components, our {{ paramName }} is incorrect because
385
+ // it would generate e.g. my-prop="{{ paramName }}", therefore, we replace it here to e.g. :my-prop="paramName"
386
+ paramNames.forEach((param) => {
387
+ slotSourceCode = replaceAll(
388
+ slotSourceCode,
389
+ new RegExp(` (\\S+)="{{ ${param} }}"`, "g"),
390
+ ` :$1="${param}"`,
391
+ );
392
+ });
393
+
394
+ return slotSourceCode;
215
395
  }
216
396
 
217
397
  case "bigint":
@@ -235,28 +415,14 @@ const generateSlotChildrenSourceCode = (children: unknown[]): string => {
235
415
  /**
236
416
  * Generates source code for the given VNode and all its children (e.g. created using `h(MyComponent)` or `h("div")`).
237
417
  */
238
- const generateVNodeSourceCode = (vnode: VNode): string => {
239
- let componentName = "component";
240
- if (typeof vnode.type === "string") {
241
- // this is e.g. the case when rendering native HTML elements like, h("div")
242
- componentName = vnode.type;
243
- } else if (typeof vnode.type === "object" && "__name" in vnode.type) {
244
- // this is the case when using custom Vue components like h(MyComponent)
245
- if ("name" in vnode.type && vnode.type.name) {
246
- // prefer custom component name set by the developer
247
- componentName = vnode.type.name;
248
- } else if ("__name" in vnode.type && vnode.type.__name) {
249
- // otherwise use name inferred by Vue from the file name
250
- componentName = vnode.type.__name;
251
- }
252
- }
253
-
418
+ const generateVNodeSourceCode = (vnode: VNode, ctx: SourceCodeGeneratorContext): string => {
419
+ const componentName = getVNodeName(vnode);
254
420
  let childrenCode = "";
255
421
 
256
422
  if (typeof vnode.children === "string") {
257
423
  childrenCode = vnode.children;
258
424
  } else if (Array.isArray(vnode.children)) {
259
- childrenCode = generateSlotChildrenSourceCode(vnode.children);
425
+ childrenCode = generateSlotChildrenSourceCode(vnode.children, ctx);
260
426
  } else if (vnode.children) {
261
427
  // children are an object, just like if regular Story args where used
262
428
  // so we can generate the source code with the regular "generateSlotSourceCode()".
@@ -265,13 +431,93 @@ const generateVNodeSourceCode = (vnode: VNode): string => {
265
431
  // $stable is a default property in vnode.children so we need to filter it out
266
432
  // to not generate source code for it
267
433
  Object.keys(vnode.children).filter((i) => i !== "$stable"),
434
+ ctx,
268
435
  );
269
436
  }
270
437
 
271
- const props = vnode.props ? generatePropsSourceCode(vnode.props, []) : "";
438
+ const props = vnode.props ? generatePropsSourceCode(vnode.props, [], [], ctx) : "";
272
439
 
273
440
  // prefer self closing tag if no children exist
274
- if (childrenCode)
441
+ if (childrenCode) {
275
442
  return `<${componentName}${props ? ` ${props}` : ""}>${childrenCode}</${componentName}>`;
443
+ }
276
444
  return `<${componentName}${props ? ` ${props}` : ""} />`;
277
445
  };
446
+
447
+ /**
448
+ * Gets the name for the given VNode.
449
+ * Will return "component" if name could not be extracted.
450
+ *
451
+ * @example "div" for `h("div")` or "MyComponent" for `h(MyComponent)`
452
+ */
453
+ const getVNodeName = (vnode: VNode) => {
454
+ // this is e.g. the case when rendering native HTML elements like, h("div")
455
+ if (typeof vnode.type === "string") return vnode.type;
456
+
457
+ if (typeof vnode.type === "object") {
458
+ // this is the case when using custom Vue components like h(MyComponent)
459
+ if ("name" in vnode.type && vnode.type.name) {
460
+ // prefer custom component name set by the developer
461
+ return vnode.type.name;
462
+ } else if ("__name" in vnode.type && vnode.type.__name) {
463
+ // otherwise use name inferred by Vue from the file name
464
+ return vnode.type.__name;
465
+ }
466
+ }
467
+
468
+ return "component";
469
+ };
470
+
471
+ /**
472
+ * Gets a list of parameters for the given function since func.arguments can not be used since
473
+ * it throws a TypeError.
474
+ *
475
+ * If the arguments are destructured (e.g. "func({ foo, bar })"), the returned array will also
476
+ * include "{" and "}".
477
+ *
478
+ * @see Based on https://stackoverflow.com/a/9924463
479
+ */
480
+ // eslint-disable-next-line @typescript-eslint/ban-types
481
+ const getFunctionParamNames = (func: Function): string[] => {
482
+ const STRIP_COMMENTS = /((\/\/.*$)|(\/\*[\s\S]*?\*\/))/gm;
483
+ const ARGUMENT_NAMES = /([^\s,]+)/g;
484
+
485
+ const fnStr = func.toString().replace(STRIP_COMMENTS, "");
486
+ const result = fnStr.slice(fnStr.indexOf("(") + 1, fnStr.indexOf(")")).match(ARGUMENT_NAMES);
487
+ return result ?? [];
488
+ };
489
+
490
+ /**
491
+ * Converts the given slot bindings/parameters to a string.
492
+ *
493
+ * @example
494
+ * If no params: '#slotName'
495
+ * If params: '#slotName="{ foo, bar }"'
496
+ */
497
+ const slotBindingsToString = (
498
+ slotName: string,
499
+ params: string[],
500
+ ): `#${string}` | `#${string}="${string}"` => {
501
+ if (!params.length) return `#${slotName}`;
502
+ if (params.length === 1) return `#${slotName}="${params[0]}"`;
503
+
504
+ // parameters might be destructured so remove duplicated brackets here
505
+ return `#${slotName}="{ ${params.filter((i) => !["{", "}"].includes(i)).join(", ")} }"`;
506
+ };
507
+
508
+ /**
509
+ * Formats the given object as string.
510
+ * Will format in single line if it only contains non-object values.
511
+ * Otherwise will format multiline.
512
+ */
513
+ export const formatObject = (obj: object): string => {
514
+ const isPrimitive = Object.values(obj).every(
515
+ (value) => value == null || typeof value !== "object",
516
+ );
517
+
518
+ // if object/array only contains non-object values, we format all values in one line
519
+ if (isPrimitive) return JSON.stringify(obj);
520
+
521
+ // otherwise, we use a "pretty" formatting with newlines and spaces
522
+ return JSON.stringify(obj, null, 2);
523
+ };