@sit-onyx/storybook-utils 1.0.0-beta.5 → 1.0.0-beta.50
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 +9 -6
- package/src/actions.spec.ts +29 -0
- package/src/actions.ts +39 -63
- package/src/index.ts +1 -0
- package/src/preview.spec.ts +3 -9
- package/src/preview.ts +19 -9
- package/src/sbType.spec.ts +38 -0
- package/src/sbType.ts +35 -0
- package/src/theme.ts +1 -1
- package/src/types.ts +1 -49
- package/src/source-code-generator.spec.ts +0 -258
- package/src/source-code-generator.ts +0 -539
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-beta.
|
|
4
|
+
"version": "1.0.0-beta.50",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"author": "Schwarz IT KG",
|
|
7
7
|
"license": "Apache-2.0",
|
|
@@ -23,14 +23,17 @@
|
|
|
23
23
|
"url": "https://github.com/SchwarzIT/onyx/issues"
|
|
24
24
|
},
|
|
25
25
|
"peerDependencies": {
|
|
26
|
-
"@storybook/vue3": ">= 8.
|
|
27
|
-
"storybook": ">= 8.
|
|
26
|
+
"@storybook/vue3": ">= 8.3.0",
|
|
27
|
+
"storybook": ">= 8.3.0",
|
|
28
28
|
"storybook-dark-mode": ">= 4",
|
|
29
|
-
"@sit-onyx/icons": "^1.0.0-beta.
|
|
30
|
-
"sit-onyx": "^1.0.0-beta.
|
|
29
|
+
"@sit-onyx/icons": "^1.0.0-beta.4",
|
|
30
|
+
"sit-onyx": "^1.0.0-beta.45"
|
|
31
31
|
},
|
|
32
32
|
"dependencies": {
|
|
33
|
-
"deepmerge-ts": "^7.0
|
|
33
|
+
"deepmerge-ts": "^7.1.0"
|
|
34
|
+
},
|
|
35
|
+
"devDependencies": {
|
|
36
|
+
"vue": "^3.5.0"
|
|
34
37
|
},
|
|
35
38
|
"scripts": {
|
|
36
39
|
"build": "tsc --noEmit",
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import type { StoryContextForEnhancers } from "storybook/internal/types";
|
|
2
|
+
import { expect, test } from "vitest";
|
|
3
|
+
import { enhanceEventArgTypes } from "./actions";
|
|
4
|
+
|
|
5
|
+
test("should enhance event arg types", () => {
|
|
6
|
+
const argTypes = {
|
|
7
|
+
someProp: {
|
|
8
|
+
name: "someProp",
|
|
9
|
+
table: { category: "props" },
|
|
10
|
+
},
|
|
11
|
+
click: {
|
|
12
|
+
name: "click",
|
|
13
|
+
table: { category: "events" },
|
|
14
|
+
},
|
|
15
|
+
} satisfies StoryContextForEnhancers["argTypes"];
|
|
16
|
+
|
|
17
|
+
const result = enhanceEventArgTypes({
|
|
18
|
+
argTypes,
|
|
19
|
+
} as unknown as StoryContextForEnhancers);
|
|
20
|
+
|
|
21
|
+
expect(result).toStrictEqual({
|
|
22
|
+
...argTypes,
|
|
23
|
+
onClick: {
|
|
24
|
+
name: "onClick",
|
|
25
|
+
table: { disable: true },
|
|
26
|
+
action: "click",
|
|
27
|
+
},
|
|
28
|
+
});
|
|
29
|
+
});
|
package/src/actions.ts
CHANGED
|
@@ -1,85 +1,61 @@
|
|
|
1
|
-
import type {
|
|
2
|
-
import { deepmerge } from "deepmerge-ts";
|
|
1
|
+
import type { Decorator } from "@storybook/vue3";
|
|
3
2
|
import { useArgs } from "storybook/internal/preview-api";
|
|
3
|
+
import type { ArgTypesEnhancer, StrictInputType } from "storybook/internal/types";
|
|
4
4
|
import { isReactive, reactive, watch } from "vue";
|
|
5
|
-
import type { DefineStorybookActionsAndVModelsOptions, ExtractVueEventNames } from ".";
|
|
6
5
|
|
|
7
6
|
/**
|
|
8
|
-
*
|
|
9
|
-
* the given events as well as implementing v-model handlers so that the Storybook controls are updated when you interact with the component.
|
|
10
|
-
* Should be preferred over manually defining argTypes for *.stories.ts files.
|
|
11
|
-
*
|
|
12
|
-
* @example
|
|
13
|
-
* ```ts
|
|
14
|
-
* // Input.stories.ts
|
|
15
|
-
* import { defineStorybookActionsAndVModels } from '@sit-onyx/storybook-utils';
|
|
16
|
-
* import type { Meta } from '@storybook/vue3';
|
|
17
|
-
* import Input from './Input.vue';
|
|
18
|
-
*
|
|
19
|
-
* const meta: Meta<typeof Input> = {
|
|
20
|
-
* title: 'components/Input',
|
|
21
|
-
* ...defineStorybookActionsAndVModels({
|
|
22
|
-
* component: Input,
|
|
23
|
-
* events: ['update:modelValue', 'change'],
|
|
24
|
-
* }),
|
|
25
|
-
* };
|
|
26
|
-
* ```
|
|
7
|
+
* Adds actions for all argTypes of the 'event' category, so that they are logged via the actions plugin.
|
|
27
8
|
*/
|
|
28
|
-
export const
|
|
29
|
-
|
|
30
|
-
)
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
9
|
+
export const enhanceEventArgTypes: ArgTypesEnhancer = ({ argTypes }) => {
|
|
10
|
+
Object.values(argTypes)
|
|
11
|
+
.filter(({ table }) => table?.category === "events")
|
|
12
|
+
.forEach(({ name }) => {
|
|
13
|
+
const eventName = `on${capitalizeFirstLetter(name)}`;
|
|
14
|
+
if (eventName in argTypes) {
|
|
15
|
+
return;
|
|
16
|
+
}
|
|
17
|
+
argTypes[eventName] = {
|
|
18
|
+
name: eventName,
|
|
19
|
+
table: { disable: true }, // do not add a second table entry for event name prefixed with "on"
|
|
20
|
+
action: name,
|
|
21
|
+
};
|
|
22
|
+
});
|
|
23
|
+
return argTypes;
|
|
40
24
|
};
|
|
41
25
|
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
* otherwise we would use it like "@on-click="..."" which is redundant.
|
|
49
|
-
*
|
|
50
|
-
* So this utility will remove the on[eventName] entry from the Storybook panel/table
|
|
51
|
-
* and register the correct eventName as action so it is logged in the "Actions" tab.
|
|
52
|
-
*
|
|
53
|
-
* @example defineActions(["click", "input"])
|
|
54
|
-
*/
|
|
55
|
-
export const defineActions = <T>(events: ExtractVueEventNames<T>[]): ArgTypes => {
|
|
56
|
-
return events.reduce<ArgTypes>((argTypes, eventName) => {
|
|
57
|
-
argTypes[`on${capitalizeFirstLetter(eventName)}`] = {
|
|
58
|
-
table: { disable: true },
|
|
59
|
-
action: eventName,
|
|
60
|
-
};
|
|
61
|
-
|
|
62
|
-
argTypes[eventName] = { control: false };
|
|
63
|
-
return argTypes;
|
|
64
|
-
}, {});
|
|
26
|
+
export type WithVModelDecoratorOptions = {
|
|
27
|
+
/**
|
|
28
|
+
* The matcher for the v-model events.
|
|
29
|
+
* @default /^update:/
|
|
30
|
+
*/
|
|
31
|
+
filter: (argType: StrictInputType) => boolean;
|
|
65
32
|
};
|
|
66
33
|
|
|
67
34
|
/**
|
|
68
|
-
* Defines a custom decorator that will implement event handlers for all v-models
|
|
69
|
-
* so that the Storybook controls are updated live when the user interacts with the component
|
|
35
|
+
* Defines a custom decorator that will implement event handlers for all v-models,
|
|
36
|
+
* so that the Storybook controls are updated live when the user interacts with the component.
|
|
37
|
+
* This ensures that the story and component props stay in sync.
|
|
70
38
|
*
|
|
71
39
|
* @example
|
|
72
40
|
* ```ts
|
|
73
|
-
*
|
|
41
|
+
* // .storybook/preview.ts
|
|
74
42
|
*
|
|
75
43
|
* {
|
|
76
|
-
* decorators: [withVModelDecorator
|
|
44
|
+
* decorators: [withVModelDecorator()]
|
|
77
45
|
* }
|
|
78
46
|
* ```
|
|
79
47
|
*/
|
|
80
|
-
|
|
48
|
+
|
|
49
|
+
export const withVModelDecorator = (options?: WithVModelDecoratorOptions): Decorator => {
|
|
81
50
|
return (story, ctx) => {
|
|
82
|
-
const
|
|
51
|
+
const vModelFilter =
|
|
52
|
+
options?.filter ||
|
|
53
|
+
(({ table, name }) => table?.category === "events" && name.startsWith("update:"));
|
|
54
|
+
|
|
55
|
+
const vModelEvents = Object.values(ctx.argTypes)
|
|
56
|
+
.filter(vModelFilter)
|
|
57
|
+
.map(({ name }) => name);
|
|
58
|
+
|
|
83
59
|
if (!vModelEvents.length) return story();
|
|
84
60
|
|
|
85
61
|
const [args, updateArgs] = useArgs();
|
package/src/index.ts
CHANGED
package/src/preview.spec.ts
CHANGED
|
@@ -1,25 +1,19 @@
|
|
|
1
1
|
import bellRing from "@sit-onyx/icons/bell-ring.svg?raw";
|
|
2
2
|
import calendar from "@sit-onyx/icons/calendar.svg?raw";
|
|
3
3
|
import placeholder from "@sit-onyx/icons/placeholder.svg?raw";
|
|
4
|
-
import { describe, expect, test
|
|
4
|
+
import { describe, expect, test } from "vitest";
|
|
5
5
|
import { replaceAll, sourceCodeTransformer } from "./preview";
|
|
6
|
-
import * as sourceCodeGenerator from "./source-code-generator";
|
|
7
6
|
|
|
8
7
|
describe("preview.ts", () => {
|
|
9
8
|
test("should transform source code and add icon/onyx imports", () => {
|
|
10
|
-
//
|
|
11
|
-
const
|
|
12
|
-
.mockReturnValue(`<template>
|
|
9
|
+
// ACT
|
|
10
|
+
const sourceCode = sourceCodeTransformer(`<template>
|
|
13
11
|
<OnyxTest icon='${placeholder}' test='${bellRing}' :obj="{foo:'${replaceAll(calendar, '"', "\\'")}'}" />
|
|
14
12
|
<OnyxOtherComponent />
|
|
15
13
|
<OnyxComp>Test</OnyxComp>
|
|
16
14
|
</template>`);
|
|
17
15
|
|
|
18
|
-
// ACT
|
|
19
|
-
const sourceCode = sourceCodeTransformer("", { title: "OnyxTest", args: {} });
|
|
20
|
-
|
|
21
16
|
// ASSERT
|
|
22
|
-
expect(generatorSpy).toHaveBeenCalledOnce();
|
|
23
17
|
expect(sourceCode).toBe(`<script lang="ts" setup>
|
|
24
18
|
import { OnyxComp, OnyxOtherComponent, OnyxTest } from "sit-onyx";
|
|
25
19
|
import bellRing from "@sit-onyx/icons/bell-ring.svg?raw";
|
package/src/preview.ts
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
import { getIconImportName } from "@sit-onyx/icons";
|
|
2
|
-
import {
|
|
2
|
+
import type { Preview } from "@storybook/vue3";
|
|
3
3
|
import { deepmerge } from "deepmerge-ts";
|
|
4
4
|
import { DARK_MODE_EVENT_NAME } from "storybook-dark-mode";
|
|
5
5
|
import { DOCS_RENDERED } from "storybook/internal/core-events";
|
|
6
6
|
import { addons } from "storybook/internal/preview-api";
|
|
7
7
|
import type { ThemeVars } from "storybook/internal/theming";
|
|
8
|
+
import { enhanceEventArgTypes } from "./actions";
|
|
8
9
|
import { requiredGlobalType, withRequired } from "./required";
|
|
9
|
-
import { generateSourceCode } from "./source-code-generator";
|
|
10
10
|
import { ONYX_BREAKPOINTS, createTheme } from "./theme";
|
|
11
11
|
|
|
12
12
|
const themes = {
|
|
@@ -21,6 +21,7 @@ const themes = {
|
|
|
21
21
|
* - Setup for dark mode (including docs page). Requires addon `storybook-dark-mode` to be enabled in .storybook/main.ts file
|
|
22
22
|
* - Custom Storybook theme using onyx colors (light and dark mode)
|
|
23
23
|
* - Configure viewports / breakpoints as defined by onyx
|
|
24
|
+
* - Logs Vue emits as Storybook events
|
|
24
25
|
*
|
|
25
26
|
* @param overrides Custom preview / overrides, will be deep merged with the default preview.
|
|
26
27
|
*
|
|
@@ -41,6 +42,7 @@ const themes = {
|
|
|
41
42
|
*/
|
|
42
43
|
export const createPreview = <T extends Preview = Preview>(overrides?: T) => {
|
|
43
44
|
const defaultPreview = {
|
|
45
|
+
argTypesEnhancers: [enhanceEventArgTypes],
|
|
44
46
|
globalTypes: {
|
|
45
47
|
...requiredGlobalType,
|
|
46
48
|
},
|
|
@@ -123,16 +125,15 @@ export const createPreview = <T extends Preview = Preview>(overrides?: T) => {
|
|
|
123
125
|
*
|
|
124
126
|
* @see https://storybook.js.org/docs/react/api/doc-block-source
|
|
125
127
|
*/
|
|
126
|
-
export const sourceCodeTransformer = (
|
|
127
|
-
sourceCode: string,
|
|
128
|
-
ctx: Pick<StoryContext, "title" | "component" | "args">,
|
|
129
|
-
): string => {
|
|
128
|
+
export const sourceCodeTransformer = (originalSourceCode: string): string => {
|
|
130
129
|
const RAW_ICONS = import.meta.glob("../node_modules/@sit-onyx/icons/src/assets/*.svg", {
|
|
131
130
|
query: "?raw",
|
|
132
131
|
import: "default",
|
|
133
132
|
eager: true,
|
|
134
133
|
});
|
|
135
134
|
|
|
135
|
+
let code = originalSourceCode;
|
|
136
|
+
|
|
136
137
|
/**
|
|
137
138
|
* Mapping between icon SVG content (key) and icon name (value).
|
|
138
139
|
* Needed to display a labelled dropdown list of all available icons.
|
|
@@ -145,8 +146,6 @@ export const sourceCodeTransformer = (
|
|
|
145
146
|
{},
|
|
146
147
|
);
|
|
147
148
|
|
|
148
|
-
let code = generateSourceCode(ctx);
|
|
149
|
-
|
|
150
149
|
const additionalImports: string[] = [];
|
|
151
150
|
|
|
152
151
|
// add icon imports to the source code for all used onyx icons
|
|
@@ -156,7 +155,10 @@ export const sourceCodeTransformer = (
|
|
|
156
155
|
const escapedIconContent = `"${replaceAll(iconContent, '"', '\\"')}"`;
|
|
157
156
|
|
|
158
157
|
if (code.includes(iconContent)) {
|
|
159
|
-
code = code.replace(
|
|
158
|
+
code = code.replace(
|
|
159
|
+
new RegExp(` (\\S+)=['"]${escapeRegExp(iconContent)}['"]`),
|
|
160
|
+
` :$1="${importName}"`,
|
|
161
|
+
);
|
|
160
162
|
additionalImports.push(`import ${importName} from "@sit-onyx/icons/${iconName}.svg?raw";`);
|
|
161
163
|
} else if (code.includes(singleQuotedIconContent)) {
|
|
162
164
|
// support icons inside objects
|
|
@@ -206,3 +208,11 @@ ${code}`;
|
|
|
206
208
|
export const replaceAll = (value: string, searchValue: string | RegExp, replaceValue: string) => {
|
|
207
209
|
return value.replace(new RegExp(searchValue, "gi"), replaceValue);
|
|
208
210
|
};
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* Escapes the given string value to be used in `new RegExp()`.
|
|
214
|
+
* @see https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Regular_expressions#escaping
|
|
215
|
+
*/
|
|
216
|
+
export const escapeRegExp = (string: string) => {
|
|
217
|
+
return string.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); // $& means the whole matched string
|
|
218
|
+
};
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import type { SBType } from "storybook/internal/types";
|
|
2
|
+
import { describe, expect, test, vi } from "vitest";
|
|
3
|
+
import { walkTree } from "./sbType";
|
|
4
|
+
|
|
5
|
+
describe("walkTree", () => {
|
|
6
|
+
test.each<{ input: SBType; expected: SBType["name"][] }>([
|
|
7
|
+
{ input: { name: "array", value: { name: "number" } }, expected: ["array", "number"] },
|
|
8
|
+
{ input: { name: "object", value: { a: { name: "number" } } }, expected: ["object", "number"] },
|
|
9
|
+
{ input: { name: "enum", value: ["a"] }, expected: ["enum"] },
|
|
10
|
+
{
|
|
11
|
+
input: { name: "intersection", value: [{ name: "number" }] },
|
|
12
|
+
expected: ["intersection", "number"],
|
|
13
|
+
},
|
|
14
|
+
{ input: { name: "union", value: [{ name: "number" }] }, expected: ["union", "number"] },
|
|
15
|
+
{ input: { name: "other", value: "a" }, expected: ["other"] },
|
|
16
|
+
])("should execute cb for $input.name correctly", ({ input, expected }) => {
|
|
17
|
+
const spy = vi.fn<(p: SBType) => void>();
|
|
18
|
+
const result = walkTree(input, spy);
|
|
19
|
+
|
|
20
|
+
expect(result).toBeUndefined();
|
|
21
|
+
expect(spy).toHaveBeenCalledTimes(expected.length);
|
|
22
|
+
const nameCalls = spy.mock.calls.map(([{ name }]) => name);
|
|
23
|
+
expect(nameCalls).toMatchObject(expected);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
test("should return value if there is any returned", () => {
|
|
27
|
+
const target: SBType = { name: "number", raw: "here" };
|
|
28
|
+
const overshoot: SBType = { name: "boolean", raw: "here" };
|
|
29
|
+
const parent: SBType = { name: "intersection", value: [target, overshoot] };
|
|
30
|
+
const returned = 42;
|
|
31
|
+
const spy = vi.fn((p: SBType) => (p.raw === "here" ? returned : undefined));
|
|
32
|
+
const result = walkTree({ name: "union", value: [parent] }, spy);
|
|
33
|
+
|
|
34
|
+
expect(spy).toHaveBeenCalledTimes(3);
|
|
35
|
+
expect(spy).toHaveBeenLastCalledWith(target, parent);
|
|
36
|
+
expect(result).toBe(returned);
|
|
37
|
+
});
|
|
38
|
+
});
|
package/src/sbType.ts
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import type { SBType } from "storybook/internal/types";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Call a function `cb` for every type node in the storybook type tree.
|
|
5
|
+
* @param inputType the root type
|
|
6
|
+
* @param cb the function that is called for every type. If any non-nullish value is returned by `cb` the execution is stopped and this value is returned.
|
|
7
|
+
* @param parent optional, the parent type. Is only used as input for the `cb` function and provided when recursing.
|
|
8
|
+
* @returns the first non-nullish value that is returned by `cb`
|
|
9
|
+
*/
|
|
10
|
+
export const walkTree = <TValue>(
|
|
11
|
+
inputType: SBType,
|
|
12
|
+
cb: (sb: SBType, parent?: SBType) => TValue,
|
|
13
|
+
parent?: SBType,
|
|
14
|
+
): TValue | undefined => {
|
|
15
|
+
const shouldReturn = cb(inputType, parent);
|
|
16
|
+
if (shouldReturn) {
|
|
17
|
+
return shouldReturn;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
if (inputType.name === "union" || inputType.name === "intersection") {
|
|
21
|
+
return inputType.value.reduce<TValue | undefined>(
|
|
22
|
+
(prev, it) => prev ?? walkTree(it, cb, inputType),
|
|
23
|
+
undefined,
|
|
24
|
+
);
|
|
25
|
+
}
|
|
26
|
+
if (inputType.name === "array") {
|
|
27
|
+
return walkTree(inputType.value, cb, inputType);
|
|
28
|
+
}
|
|
29
|
+
if (inputType.name === "object") {
|
|
30
|
+
return Object.values(inputType.value).reduce<TValue | undefined>(
|
|
31
|
+
(prev, it) => prev ?? walkTree(it, cb, inputType),
|
|
32
|
+
undefined,
|
|
33
|
+
);
|
|
34
|
+
}
|
|
35
|
+
};
|
package/src/theme.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
+
import { ONYX_BREAKPOINTS as RAW_ONYX_BREAKPOINTS, type OnyxBreakpoint } from "sit-onyx";
|
|
1
2
|
import onyxVariables from "sit-onyx/themes/onyx.json";
|
|
2
|
-
import { ONYX_BREAKPOINTS as RAW_ONYX_BREAKPOINTS, type OnyxBreakpoint } from "sit-onyx/types";
|
|
3
3
|
import { create, type ThemeVars, type ThemeVarsPartial } from "storybook/internal/theming";
|
|
4
4
|
import onyxLogo from "./assets/logo-onyx.svg";
|
|
5
5
|
|
package/src/types.ts
CHANGED
|
@@ -1,52 +1,3 @@
|
|
|
1
|
-
import type { ComponentPropsAndSlots, Meta } from "@storybook/vue3";
|
|
2
|
-
|
|
3
|
-
/**
|
|
4
|
-
* Extracts all event names defined by e.g. `defineEmits()` from the given Vue component.
|
|
5
|
-
*
|
|
6
|
-
* @example
|
|
7
|
-
* ```ts
|
|
8
|
-
* import Input from "./Input.vue";
|
|
9
|
-
* type InputEvents = ExtractVueEventNames<typeof Input>; // e.g. "input" | "change"
|
|
10
|
-
* ```
|
|
11
|
-
*/
|
|
12
|
-
export type ExtractVueEventNames<VueComponent> =
|
|
13
|
-
Extract<
|
|
14
|
-
// extract all props/events of the vue component that are functions
|
|
15
|
-
ExtractKeysByValueType<
|
|
16
|
-
// this generic type will extract ALL props and events from the given Vue component
|
|
17
|
-
ComponentPropsAndSlots<VueComponent>,
|
|
18
|
-
// emits are declared as functions, so we only take props/events that are functions and ignore the rest
|
|
19
|
-
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- We must use any here to match the type defined by Vue
|
|
20
|
-
((...args: any) => any) | undefined
|
|
21
|
-
>,
|
|
22
|
-
// filter out potential function properties by just picking events that start with "on"
|
|
23
|
-
`on${string}`
|
|
24
|
-
> extends `on${infer EventName}`
|
|
25
|
-
? // until now the extracted event names still start with "on" but we want to have the plain event name
|
|
26
|
-
// so we will remove the "on" prefix and uncapitalized the first letter so e.g. "onClick" becomes "click"
|
|
27
|
-
Uncapitalize<EventName>
|
|
28
|
-
: never;
|
|
29
|
-
|
|
30
|
-
/**
|
|
31
|
-
* Extracts only the keys from T whose value type satisfies U.
|
|
32
|
-
*
|
|
33
|
-
* @example
|
|
34
|
-
* ```ts
|
|
35
|
-
* type Test = ExtractKeysByValueType<{ a: boolean, b: number, c: boolean }, boolean>
|
|
36
|
-
* // result: "a" | "c"
|
|
37
|
-
* ```
|
|
38
|
-
*/
|
|
39
|
-
export type ExtractKeysByValueType<T, U> = { [P in keyof T]: T[P] extends U ? P : never }[keyof T] &
|
|
40
|
-
keyof T;
|
|
41
|
-
|
|
42
|
-
/**
|
|
43
|
-
* Options for defining Storybook actions and v-models.
|
|
44
|
-
*/
|
|
45
|
-
export type DefineStorybookActionsAndVModelsOptions<T> = Meta<T> & {
|
|
46
|
-
component: NonNullable<T>;
|
|
47
|
-
events: ExtractVueEventNames<T>[];
|
|
48
|
-
};
|
|
49
|
-
|
|
50
1
|
export type StorybookGlobalType<TValue> = {
|
|
51
2
|
name: string;
|
|
52
3
|
description: string;
|
|
@@ -55,5 +6,6 @@ export type StorybookGlobalType<TValue> = {
|
|
|
55
6
|
icon: string;
|
|
56
7
|
items: { value: TValue; right?: string; title: string }[];
|
|
57
8
|
title?: string;
|
|
9
|
+
dynamicTitle?: boolean;
|
|
58
10
|
};
|
|
59
11
|
};
|
|
@@ -1,258 +0,0 @@
|
|
|
1
|
-
/* eslint-disable @typescript-eslint/no-unused-vars */
|
|
2
|
-
//
|
|
3
|
-
// This file is only a temporary copy of the improved source code generation for Storybook.
|
|
4
|
-
// It is intended to be deleted once its officially released in Storybook itself, see:
|
|
5
|
-
// https://github.com/storybookjs/storybook/pull/27194
|
|
6
|
-
//
|
|
7
|
-
import { expect, test } from "vitest";
|
|
8
|
-
import { h } from "vue";
|
|
9
|
-
import {
|
|
10
|
-
generatePropsSourceCode,
|
|
11
|
-
generateSlotSourceCode,
|
|
12
|
-
generateSourceCode,
|
|
13
|
-
getFunctionParamNames,
|
|
14
|
-
parseDocgenInfo,
|
|
15
|
-
type SourceCodeGeneratorContext,
|
|
16
|
-
} from "./source-code-generator";
|
|
17
|
-
|
|
18
|
-
test("should generate source code for props", () => {
|
|
19
|
-
const ctx: SourceCodeGeneratorContext = {
|
|
20
|
-
scriptVariables: {},
|
|
21
|
-
imports: {},
|
|
22
|
-
};
|
|
23
|
-
|
|
24
|
-
const code = generatePropsSourceCode(
|
|
25
|
-
{
|
|
26
|
-
a: "foo",
|
|
27
|
-
b: '"I am double quoted"',
|
|
28
|
-
c: 42,
|
|
29
|
-
d: true,
|
|
30
|
-
e: false,
|
|
31
|
-
f: [1, 2, 3],
|
|
32
|
-
g: {
|
|
33
|
-
g1: "foo",
|
|
34
|
-
g2: 42,
|
|
35
|
-
},
|
|
36
|
-
h: undefined,
|
|
37
|
-
i: null,
|
|
38
|
-
j: "",
|
|
39
|
-
k: BigInt(9007199254740991),
|
|
40
|
-
l: Symbol(),
|
|
41
|
-
m: Symbol("foo"),
|
|
42
|
-
modelValue: "test-v-model",
|
|
43
|
-
otherModelValue: 42,
|
|
44
|
-
default: "default slot",
|
|
45
|
-
testSlot: "test slot",
|
|
46
|
-
},
|
|
47
|
-
["default", "testSlot"],
|
|
48
|
-
["update:modelValue", "update:otherModelValue"],
|
|
49
|
-
ctx,
|
|
50
|
-
);
|
|
51
|
-
|
|
52
|
-
expect(code).toBe(
|
|
53
|
-
`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"`,
|
|
54
|
-
);
|
|
55
|
-
|
|
56
|
-
expect(ctx.scriptVariables).toStrictEqual({
|
|
57
|
-
f: `[1,2,3]`,
|
|
58
|
-
g: `{"g1":"foo","g2":42}`,
|
|
59
|
-
modelValue: 'ref("test-v-model")',
|
|
60
|
-
otherModelValue: "ref(42)",
|
|
61
|
-
});
|
|
62
|
-
|
|
63
|
-
expect(Array.from(ctx.imports.vue.values())).toStrictEqual(["ref"]);
|
|
64
|
-
});
|
|
65
|
-
|
|
66
|
-
test("should generate source code for slots", () => {
|
|
67
|
-
// slot code generator should support primitive values (string, number etc.)
|
|
68
|
-
// but also VNodes (e.g. created using h()) so custom Vue components can also be used
|
|
69
|
-
// inside slots with proper generated code
|
|
70
|
-
|
|
71
|
-
const slots = {
|
|
72
|
-
default: "default content",
|
|
73
|
-
a: "a content",
|
|
74
|
-
b: 42,
|
|
75
|
-
c: true,
|
|
76
|
-
// single VNode without props
|
|
77
|
-
d: h("div", "d content"),
|
|
78
|
-
// VNode with props and single child
|
|
79
|
-
e: h("div", { style: "color:red" }, "e content"),
|
|
80
|
-
// VNode with props and single child returned as getter
|
|
81
|
-
f: h("div", { style: "color:red" }, () => "f content"),
|
|
82
|
-
// VNode with multiple children
|
|
83
|
-
g: h("div", { style: "color:red" }, [
|
|
84
|
-
"child 1",
|
|
85
|
-
h("span", { style: "color:green" }, "child 2"),
|
|
86
|
-
]),
|
|
87
|
-
// VNode multiple children but returned as getter
|
|
88
|
-
h: h("div", { style: "color:red" }, () => [
|
|
89
|
-
"child 1",
|
|
90
|
-
h("span", { style: "color:green" }, "child 2"),
|
|
91
|
-
]),
|
|
92
|
-
// VNode with multiple and nested children
|
|
93
|
-
i: h("div", { style: "color:red" }, [
|
|
94
|
-
"child 1",
|
|
95
|
-
h("span", { style: "color:green" }, ["nested child 1", h("p", "nested child 2")]),
|
|
96
|
-
]),
|
|
97
|
-
j: ["child 1", "child 2"],
|
|
98
|
-
k: null,
|
|
99
|
-
l: { foo: "bar" },
|
|
100
|
-
m: BigInt(9007199254740991),
|
|
101
|
-
};
|
|
102
|
-
|
|
103
|
-
const expectedCode = `default content
|
|
104
|
-
|
|
105
|
-
<template #a>a content</template>
|
|
106
|
-
|
|
107
|
-
<template #b>42</template>
|
|
108
|
-
|
|
109
|
-
<template #c>true</template>
|
|
110
|
-
|
|
111
|
-
<template #d><div>d content</div></template>
|
|
112
|
-
|
|
113
|
-
<template #e><div style="color:red">e content</div></template>
|
|
114
|
-
|
|
115
|
-
<template #f><div style="color:red">f content</div></template>
|
|
116
|
-
|
|
117
|
-
<template #g><div style="color:red">child 1
|
|
118
|
-
<span style="color:green">child 2</span></div></template>
|
|
119
|
-
|
|
120
|
-
<template #h><div style="color:red">child 1
|
|
121
|
-
<span style="color:green">child 2</span></div></template>
|
|
122
|
-
|
|
123
|
-
<template #i><div style="color:red">child 1
|
|
124
|
-
<span style="color:green">nested child 1
|
|
125
|
-
<p>nested child 2</p></span></div></template>
|
|
126
|
-
|
|
127
|
-
<template #j>child 1
|
|
128
|
-
child 2</template>
|
|
129
|
-
|
|
130
|
-
<template #l>{"foo":"bar"}</template>
|
|
131
|
-
|
|
132
|
-
<template #m>{{ BigInt(9007199254740991) }}</template>`;
|
|
133
|
-
|
|
134
|
-
let actualCode = generateSlotSourceCode(slots, Object.keys(slots), {
|
|
135
|
-
scriptVariables: {},
|
|
136
|
-
imports: {},
|
|
137
|
-
});
|
|
138
|
-
expect(actualCode).toBe(expectedCode);
|
|
139
|
-
|
|
140
|
-
// should generate the same code if getters/functions are used to return the slot content
|
|
141
|
-
const slotsWithGetters = Object.entries(slots).reduce<
|
|
142
|
-
Record<string, () => (typeof slots)[keyof typeof slots]>
|
|
143
|
-
>((obj, [slotName, value]) => {
|
|
144
|
-
obj[slotName] = () => value;
|
|
145
|
-
return obj;
|
|
146
|
-
}, {});
|
|
147
|
-
|
|
148
|
-
actualCode = generateSlotSourceCode(slotsWithGetters, Object.keys(slotsWithGetters), {
|
|
149
|
-
scriptVariables: {},
|
|
150
|
-
imports: {},
|
|
151
|
-
});
|
|
152
|
-
expect(actualCode).toBe(expectedCode);
|
|
153
|
-
});
|
|
154
|
-
|
|
155
|
-
test("should generate source code for slots with bindings", () => {
|
|
156
|
-
type TestBindings = {
|
|
157
|
-
foo: string;
|
|
158
|
-
bar?: number;
|
|
159
|
-
};
|
|
160
|
-
|
|
161
|
-
const slots = {
|
|
162
|
-
a: ({ foo, bar }: TestBindings) => `Slot with bindings ${foo} and ${bar}`,
|
|
163
|
-
b: ({ foo }: TestBindings) => h("a", { href: foo, target: foo }, `Test link: ${foo}`),
|
|
164
|
-
};
|
|
165
|
-
|
|
166
|
-
const expectedCode = `<template #a="{ foo, bar }">Slot with bindings {{ foo }} and {{ bar }}</template>
|
|
167
|
-
|
|
168
|
-
<template #b="{ foo }"><a :href="foo" :target="foo">Test link: {{ foo }}</a></template>`;
|
|
169
|
-
|
|
170
|
-
const actualCode = generateSlotSourceCode(slots, Object.keys(slots), {
|
|
171
|
-
imports: {},
|
|
172
|
-
scriptVariables: {},
|
|
173
|
-
});
|
|
174
|
-
expect(actualCode).toBe(expectedCode);
|
|
175
|
-
});
|
|
176
|
-
|
|
177
|
-
test("should generate source code with <script setup> block", () => {
|
|
178
|
-
const actualCode = generateSourceCode({
|
|
179
|
-
title: "MyComponent",
|
|
180
|
-
component: {
|
|
181
|
-
__docgenInfo: {
|
|
182
|
-
slots: [{ name: "mySlot" }],
|
|
183
|
-
events: [{ name: "update:c" }],
|
|
184
|
-
},
|
|
185
|
-
},
|
|
186
|
-
args: {
|
|
187
|
-
a: 42,
|
|
188
|
-
b: "foo",
|
|
189
|
-
c: [1, 2, 3],
|
|
190
|
-
d: { bar: "baz" },
|
|
191
|
-
mySlot: () => h("div", { test: [1, 2], d: { nestedProp: "foo" } }),
|
|
192
|
-
},
|
|
193
|
-
});
|
|
194
|
-
|
|
195
|
-
expect(actualCode).toBe(`<script lang="ts" setup>
|
|
196
|
-
import { ref } from "vue";
|
|
197
|
-
|
|
198
|
-
const c = ref([1,2,3]);
|
|
199
|
-
|
|
200
|
-
const d = {"bar":"baz"};
|
|
201
|
-
|
|
202
|
-
const d1 = {"nestedProp":"foo"};
|
|
203
|
-
|
|
204
|
-
const test = [1,2];
|
|
205
|
-
</script>
|
|
206
|
-
|
|
207
|
-
<template>
|
|
208
|
-
<MyComponent :a="42" b="foo" v-model:c="c" :d="d"> <template #mySlot><div :d="d1" :test="test" /></template> </MyComponent>
|
|
209
|
-
</template>`);
|
|
210
|
-
});
|
|
211
|
-
|
|
212
|
-
test.each([
|
|
213
|
-
{ __docgenInfo: "invalid-value", slotNames: [] },
|
|
214
|
-
{ __docgenInfo: {}, slotNames: [] },
|
|
215
|
-
{ __docgenInfo: { slots: "invalid-value" }, slotNames: [] },
|
|
216
|
-
{ __docgenInfo: { slots: ["invalid-value"] }, slotNames: [] },
|
|
217
|
-
{
|
|
218
|
-
__docgenInfo: { slots: [{ name: "slot-1" }, { name: "slot-2" }, { notName: "slot-3" }] },
|
|
219
|
-
slotNames: ["slot-1", "slot-2"],
|
|
220
|
-
},
|
|
221
|
-
])("should parse slots names from __docgenInfo", ({ __docgenInfo, slotNames }) => {
|
|
222
|
-
const docgenInfo = parseDocgenInfo({ __docgenInfo });
|
|
223
|
-
expect(docgenInfo.slotNames).toStrictEqual(slotNames);
|
|
224
|
-
});
|
|
225
|
-
|
|
226
|
-
test.each([
|
|
227
|
-
{ __docgenInfo: "invalid-value", eventNames: [] },
|
|
228
|
-
{ __docgenInfo: {}, eventNames: [] },
|
|
229
|
-
{ __docgenInfo: { events: "invalid-value" }, eventNames: [] },
|
|
230
|
-
{ __docgenInfo: { events: ["invalid-value"] }, eventNames: [] },
|
|
231
|
-
{
|
|
232
|
-
__docgenInfo: { events: [{ name: "event-1" }, { name: "event-2" }, { notName: "event-3" }] },
|
|
233
|
-
eventNames: ["event-1", "event-2"],
|
|
234
|
-
},
|
|
235
|
-
])("should parse event names from __docgenInfo", ({ __docgenInfo, eventNames }) => {
|
|
236
|
-
const docgenInfo = parseDocgenInfo({ __docgenInfo });
|
|
237
|
-
expect(docgenInfo.eventNames).toStrictEqual(eventNames);
|
|
238
|
-
});
|
|
239
|
-
|
|
240
|
-
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
241
|
-
test.each<{ fn: (...args: any[]) => unknown; expectedNames: string[] }>([
|
|
242
|
-
{ fn: () => ({}), expectedNames: [] },
|
|
243
|
-
{ fn: (a) => ({}), expectedNames: ["a"] },
|
|
244
|
-
{ fn: (a, b) => ({}), expectedNames: ["a", "b"] },
|
|
245
|
-
{ fn: (a, b, { c }) => ({}), expectedNames: ["a", "b", "{", "c", "}"] },
|
|
246
|
-
{ fn: ({ a, b }) => ({}), expectedNames: ["{", "a", "b", "}"] },
|
|
247
|
-
{
|
|
248
|
-
fn: {
|
|
249
|
-
// simulate minified function after running "storybook build"
|
|
250
|
-
toString: () => "({a:foo,b:bar})=>({})",
|
|
251
|
-
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
252
|
-
} as (...args: any[]) => unknown,
|
|
253
|
-
expectedNames: ["{", "a", "b", "}"],
|
|
254
|
-
},
|
|
255
|
-
])("should extract function parameter names", ({ fn, expectedNames }) => {
|
|
256
|
-
const paramNames = getFunctionParamNames(fn);
|
|
257
|
-
expect(paramNames).toStrictEqual(expectedNames);
|
|
258
|
-
});
|
|
@@ -1,539 +0,0 @@
|
|
|
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 type { Args, StoryContext } from "@storybook/vue3";
|
|
7
|
-
import { SourceType } from "storybook/internal/docs-tools";
|
|
8
|
-
import { isVNode, type VNode } from "vue";
|
|
9
|
-
import { replaceAll } from "./preview";
|
|
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
|
-
|
|
29
|
-
/**
|
|
30
|
-
* Generate Vue source code for the given Story.
|
|
31
|
-
* @returns Source code or empty string if source code could not be generated.
|
|
32
|
-
*/
|
|
33
|
-
export const generateSourceCode = (
|
|
34
|
-
ctx: Pick<StoryContext, "title" | "component" | "args"> & {
|
|
35
|
-
component?: StoryContext["component"] & { __docgenInfo?: unknown };
|
|
36
|
-
},
|
|
37
|
-
): string => {
|
|
38
|
-
const sourceCodeContext: SourceCodeGeneratorContext = {
|
|
39
|
-
imports: {},
|
|
40
|
-
scriptVariables: {},
|
|
41
|
-
};
|
|
42
|
-
|
|
43
|
-
const { displayName, slotNames, eventNames } = parseDocgenInfo(ctx.component);
|
|
44
|
-
|
|
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)!;
|
|
48
|
-
|
|
49
|
-
// prefer self closing tag if no slot content exists
|
|
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}`;
|
|
73
|
-
};
|
|
74
|
-
|
|
75
|
-
/**
|
|
76
|
-
* Checks if the source code generation should be skipped for the given Story context.
|
|
77
|
-
* Will be true if one of the following is true:
|
|
78
|
-
* - view mode is not "docs"
|
|
79
|
-
* - story is no arg story
|
|
80
|
-
* - story has set custom source code via parameters.docs.source.code
|
|
81
|
-
* - story has set source type to "code" via parameters.docs.source.type
|
|
82
|
-
*/
|
|
83
|
-
export const shouldSkipSourceCodeGeneration = (context: StoryContext): boolean => {
|
|
84
|
-
const sourceParams = context?.parameters.docs?.source;
|
|
85
|
-
if (sourceParams?.type === SourceType.DYNAMIC) {
|
|
86
|
-
// always render if the user forces it
|
|
87
|
-
return false;
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
const isArgsStory = context?.parameters.__isArgsStory;
|
|
91
|
-
const isDocsViewMode = context?.viewMode === "docs";
|
|
92
|
-
|
|
93
|
-
// never render if the user is forcing the block to render code, or
|
|
94
|
-
// if the user provides code, or if it's not an args story.
|
|
95
|
-
return (
|
|
96
|
-
!isDocsViewMode || !isArgsStory || sourceParams?.code || sourceParams?.type === SourceType.CODE
|
|
97
|
-
);
|
|
98
|
-
};
|
|
99
|
-
|
|
100
|
-
/**
|
|
101
|
-
* Parses the __docgenInfo of the given component.
|
|
102
|
-
* Requires Storybook docs addon to be enabled.
|
|
103
|
-
* Default slot will always be sorted first, remaining slots are sorted alphabetically.
|
|
104
|
-
*/
|
|
105
|
-
export const parseDocgenInfo = (
|
|
106
|
-
component?: StoryContext["component"] & { __docgenInfo?: unknown },
|
|
107
|
-
) => {
|
|
108
|
-
// type check __docgenInfo to prevent errors
|
|
109
|
-
if (
|
|
110
|
-
!component ||
|
|
111
|
-
!("__docgenInfo" in component) ||
|
|
112
|
-
!component.__docgenInfo ||
|
|
113
|
-
typeof component.__docgenInfo !== "object"
|
|
114
|
-
) {
|
|
115
|
-
return {
|
|
116
|
-
displayName: component?.__name,
|
|
117
|
-
eventNames: [],
|
|
118
|
-
slotNames: [],
|
|
119
|
-
};
|
|
120
|
-
}
|
|
121
|
-
|
|
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) => {
|
|
142
|
-
if (a === "default") return -1;
|
|
143
|
-
if (b === "default") return 1;
|
|
144
|
-
return a.localeCompare(b);
|
|
145
|
-
}),
|
|
146
|
-
eventNames: parseNames("events"),
|
|
147
|
-
};
|
|
148
|
-
};
|
|
149
|
-
|
|
150
|
-
/**
|
|
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.
|
|
154
|
-
*
|
|
155
|
-
* @param args Story args / property values.
|
|
156
|
-
* @param slotNames All slot names of the component. Needed to not generate code for args that are slots.
|
|
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"`
|
|
161
|
-
*/
|
|
162
|
-
export const generatePropsSourceCode = (
|
|
163
|
-
args: Record<string, unknown>,
|
|
164
|
-
slotNames: 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[] = [];
|
|
181
|
-
|
|
182
|
-
Object.entries(args).forEach(([propName, value]) => {
|
|
183
|
-
// ignore slots
|
|
184
|
-
if (slotNames.includes(propName)) return;
|
|
185
|
-
if (value == undefined) return; // do not render undefined/null values
|
|
186
|
-
|
|
187
|
-
switch (typeof value) {
|
|
188
|
-
case "string":
|
|
189
|
-
if (value === "") return; // do not render empty strings
|
|
190
|
-
|
|
191
|
-
properties.push({
|
|
192
|
-
name: propName,
|
|
193
|
-
value: value.includes('"') ? `'${value}'` : `"${value}"`,
|
|
194
|
-
templateFn: (name, propValue) => `${name}=${propValue}`,
|
|
195
|
-
});
|
|
196
|
-
break;
|
|
197
|
-
case "number":
|
|
198
|
-
properties.push({
|
|
199
|
-
name: propName,
|
|
200
|
-
value: value.toString(),
|
|
201
|
-
templateFn: (name, propValue) => `:${name}="${propValue}"`,
|
|
202
|
-
});
|
|
203
|
-
break;
|
|
204
|
-
case "bigint":
|
|
205
|
-
properties.push({
|
|
206
|
-
name: propName,
|
|
207
|
-
value: `BigInt(${value.toString()})`,
|
|
208
|
-
templateFn: (name, propValue) => `:${name}="${propValue}"`,
|
|
209
|
-
});
|
|
210
|
-
break;
|
|
211
|
-
case "boolean":
|
|
212
|
-
properties.push({
|
|
213
|
-
name: propName,
|
|
214
|
-
value: value ? "true" : "false",
|
|
215
|
-
templateFn: (name, propValue) => (propValue === "true" ? name : `:${name}="false"`),
|
|
216
|
-
});
|
|
217
|
-
break;
|
|
218
|
-
case "symbol":
|
|
219
|
-
properties.push({
|
|
220
|
-
name: propName,
|
|
221
|
-
value: `Symbol(${value.description ? `'${value.description}'` : ""})`,
|
|
222
|
-
templateFn: (name, propValue) => `:${name}="${propValue}"`,
|
|
223
|
-
});
|
|
224
|
-
break;
|
|
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
|
-
});
|
|
233
|
-
break;
|
|
234
|
-
}
|
|
235
|
-
case "function":
|
|
236
|
-
// TODO: check if functions should be rendered in source code
|
|
237
|
-
break;
|
|
238
|
-
}
|
|
239
|
-
});
|
|
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
|
-
|
|
291
|
-
return props.join(" ");
|
|
292
|
-
};
|
|
293
|
-
|
|
294
|
-
/**
|
|
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).
|
|
297
|
-
*
|
|
298
|
-
* @param args Story args.
|
|
299
|
-
* @param slotNames All slot names of the component. Needed to only generate slots and ignore props etc.
|
|
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>`
|
|
304
|
-
*/
|
|
305
|
-
export const generateSlotSourceCode = (
|
|
306
|
-
args: Args,
|
|
307
|
-
slotNames: string[],
|
|
308
|
-
ctx: SourceCodeGeneratorContext,
|
|
309
|
-
): string => {
|
|
310
|
-
/** List of slot source codes (e.g. <template #slotName>Content</template>) */
|
|
311
|
-
const slotSourceCodes: string[] = [];
|
|
312
|
-
|
|
313
|
-
slotNames.forEach((slotName) => {
|
|
314
|
-
const arg = args[slotName];
|
|
315
|
-
if (!arg) return;
|
|
316
|
-
|
|
317
|
-
const slotContent = generateSlotChildrenSourceCode([arg], ctx);
|
|
318
|
-
if (!slotContent) return; // do not generate source code for empty slots
|
|
319
|
-
|
|
320
|
-
const slotBindings = typeof arg === "function" ? getFunctionParamNames(arg) : [];
|
|
321
|
-
|
|
322
|
-
if (slotName === "default" && !slotBindings.length) {
|
|
323
|
-
// do not add unnecessary "<template #default>" tag since the default slot content without bindings
|
|
324
|
-
// can be put directly into the slot without need of "<template #default>"
|
|
325
|
-
slotSourceCodes.push(slotContent);
|
|
326
|
-
} else {
|
|
327
|
-
slotSourceCodes.push(
|
|
328
|
-
`<template ${slotBindingsToString(slotName, slotBindings)}>${slotContent}</template>`,
|
|
329
|
-
);
|
|
330
|
-
}
|
|
331
|
-
});
|
|
332
|
-
|
|
333
|
-
return slotSourceCodes.join("\n\n");
|
|
334
|
-
};
|
|
335
|
-
|
|
336
|
-
/**
|
|
337
|
-
* Generates the source code for the given slot children (the code inside <template #slotName></template>).
|
|
338
|
-
*/
|
|
339
|
-
const generateSlotChildrenSourceCode = (
|
|
340
|
-
children: unknown[],
|
|
341
|
-
ctx: SourceCodeGeneratorContext,
|
|
342
|
-
): string => {
|
|
343
|
-
const slotChildrenSourceCodes: string[] = [];
|
|
344
|
-
|
|
345
|
-
/**
|
|
346
|
-
* Recursively generates the source code for a single slot child and all its children.
|
|
347
|
-
* @returns Source code for child and all nested children or empty string if child is of a non-supported type.
|
|
348
|
-
*/
|
|
349
|
-
const generateSingleChildSourceCode = (child: unknown): string => {
|
|
350
|
-
if (isVNode(child)) {
|
|
351
|
-
return generateVNodeSourceCode(child, ctx);
|
|
352
|
-
}
|
|
353
|
-
|
|
354
|
-
switch (typeof child) {
|
|
355
|
-
case "string":
|
|
356
|
-
case "number":
|
|
357
|
-
case "boolean":
|
|
358
|
-
return child.toString();
|
|
359
|
-
|
|
360
|
-
case "object":
|
|
361
|
-
if (child === null) return "";
|
|
362
|
-
if (Array.isArray(child)) {
|
|
363
|
-
// if child also has children, we generate them recursively
|
|
364
|
-
return child
|
|
365
|
-
.map(generateSingleChildSourceCode)
|
|
366
|
-
.filter((code) => code !== "")
|
|
367
|
-
.join("\n");
|
|
368
|
-
}
|
|
369
|
-
return JSON.stringify(child);
|
|
370
|
-
|
|
371
|
-
case "function": {
|
|
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;
|
|
395
|
-
}
|
|
396
|
-
|
|
397
|
-
case "bigint":
|
|
398
|
-
return `{{ BigInt(${child.toString()}) }}`;
|
|
399
|
-
|
|
400
|
-
// the only missing case here is "symbol"
|
|
401
|
-
// because rendering a symbol as slot / HTML does not make sense and is not supported by Vue
|
|
402
|
-
default:
|
|
403
|
-
return "";
|
|
404
|
-
}
|
|
405
|
-
};
|
|
406
|
-
|
|
407
|
-
children.forEach((child) => {
|
|
408
|
-
const sourceCode = generateSingleChildSourceCode(child);
|
|
409
|
-
if (sourceCode !== "") slotChildrenSourceCodes.push(sourceCode);
|
|
410
|
-
});
|
|
411
|
-
|
|
412
|
-
return slotChildrenSourceCodes.join("\n");
|
|
413
|
-
};
|
|
414
|
-
|
|
415
|
-
/**
|
|
416
|
-
* Generates source code for the given VNode and all its children (e.g. created using `h(MyComponent)` or `h("div")`).
|
|
417
|
-
*/
|
|
418
|
-
const generateVNodeSourceCode = (vnode: VNode, ctx: SourceCodeGeneratorContext): string => {
|
|
419
|
-
const componentName = getVNodeName(vnode);
|
|
420
|
-
let childrenCode = "";
|
|
421
|
-
|
|
422
|
-
if (typeof vnode.children === "string") {
|
|
423
|
-
childrenCode = vnode.children;
|
|
424
|
-
} else if (Array.isArray(vnode.children)) {
|
|
425
|
-
childrenCode = generateSlotChildrenSourceCode(vnode.children, ctx);
|
|
426
|
-
} else if (vnode.children) {
|
|
427
|
-
// children are an object, just like if regular Story args where used
|
|
428
|
-
// so we can generate the source code with the regular "generateSlotSourceCode()".
|
|
429
|
-
childrenCode = generateSlotSourceCode(
|
|
430
|
-
vnode.children,
|
|
431
|
-
// $stable is a default property in vnode.children so we need to filter it out
|
|
432
|
-
// to not generate source code for it
|
|
433
|
-
Object.keys(vnode.children).filter((i) => i !== "$stable"),
|
|
434
|
-
ctx,
|
|
435
|
-
);
|
|
436
|
-
}
|
|
437
|
-
|
|
438
|
-
const props = vnode.props ? generatePropsSourceCode(vnode.props, [], [], ctx) : "";
|
|
439
|
-
|
|
440
|
-
// prefer self closing tag if no children exist
|
|
441
|
-
if (childrenCode) {
|
|
442
|
-
return `<${componentName}${props ? ` ${props}` : ""}>${childrenCode}</${componentName}>`;
|
|
443
|
-
}
|
|
444
|
-
return `<${componentName}${props ? ` ${props}` : ""} />`;
|
|
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
|
-
export 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
|
-
if (!result) return [];
|
|
488
|
-
|
|
489
|
-
// when running "storybook build", the function will be minified, so result for e.g.
|
|
490
|
-
// `({ foo, bar }) => { // function body }` will be `["{foo:e", "bar:a}"]`
|
|
491
|
-
// therefore we need to remove the :e and :a mappings and extract the "{" and "}"" from the destructured object
|
|
492
|
-
// so the final result becomes `["{", "foo", "bar", "}"]`
|
|
493
|
-
return result.flatMap((param) => {
|
|
494
|
-
if (["{", "}"].includes(param)) return param;
|
|
495
|
-
const nonMinifiedName = param.split(":")[0].trim();
|
|
496
|
-
if (nonMinifiedName.startsWith("{")) {
|
|
497
|
-
return ["{", nonMinifiedName.substring(1)];
|
|
498
|
-
}
|
|
499
|
-
if (param.endsWith("}") && !nonMinifiedName.endsWith("}")) {
|
|
500
|
-
return [nonMinifiedName, "}"];
|
|
501
|
-
}
|
|
502
|
-
return nonMinifiedName;
|
|
503
|
-
});
|
|
504
|
-
};
|
|
505
|
-
|
|
506
|
-
/**
|
|
507
|
-
* Converts the given slot bindings/parameters to a string.
|
|
508
|
-
*
|
|
509
|
-
* @example
|
|
510
|
-
* If no params: '#slotName'
|
|
511
|
-
* If params: '#slotName="{ foo, bar }"'
|
|
512
|
-
*/
|
|
513
|
-
const slotBindingsToString = (
|
|
514
|
-
slotName: string,
|
|
515
|
-
params: string[],
|
|
516
|
-
): `#${string}` | `#${string}="${string}"` => {
|
|
517
|
-
if (!params.length) return `#${slotName}`;
|
|
518
|
-
if (params.length === 1) return `#${slotName}="${params[0]}"`;
|
|
519
|
-
|
|
520
|
-
// parameters might be destructured so remove duplicated brackets here
|
|
521
|
-
return `#${slotName}="{ ${params.filter((i) => !["{", "}"].includes(i)).join(", ")} }"`;
|
|
522
|
-
};
|
|
523
|
-
|
|
524
|
-
/**
|
|
525
|
-
* Formats the given object as string.
|
|
526
|
-
* Will format in single line if it only contains non-object values.
|
|
527
|
-
* Otherwise will format multiline.
|
|
528
|
-
*/
|
|
529
|
-
export const formatObject = (obj: object): string => {
|
|
530
|
-
const isPrimitive = Object.values(obj).every(
|
|
531
|
-
(value) => value == null || typeof value !== "object",
|
|
532
|
-
);
|
|
533
|
-
|
|
534
|
-
// if object/array only contains non-object values, we format all values in one line
|
|
535
|
-
if (isPrimitive) return JSON.stringify(obj);
|
|
536
|
-
|
|
537
|
-
// otherwise, we use a "pretty" formatting with newlines and spaces
|
|
538
|
-
return JSON.stringify(obj, null, 2);
|
|
539
|
-
};
|