@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 +3 -3
- package/src/preview.ts +13 -5
- package/src/source-code-generator.spec.ts +105 -10
- package/src/source-code-generator.ts +324 -78
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.
|
|
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
|
-
"
|
|
33
|
-
"sit-onyx": "^1.0
|
|
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
|
|
169
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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="
|
|
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
|
|
139
|
-
const
|
|
140
|
-
expect(
|
|
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
|
|
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
|
|
38
|
+
const sourceCodeContext: SourceCodeGeneratorContext = {
|
|
39
|
+
imports: {},
|
|
40
|
+
scriptVariables: {},
|
|
41
|
+
};
|
|
20
42
|
|
|
21
|
-
const slotNames =
|
|
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
|
-
|
|
26
|
-
|
|
27
|
-
|
|
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
|
-
|
|
33
|
-
|
|
34
|
-
|
|
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
|
-
*
|
|
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
|
|
105
|
+
export const parseDocgenInfo = (
|
|
68
106
|
component?: StoryContext["component"] & { __docgenInfo?: unknown },
|
|
69
|
-
)
|
|
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
|
-
!
|
|
76
|
-
!
|
|
77
|
-
!
|
|
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
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
.
|
|
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 `
|
|
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
|
-
|
|
103
|
-
|
|
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
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 "
|
|
130
|
-
|
|
131
|
-
|
|
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 "
|
|
134
|
-
|
|
135
|
-
|
|
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 `
|
|
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 = (
|
|
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
|
-
|
|
166
|
-
const bindings = "";
|
|
320
|
+
const slotBindings = typeof arg === "function" ? getFunctionParamNames(arg) : [];
|
|
167
321
|
|
|
168
|
-
if (slotName === "default" && !
|
|
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(
|
|
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 = (
|
|
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
|
|
214
|
-
|
|
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
|
-
|
|
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
|
+
};
|