@sit-onyx/storybook-utils 1.0.0-alpha.111 → 1.0.0-alpha.113
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 +5 -3
- package/src/preview.spec.ts +22 -9
- package/src/preview.ts +59 -21
- package/src/source-code-generator.spec.ts +141 -0
- package/src/source-code-generator.ts +277 -0
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.113",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"author": "Schwarz IT KG",
|
|
7
7
|
"license": "Apache-2.0",
|
|
@@ -24,14 +24,16 @@
|
|
|
24
24
|
},
|
|
25
25
|
"peerDependencies": {
|
|
26
26
|
"@storybook/core-events": ">= 8.0.0",
|
|
27
|
+
"@storybook/docs-tools": ">= 8.0.0",
|
|
27
28
|
"@storybook/preview-api": ">= 8.0.0",
|
|
28
29
|
"@storybook/theming": ">= 8.0.0",
|
|
29
30
|
"@storybook/vue3": ">= 8.0.0",
|
|
30
31
|
"storybook-dark-mode": ">= 4",
|
|
31
|
-
"sit-onyx": "^1.0
|
|
32
|
+
"@sit-onyx/icons": "^0.1.0-alpha.1",
|
|
33
|
+
"sit-onyx": "^1.0.0-alpha.110"
|
|
32
34
|
},
|
|
33
35
|
"dependencies": {
|
|
34
|
-
"deepmerge-ts": "^7.0.
|
|
36
|
+
"deepmerge-ts": "^7.0.1"
|
|
35
37
|
},
|
|
36
38
|
"scripts": {
|
|
37
39
|
"build": "tsc --noEmit",
|
package/src/preview.spec.ts
CHANGED
|
@@ -1,18 +1,31 @@
|
|
|
1
|
-
import
|
|
2
|
-
import
|
|
1
|
+
import bellRing from "@sit-onyx/icons/bell-ring.svg?raw";
|
|
2
|
+
import calendar from "@sit-onyx/icons/calendar.svg?raw";
|
|
3
|
+
import placeholder from "@sit-onyx/icons/placeholder.svg?raw";
|
|
4
|
+
import { describe, expect, test, vi } from "vitest";
|
|
5
|
+
import { replaceAll, sourceCodeTransformer } from "./preview";
|
|
6
|
+
import * as sourceCodeGenerator from "./source-code-generator";
|
|
3
7
|
|
|
4
8
|
describe("preview.ts", () => {
|
|
5
|
-
test("should transform source code", () => {
|
|
9
|
+
test("should transform source code and add icon imports", () => {
|
|
6
10
|
// ARRANGE
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
+
const generatorSpy = vi.spyOn(sourceCodeGenerator, "generateSourceCode")
|
|
12
|
+
.mockReturnValue(`<template>
|
|
13
|
+
<OnyxTest icon='${placeholder}' test='${bellRing}' :obj="{foo:'${replaceAll(calendar, '"', "\\'")}'}" />
|
|
14
|
+
</template>`);
|
|
11
15
|
|
|
12
16
|
// ACT
|
|
13
|
-
const
|
|
17
|
+
const sourceCode = sourceCodeTransformer("", { title: "OnyxTest", args: {} });
|
|
14
18
|
|
|
15
19
|
// ASSERT
|
|
16
|
-
expect(
|
|
20
|
+
expect(generatorSpy).toHaveBeenCalledOnce();
|
|
21
|
+
expect(sourceCode).toBe(`<script lang="ts" setup>
|
|
22
|
+
import bellRing from "@sit-onyx/icons/bell-ring.svg?raw";
|
|
23
|
+
import calendar from "@sit-onyx/icons/calendar.svg?raw";
|
|
24
|
+
import placeholder from "@sit-onyx/icons/placeholder.svg?raw";
|
|
25
|
+
</script>
|
|
26
|
+
|
|
27
|
+
<template>
|
|
28
|
+
<OnyxTest :icon="placeholder" :test="bellRing" :obj="{foo:calendar}" />
|
|
29
|
+
</template>`);
|
|
17
30
|
});
|
|
18
31
|
});
|
package/src/preview.ts
CHANGED
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
import { DOCS_RENDERED } from "@storybook/core-events";
|
|
2
2
|
import { addons } from "@storybook/preview-api";
|
|
3
3
|
import { type ThemeVars } from "@storybook/theming";
|
|
4
|
-
import { type Preview } from "@storybook/vue3";
|
|
4
|
+
import { type Preview, type StoryContext } from "@storybook/vue3";
|
|
5
5
|
import { deepmerge } from "deepmerge-ts";
|
|
6
6
|
import { DARK_MODE_EVENT_NAME } from "storybook-dark-mode";
|
|
7
7
|
|
|
8
|
+
import { getIconImportName } from "@sit-onyx/icons";
|
|
8
9
|
import { requiredGlobalType, withRequired } from "./required";
|
|
10
|
+
import { generateSourceCode } from "./source-code-generator";
|
|
9
11
|
import { ONYX_BREAKPOINTS, createTheme } from "./theme";
|
|
10
12
|
|
|
11
13
|
const themes = {
|
|
@@ -116,32 +118,68 @@ export const createPreview = <T extends Preview = Preview>(overrides?: T) => {
|
|
|
116
118
|
};
|
|
117
119
|
|
|
118
120
|
/**
|
|
119
|
-
* Custom transformer for the story source code to
|
|
120
|
-
*
|
|
121
|
-
*
|
|
121
|
+
* Custom transformer for the story source code to support improved source code generation.
|
|
122
|
+
* and add imports for all used onyx icons so icon imports are displayed in the source code
|
|
123
|
+
* instead of the the raw SVG content.
|
|
124
|
+
*
|
|
122
125
|
* @see https://storybook.js.org/docs/react/api/doc-block-source
|
|
123
126
|
*/
|
|
124
|
-
export const sourceCodeTransformer = (
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
127
|
+
export const sourceCodeTransformer = (
|
|
128
|
+
sourceCode: string,
|
|
129
|
+
ctx: Pick<StoryContext, "title" | "component" | "args">,
|
|
130
|
+
): string => {
|
|
131
|
+
const RAW_ICONS = import.meta.glob("../node_modules/@sit-onyx/icons/src/assets/*.svg", {
|
|
132
|
+
query: "?raw",
|
|
133
|
+
import: "default",
|
|
134
|
+
eager: true,
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Mapping between icon SVG content (key) and icon name (value).
|
|
139
|
+
* Needed to display a labelled dropdown list of all available icons.
|
|
140
|
+
*/
|
|
141
|
+
const ALL_ICONS = Object.entries(RAW_ICONS).reduce<Record<string, string>>(
|
|
142
|
+
(obj, [filePath, content]) => {
|
|
143
|
+
obj[filePath.split("/").at(-1)!.replace(".svg", "")] = content as string;
|
|
144
|
+
return obj;
|
|
145
|
+
},
|
|
146
|
+
{},
|
|
147
|
+
);
|
|
148
|
+
|
|
149
|
+
let code = generateSourceCode(ctx);
|
|
150
|
+
|
|
151
|
+
const iconImports: string[] = [];
|
|
152
|
+
|
|
153
|
+
// add icon imports to the source code for all used onyx icons
|
|
154
|
+
Object.entries(ALL_ICONS).forEach(([iconName, iconContent]) => {
|
|
155
|
+
const importName = getIconImportName(iconName);
|
|
156
|
+
const singleQuotedIconContent = `'${replaceAll(iconContent, '"', "\\'")}'`;
|
|
157
|
+
|
|
158
|
+
if (code.includes(iconContent)) {
|
|
159
|
+
code = code.replace(new RegExp(` (\\S+)=['"]${iconContent}['"]`), ` :$1="${importName}"`);
|
|
160
|
+
iconImports.push(`import ${importName} from "@sit-onyx/icons/${iconName}.svg?raw";`);
|
|
161
|
+
} else if (code.includes(singleQuotedIconContent)) {
|
|
162
|
+
// support icons inside objects
|
|
163
|
+
code = code.replace(singleQuotedIconContent, importName);
|
|
164
|
+
iconImports.push(`import ${importName} from "@sit-onyx/icons/${iconName}.svg?raw";`);
|
|
165
|
+
}
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
if (iconImports.length > 0) {
|
|
169
|
+
return `<script lang="ts" setup>
|
|
170
|
+
${iconImports.join("\n")}
|
|
171
|
+
</script>
|
|
172
|
+
|
|
173
|
+
${code}`;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
return code;
|
|
139
177
|
};
|
|
140
178
|
|
|
141
179
|
/**
|
|
142
180
|
* Custom String.replaceAll implementation using a RegExp
|
|
143
181
|
* because String.replaceAll() is not available in our specified EcmaScript target in tsconfig.json
|
|
144
182
|
*/
|
|
145
|
-
const replaceAll = (
|
|
146
|
-
return
|
|
183
|
+
export const replaceAll = (value: string, searchValue: string | RegExp, replaceValue: string) => {
|
|
184
|
+
return value.replace(new RegExp(searchValue, "gi"), replaceValue);
|
|
147
185
|
};
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
//
|
|
2
|
+
// This file is only a temporary copy of the improved source code generation for Storybook.
|
|
3
|
+
// It is intended to be deleted once its officially released in Storybook itself, see:
|
|
4
|
+
// https://github.com/storybookjs/storybook/pull/27194
|
|
5
|
+
//
|
|
6
|
+
import { expect, test } from "vitest";
|
|
7
|
+
import { h } from "vue";
|
|
8
|
+
import {
|
|
9
|
+
extractSlotNames,
|
|
10
|
+
generatePropsSourceCode,
|
|
11
|
+
generateSlotSourceCode,
|
|
12
|
+
} from "./source-code-generator";
|
|
13
|
+
|
|
14
|
+
test("should generate source code for props", () => {
|
|
15
|
+
const slots = ["default", "testSlot"];
|
|
16
|
+
|
|
17
|
+
const code = generatePropsSourceCode(
|
|
18
|
+
{
|
|
19
|
+
a: "foo",
|
|
20
|
+
b: '"I am double quoted"',
|
|
21
|
+
c: 42,
|
|
22
|
+
d: true,
|
|
23
|
+
e: false,
|
|
24
|
+
f: [1, 2, 3],
|
|
25
|
+
g: {
|
|
26
|
+
g1: "foo",
|
|
27
|
+
b2: 42,
|
|
28
|
+
},
|
|
29
|
+
h: undefined,
|
|
30
|
+
i: null,
|
|
31
|
+
j: "",
|
|
32
|
+
k: BigInt(9007199254740991),
|
|
33
|
+
l: Symbol(),
|
|
34
|
+
m: Symbol("foo"),
|
|
35
|
+
default: "default slot",
|
|
36
|
+
testSlot: "test slot",
|
|
37
|
+
},
|
|
38
|
+
slots,
|
|
39
|
+
);
|
|
40
|
+
|
|
41
|
+
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')"`,
|
|
43
|
+
);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
test("should generate source code for slots", () => {
|
|
47
|
+
// slot code generator should support primitive values (string, number etc.)
|
|
48
|
+
// but also VNodes (e.g. created using h()) so custom Vue components can also be used
|
|
49
|
+
// inside slots with proper generated code
|
|
50
|
+
|
|
51
|
+
const slots = {
|
|
52
|
+
default: "default content",
|
|
53
|
+
a: "a content",
|
|
54
|
+
b: 42,
|
|
55
|
+
c: true,
|
|
56
|
+
// single VNode without props
|
|
57
|
+
d: h("div", "d content"),
|
|
58
|
+
// VNode with props and single child
|
|
59
|
+
e: h("div", { style: "color:red" }, "e content"),
|
|
60
|
+
// VNode with props and single child returned as getter
|
|
61
|
+
f: h("div", { style: "color:red" }, () => "f content"),
|
|
62
|
+
// VNode with multiple children
|
|
63
|
+
g: h("div", { style: "color:red" }, [
|
|
64
|
+
"child 1",
|
|
65
|
+
h("span", { style: "color:green" }, "child 2"),
|
|
66
|
+
]),
|
|
67
|
+
// VNode multiple children but returned as getter
|
|
68
|
+
h: h("div", { style: "color:red" }, () => [
|
|
69
|
+
"child 1",
|
|
70
|
+
h("span", { style: "color:green" }, "child 2"),
|
|
71
|
+
]),
|
|
72
|
+
// VNode with multiple and nested children
|
|
73
|
+
i: h("div", { style: "color:red" }, [
|
|
74
|
+
"child 1",
|
|
75
|
+
h("span", { style: "color:green" }, ["nested child 1", h("p", "nested child 2")]),
|
|
76
|
+
]),
|
|
77
|
+
j: ["child 1", "child 2"],
|
|
78
|
+
k: null,
|
|
79
|
+
l: { foo: "bar" },
|
|
80
|
+
m: BigInt(9007199254740991),
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
const expectedCode = `default content
|
|
84
|
+
|
|
85
|
+
<template #a>a content</template>
|
|
86
|
+
|
|
87
|
+
<template #b>42</template>
|
|
88
|
+
|
|
89
|
+
<template #c>true</template>
|
|
90
|
+
|
|
91
|
+
<template #d><div>d content</div></template>
|
|
92
|
+
|
|
93
|
+
<template #e><div style="color:red">e content</div></template>
|
|
94
|
+
|
|
95
|
+
<template #f><div style="color:red">f content</div></template>
|
|
96
|
+
|
|
97
|
+
<template #g><div style="color:red">child 1
|
|
98
|
+
<span style="color:green">child 2</span></div></template>
|
|
99
|
+
|
|
100
|
+
<template #h><div style="color:red">child 1
|
|
101
|
+
<span style="color:green">child 2</span></div></template>
|
|
102
|
+
|
|
103
|
+
<template #i><div style="color:red">child 1
|
|
104
|
+
<span style="color:green">nested child 1
|
|
105
|
+
<p>nested child 2</p></span></div></template>
|
|
106
|
+
|
|
107
|
+
<template #j>child 1
|
|
108
|
+
child 2</template>
|
|
109
|
+
|
|
110
|
+
<template #l>{"foo":"bar"}</template>
|
|
111
|
+
|
|
112
|
+
<template #m>{{ BigInt(9007199254740991) }}</template>`;
|
|
113
|
+
|
|
114
|
+
let actualCode = generateSlotSourceCode(slots, Object.keys(slots));
|
|
115
|
+
expect(actualCode).toBe(expectedCode);
|
|
116
|
+
|
|
117
|
+
// should generate the same code if getters/functions are used to return the slot content
|
|
118
|
+
const slotsWithGetters = Object.entries(slots).reduce<
|
|
119
|
+
Record<string, () => (typeof slots)[keyof typeof slots]>
|
|
120
|
+
>((obj, [slotName, value]) => {
|
|
121
|
+
obj[slotName] = () => value;
|
|
122
|
+
return obj;
|
|
123
|
+
}, {});
|
|
124
|
+
|
|
125
|
+
actualCode = generateSlotSourceCode(slotsWithGetters, Object.keys(slotsWithGetters));
|
|
126
|
+
expect(actualCode).toBe(expectedCode);
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
test.each([
|
|
130
|
+
{ __docgenInfo: "invalid-value", slotNames: [] },
|
|
131
|
+
{ __docgenInfo: {}, slotNames: [] },
|
|
132
|
+
{ __docgenInfo: { slots: "invalid-value" }, slotNames: [] },
|
|
133
|
+
{ __docgenInfo: { slots: ["invalid-value"] }, slotNames: [] },
|
|
134
|
+
{
|
|
135
|
+
__docgenInfo: { slots: [{ name: "slot-1" }, { name: "slot-2" }, { notName: "slot-3" }] },
|
|
136
|
+
slotNames: ["slot-1", "slot-2"],
|
|
137
|
+
},
|
|
138
|
+
])("should extract slots names from __docgenInfo", ({ __docgenInfo, slotNames }) => {
|
|
139
|
+
const actualNames = extractSlotNames({ __docgenInfo });
|
|
140
|
+
expect(actualNames).toStrictEqual(slotNames);
|
|
141
|
+
});
|
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
//
|
|
2
|
+
// This file is only a temporary copy of the improved source code generation for Storybook.
|
|
3
|
+
// It is intended to be deleted once its officially released in Storybook itself, see:
|
|
4
|
+
// https://github.com/storybookjs/storybook/pull/27194
|
|
5
|
+
//
|
|
6
|
+
import { SourceType } from "@storybook/docs-tools";
|
|
7
|
+
import type { Args, StoryContext } from "@storybook/vue3";
|
|
8
|
+
import type { VNode } from "vue";
|
|
9
|
+
import { isVNode } from "vue";
|
|
10
|
+
import { replaceAll } from "./preview";
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Generate Vue source code for the given Story.
|
|
14
|
+
* @returns Source code or empty string if source code could not be generated.
|
|
15
|
+
*/
|
|
16
|
+
export const generateSourceCode = (
|
|
17
|
+
ctx: Pick<StoryContext, "title" | "component" | "args">,
|
|
18
|
+
): string => {
|
|
19
|
+
const componentName = ctx.component?.__name || ctx.title.split("/").at(-1)!;
|
|
20
|
+
|
|
21
|
+
const slotNames = extractSlotNames(ctx.component);
|
|
22
|
+
const slotSourceCode = generateSlotSourceCode(ctx.args, slotNames);
|
|
23
|
+
const propsSourceCode = generatePropsSourceCode(ctx.args, slotNames);
|
|
24
|
+
|
|
25
|
+
if (slotSourceCode) {
|
|
26
|
+
return `<template>
|
|
27
|
+
<${componentName} ${propsSourceCode}> ${slotSourceCode} </${componentName}>
|
|
28
|
+
</template>`;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// prefer self closing tag if no slot content exists
|
|
32
|
+
return `<template>
|
|
33
|
+
<${componentName} ${propsSourceCode} />
|
|
34
|
+
</template>`;
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Checks if the source code generation should be skipped for the given Story context.
|
|
39
|
+
* Will be true if one of the following is true:
|
|
40
|
+
* - view mode is not "docs"
|
|
41
|
+
* - story is no arg story
|
|
42
|
+
* - story has set custom source code via parameters.docs.source.code
|
|
43
|
+
* - story has set source type to "code" via parameters.docs.source.type
|
|
44
|
+
*/
|
|
45
|
+
export const shouldSkipSourceCodeGeneration = (context: StoryContext): boolean => {
|
|
46
|
+
const sourceParams = context?.parameters.docs?.source;
|
|
47
|
+
if (sourceParams?.type === SourceType.DYNAMIC) {
|
|
48
|
+
// always render if the user forces it
|
|
49
|
+
return false;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const isArgsStory = context?.parameters.__isArgsStory;
|
|
53
|
+
const isDocsViewMode = context?.viewMode === "docs";
|
|
54
|
+
|
|
55
|
+
// never render if the user is forcing the block to render code, or
|
|
56
|
+
// if the user provides code, or if it's not an args story.
|
|
57
|
+
return (
|
|
58
|
+
!isDocsViewMode || !isArgsStory || sourceParams?.code || sourceParams?.type === SourceType.CODE
|
|
59
|
+
);
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Gets all slot names from the `__docgenInfo` of the given component if available.
|
|
64
|
+
* Requires Storybook docs addon to be enabled.
|
|
65
|
+
* Default slot will always be sorted first, remaining slots are sorted alphabetically.
|
|
66
|
+
*/
|
|
67
|
+
export const extractSlotNames = (
|
|
68
|
+
component?: StoryContext["component"] & { __docgenInfo?: unknown },
|
|
69
|
+
): string[] => {
|
|
70
|
+
if (!component || !("__docgenInfo" in component)) return [];
|
|
71
|
+
|
|
72
|
+
// type check __docgenInfo to prevent errors
|
|
73
|
+
if (!component.__docgenInfo || typeof component.__docgenInfo !== "object") return [];
|
|
74
|
+
if (
|
|
75
|
+
!("slots" in component.__docgenInfo) ||
|
|
76
|
+
!component.__docgenInfo.slots ||
|
|
77
|
+
!Array.isArray(component.__docgenInfo.slots)
|
|
78
|
+
) {
|
|
79
|
+
return [];
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
return component.__docgenInfo.slots
|
|
83
|
+
.map((slot) => slot.name)
|
|
84
|
+
.filter((i): i is string => typeof i === "string")
|
|
85
|
+
.sort((a, b) => {
|
|
86
|
+
if (a === "default") return -1;
|
|
87
|
+
if (b === "default") return 1;
|
|
88
|
+
return a.localeCompare(b);
|
|
89
|
+
});
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Generates the source code for the given Vue component properties.
|
|
94
|
+
*
|
|
95
|
+
* @param args Story args / property values.
|
|
96
|
+
* @param slotNames All slot names of the component. Needed to not generate code for args that are slots.
|
|
97
|
+
* Can be extracted using `extractSlotNames()`.
|
|
98
|
+
*/
|
|
99
|
+
export const generatePropsSourceCode = (
|
|
100
|
+
args: Record<string, unknown>,
|
|
101
|
+
slotNames: string[],
|
|
102
|
+
): string => {
|
|
103
|
+
const props: string[] = [];
|
|
104
|
+
|
|
105
|
+
Object.entries(args).forEach(([propName, value]) => {
|
|
106
|
+
// ignore slots
|
|
107
|
+
if (slotNames.includes(propName)) return;
|
|
108
|
+
|
|
109
|
+
switch (typeof value) {
|
|
110
|
+
case "string":
|
|
111
|
+
if (value === "") return; // do not render empty strings
|
|
112
|
+
|
|
113
|
+
if (value.includes('"')) {
|
|
114
|
+
props.push(`${propName}='${value}'`);
|
|
115
|
+
} else {
|
|
116
|
+
props.push(`${propName}="${value}"`);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
break;
|
|
120
|
+
case "number":
|
|
121
|
+
props.push(`:${propName}="${value}"`);
|
|
122
|
+
break;
|
|
123
|
+
case "bigint":
|
|
124
|
+
props.push(`:${propName}="BigInt(${value.toString()})"`);
|
|
125
|
+
break;
|
|
126
|
+
case "boolean":
|
|
127
|
+
props.push(value === true ? propName : `:${propName}="false"`);
|
|
128
|
+
break;
|
|
129
|
+
case "object":
|
|
130
|
+
if (value === null) return; // do not render null values
|
|
131
|
+
props.push(`:${propName}="${replaceAll(JSON.stringify(value), '"', "'")}"`);
|
|
132
|
+
break;
|
|
133
|
+
case "symbol": {
|
|
134
|
+
const symbol = `Symbol(${value.description ? `'${value.description}'` : ""})`;
|
|
135
|
+
props.push(`:${propName}="${symbol}"`);
|
|
136
|
+
break;
|
|
137
|
+
}
|
|
138
|
+
case "function":
|
|
139
|
+
// TODO: check if functions should be rendered in source code
|
|
140
|
+
break;
|
|
141
|
+
}
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
return props.join(" ");
|
|
145
|
+
};
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Generates the source code for the given Vue component slots.
|
|
149
|
+
*
|
|
150
|
+
* @param args Story args.
|
|
151
|
+
* @param slotNames All slot names of the component. Needed to only generate slots and ignore props etc.
|
|
152
|
+
* Can be extracted using `extractSlotNames()`.
|
|
153
|
+
*/
|
|
154
|
+
export const generateSlotSourceCode = (args: Args, slotNames: string[]): string => {
|
|
155
|
+
/** List of slot source codes (e.g. <template #slotName>Content</template>) */
|
|
156
|
+
const slotSourceCodes: string[] = [];
|
|
157
|
+
|
|
158
|
+
slotNames.forEach((slotName) => {
|
|
159
|
+
const arg = args[slotName];
|
|
160
|
+
if (!arg) return;
|
|
161
|
+
|
|
162
|
+
const slotContent = generateSlotChildrenSourceCode([arg]);
|
|
163
|
+
if (!slotContent) return; // do not generate source code for empty slots
|
|
164
|
+
|
|
165
|
+
// TODO: support generating bindings
|
|
166
|
+
const bindings = "";
|
|
167
|
+
|
|
168
|
+
if (slotName === "default" && !bindings) {
|
|
169
|
+
// do not add unnecessary "<template #default>" tag since the default slot content without bindings
|
|
170
|
+
// can be put directly into the slot without need of "<template #default>"
|
|
171
|
+
slotSourceCodes.push(slotContent);
|
|
172
|
+
} else {
|
|
173
|
+
slotSourceCodes.push(`<template #${slotName}${bindings}>${slotContent}</template>`);
|
|
174
|
+
}
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
return slotSourceCodes.join("\n\n");
|
|
178
|
+
};
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Generates the source code for the given slot children (the code inside <template #slotName></template>).
|
|
182
|
+
*/
|
|
183
|
+
const generateSlotChildrenSourceCode = (children: unknown[]): string => {
|
|
184
|
+
const slotChildrenSourceCodes: string[] = [];
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* Recursively generates the source code for a single slot child and all its children.
|
|
188
|
+
* @returns Source code for child and all nested children or empty string if child is of a non-supported type.
|
|
189
|
+
*/
|
|
190
|
+
const generateSingleChildSourceCode = (child: unknown): string => {
|
|
191
|
+
if (isVNode(child)) {
|
|
192
|
+
return generateVNodeSourceCode(child);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
switch (typeof child) {
|
|
196
|
+
case "string":
|
|
197
|
+
case "number":
|
|
198
|
+
case "boolean":
|
|
199
|
+
return child.toString();
|
|
200
|
+
|
|
201
|
+
case "object":
|
|
202
|
+
if (child === null) return "";
|
|
203
|
+
if (Array.isArray(child)) {
|
|
204
|
+
// if child also has children, we generate them recursively
|
|
205
|
+
return child
|
|
206
|
+
.map(generateSingleChildSourceCode)
|
|
207
|
+
.filter((code) => code !== "")
|
|
208
|
+
.join("\n");
|
|
209
|
+
}
|
|
210
|
+
return JSON.stringify(child);
|
|
211
|
+
|
|
212
|
+
case "function": {
|
|
213
|
+
const returnValue = child();
|
|
214
|
+
return generateSlotChildrenSourceCode([returnValue]);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
case "bigint":
|
|
218
|
+
return `{{ BigInt(${child.toString()}) }}`;
|
|
219
|
+
|
|
220
|
+
// the only missing case here is "symbol"
|
|
221
|
+
// because rendering a symbol as slot / HTML does not make sense and is not supported by Vue
|
|
222
|
+
default:
|
|
223
|
+
return "";
|
|
224
|
+
}
|
|
225
|
+
};
|
|
226
|
+
|
|
227
|
+
children.forEach((child) => {
|
|
228
|
+
const sourceCode = generateSingleChildSourceCode(child);
|
|
229
|
+
if (sourceCode !== "") slotChildrenSourceCodes.push(sourceCode);
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
return slotChildrenSourceCodes.join("\n");
|
|
233
|
+
};
|
|
234
|
+
|
|
235
|
+
/**
|
|
236
|
+
* Generates source code for the given VNode and all its children (e.g. created using `h(MyComponent)` or `h("div")`).
|
|
237
|
+
*/
|
|
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
|
+
|
|
254
|
+
let childrenCode = "";
|
|
255
|
+
|
|
256
|
+
if (typeof vnode.children === "string") {
|
|
257
|
+
childrenCode = vnode.children;
|
|
258
|
+
} else if (Array.isArray(vnode.children)) {
|
|
259
|
+
childrenCode = generateSlotChildrenSourceCode(vnode.children);
|
|
260
|
+
} else if (vnode.children) {
|
|
261
|
+
// children are an object, just like if regular Story args where used
|
|
262
|
+
// so we can generate the source code with the regular "generateSlotSourceCode()".
|
|
263
|
+
childrenCode = generateSlotSourceCode(
|
|
264
|
+
vnode.children,
|
|
265
|
+
// $stable is a default property in vnode.children so we need to filter it out
|
|
266
|
+
// to not generate source code for it
|
|
267
|
+
Object.keys(vnode.children).filter((i) => i !== "$stable"),
|
|
268
|
+
);
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
const props = vnode.props ? generatePropsSourceCode(vnode.props, []) : "";
|
|
272
|
+
|
|
273
|
+
// prefer self closing tag if no children exist
|
|
274
|
+
if (childrenCode)
|
|
275
|
+
return `<${componentName}${props ? ` ${props}` : ""}>${childrenCode}</${componentName}>`;
|
|
276
|
+
return `<${componentName}${props ? ` ${props}` : ""} />`;
|
|
277
|
+
};
|