@sit-onyx/storybook-utils 1.0.0-alpha.16 → 1.0.0-alpha.160

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/README.md CHANGED
@@ -11,3 +11,9 @@
11
11
  # onyx Storybook utils
12
12
 
13
13
  Storybook utilities for Vue created by [Schwarz IT](https://it.schwarz).
14
+
15
+ <br />
16
+
17
+ ## Documentation
18
+
19
+ You can find our documentation [here](https://onyx.schwarz/development/packages/storybook-utils.html).
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.16",
4
+ "version": "1.0.0-alpha.160",
5
5
  "type": "module",
6
6
  "author": "Schwarz IT KG",
7
7
  "license": "Apache-2.0",
@@ -13,18 +13,31 @@
13
13
  ".": "./src/index.ts",
14
14
  "./style.css": "./src/index.css"
15
15
  },
16
+ "homepage": "https://onyx.schwarz/development/packages/storybook-utils.html",
17
+ "repository": {
18
+ "type": "git",
19
+ "url": "https://github.com/SchwarzIT/onyx",
20
+ "directory": "packages/storybook-utils"
21
+ },
22
+ "bugs": {
23
+ "url": "https://github.com/SchwarzIT/onyx/issues"
24
+ },
16
25
  "peerDependencies": {
17
- "@storybook/core-events": ">= 7",
18
- "@storybook/preview-api": ">= 7",
19
- "@storybook/theming": ">= 7",
20
- "@storybook/vue3": ">= 7",
21
- "storybook-dark-mode": ">= 3",
22
- "sit-onyx": "^1.0.0-alpha.15"
26
+ "@storybook/core-events": ">= 8.0.0",
27
+ "@storybook/docs-tools": ">= 8.0.0",
28
+ "@storybook/preview-api": ">= 8.0.0",
29
+ "@storybook/theming": ">= 8.0.0",
30
+ "@storybook/vue3": ">= 8.0.0",
31
+ "storybook-dark-mode": ">= 4",
32
+ "@sit-onyx/icons": "^0.1.0-alpha.2",
33
+ "sit-onyx": "^1.0.0-alpha.154"
23
34
  },
24
35
  "dependencies": {
25
- "deepmerge-ts": "^5.1.0"
36
+ "deepmerge-ts": "^7.0.3"
26
37
  },
27
38
  "scripts": {
28
- "build": "tsc --noEmit"
39
+ "build": "tsc --noEmit",
40
+ "test": "vitest",
41
+ "test:coverage": "vitest run --coverage"
29
42
  }
30
43
  }
@@ -0,0 +1,36 @@
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";
7
+
8
+ describe("preview.ts", () => {
9
+ test("should transform source code and add icon/onyx imports", () => {
10
+ // ARRANGE
11
+ const generatorSpy = vi.spyOn(sourceCodeGenerator, "generateSourceCode")
12
+ .mockReturnValue(`<template>
13
+ <OnyxTest icon='${placeholder}' test='${bellRing}' :obj="{foo:'${replaceAll(calendar, '"', "\\'")}'}" />
14
+ <OnyxOtherComponent />
15
+ <OnyxComp>Test</OnyxComp>
16
+ </template>`);
17
+
18
+ // ACT
19
+ const sourceCode = sourceCodeTransformer("", { title: "OnyxTest", args: {} });
20
+
21
+ // ASSERT
22
+ expect(generatorSpy).toHaveBeenCalledOnce();
23
+ expect(sourceCode).toBe(`<script lang="ts" setup>
24
+ import { OnyxComp, OnyxOtherComponent, OnyxTest } from "sit-onyx";
25
+ import bellRing from "@sit-onyx/icons/bell-ring.svg?raw";
26
+ import calendar from "@sit-onyx/icons/calendar.svg?raw";
27
+ import placeholder from "@sit-onyx/icons/placeholder.svg?raw";
28
+ </script>
29
+
30
+ <template>
31
+ <OnyxTest :icon="placeholder" :test="bellRing" :obj="{foo:calendar}" />
32
+ <OnyxOtherComponent />
33
+ <OnyxComp>Test</OnyxComp>
34
+ </template>`);
35
+ });
36
+ });
package/src/preview.ts CHANGED
@@ -1,9 +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
+
8
+ import { getIconImportName } from "@sit-onyx/icons";
9
+ import { requiredGlobalType, withRequired } from "./required";
10
+ import { generateSourceCode } from "./source-code-generator";
7
11
  import { ONYX_BREAKPOINTS, createTheme } from "./theme";
8
12
 
9
13
  const themes = {
@@ -17,7 +21,6 @@ const themes = {
17
21
  * - Improved Vue-specific code highlighting (e.g. using `@` instead of `v-on:`)
18
22
  * - Setup for dark mode (including docs page). Requires addon `storybook-dark-mode` to be enabled in .storybook/main.ts file
19
23
  * - Custom Storybook theme using onyx colors (light and dark mode)
20
- * - Support for setting the light/dark mode when Storybook is embedded as an iframe (via query parameter, e.g. `?theme=dark`)
21
24
  * - Configure viewports / breakpoints as defined by onyx
22
25
  *
23
26
  * @param overrides Custom preview / overrides, will be deep merged with the default preview.
@@ -39,6 +42,10 @@ const themes = {
39
42
  */
40
43
  export const createPreview = <T extends Preview = Preview>(overrides?: T) => {
41
44
  const defaultPreview = {
45
+ globalTypes: {
46
+ ...requiredGlobalType,
47
+ },
48
+ decorators: [withRequired],
42
49
  parameters: {
43
50
  controls: {
44
51
  matchers: {
@@ -52,20 +59,16 @@ export const createPreview = <T extends Preview = Preview>(overrides?: T) => {
52
59
  docs: {
53
60
  // see: https://github.com/hipstersmoothie/storybook-dark-mode/issues/127#issuecomment-840701971
54
61
  get theme(): ThemeVars {
55
- // support setting the theme via query parameters, useful if docs are embedded via an iframe
56
- const params = new URLSearchParams(window.location.search);
57
- const themeParam = params.get("theme");
58
-
59
- const isDark = themeParam
60
- ? themeParam === "dark"
61
- : parent.document.body.classList.contains("dark");
62
+ const isDark = parent.document.body.classList.contains("dark");
62
63
 
63
64
  if (isDark) {
64
65
  document.body.classList.remove("light");
65
66
  document.body.classList.add("dark");
67
+ document.documentElement.style.colorScheme = "dark";
66
68
  } else {
67
69
  document.body.classList.remove("dark");
68
70
  document.body.classList.add("light");
71
+ document.documentElement.style.colorScheme = "light";
69
72
  }
70
73
 
71
74
  return isDark ? themes.dark : themes.light;
@@ -78,22 +81,7 @@ export const createPreview = <T extends Preview = Preview>(overrides?: T) => {
78
81
  * we want it to look.
79
82
  * @see https://storybook.js.org/docs/react/api/doc-block-source
80
83
  */
81
- transform: (sourceCode: string): string => {
82
- const replacements = [
83
- // replace event bindings with shortcut
84
- { searchValue: "v-on:", replaceValue: "@" },
85
- // remove empty event handlers, e.g. @click="()=>({})" will be removed
86
- { searchValue: / @.*['"]\(\)=>\({}\)['"]/g, replaceValue: "" },
87
- // remove empty v-binds, e.g. v-bind="{}" will be removed
88
- { searchValue: / v-bind=['"]{}['"]/g, replaceValue: "" },
89
- // replace boolean shortcuts for true, e.g. disabled="true" will be changed to just disabled
90
- { searchValue: /:(.*)=['"]true['"]/g, replaceValue: "$1" },
91
- ];
92
-
93
- return replacements.reduce((code, replacement) => {
94
- return replaceAll(code, replacement.searchValue, replacement.replaceValue);
95
- }, sourceCode);
96
- },
84
+ transform: sourceCodeTransformer,
97
85
  },
98
86
  },
99
87
  darkMode: {
@@ -129,10 +117,93 @@ export const createPreview = <T extends Preview = Preview>(overrides?: T) => {
129
117
  return deepmerge<[T, typeof defaultPreview]>(overrides ?? ({} as T), defaultPreview);
130
118
  };
131
119
 
120
+ /**
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
+ *
125
+ * @see https://storybook.js.org/docs/react/api/doc-block-source
126
+ */
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 additionalImports: 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
+ const escapedIconContent = `"${replaceAll(iconContent, '"', '\\"')}"`;
158
+
159
+ if (code.includes(iconContent)) {
160
+ code = code.replace(new RegExp(` (\\S+)=['"]${iconContent}['"]`), ` :$1="${importName}"`);
161
+ additionalImports.push(`import ${importName} from "@sit-onyx/icons/${iconName}.svg?raw";`);
162
+ } else if (code.includes(singleQuotedIconContent)) {
163
+ // support icons inside objects
164
+ code = code.replace(singleQuotedIconContent, importName);
165
+ additionalImports.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
+ additionalImports.push(`import ${importName} from "@sit-onyx/icons/${iconName}.svg?raw";`);
170
+ }
171
+ });
172
+
173
+ // add imports for all used onyx components
174
+ // Set is used here to only include unique components if they are used multiple times
175
+ const usedOnyxComponents = [
176
+ ...new Set(Array.from(code.matchAll(/<(Onyx\w+)(?:\s*\/?)/g)).map((match) => match[1])),
177
+ ].sort();
178
+
179
+ if (usedOnyxComponents.length > 0) {
180
+ additionalImports.unshift(`import { ${usedOnyxComponents.join(", ")} } from "sit-onyx";`);
181
+ }
182
+
183
+ if (additionalImports.length === 0) return code;
184
+
185
+ if (code.startsWith("<script")) {
186
+ const index = code.indexOf("\n");
187
+ const hasOtherImports = code.includes("import {");
188
+ return (
189
+ code.slice(0, index) +
190
+ additionalImports.join("\n") +
191
+ (!hasOtherImports ? "\n" : "") +
192
+ code.slice(index)
193
+ );
194
+ }
195
+
196
+ return `<script lang="ts" setup>
197
+ ${additionalImports.join("\n")}
198
+ </script>
199
+
200
+ ${code}`;
201
+ };
202
+
132
203
  /**
133
204
  * Custom String.replaceAll implementation using a RegExp
134
205
  * because String.replaceAll() is not available in our specified EcmaScript target in tsconfig.json
135
206
  */
136
- const replaceAll = (message: string, searchValue: string | RegExp, replaceValue: string) => {
137
- return message.replace(new RegExp(searchValue, "gi"), replaceValue);
207
+ export const replaceAll = (value: string, searchValue: string | RegExp, replaceValue: string) => {
208
+ return value.replace(new RegExp(searchValue, "gi"), replaceValue);
138
209
  };
@@ -0,0 +1,36 @@
1
+ import { type Decorator } from "@storybook/vue3";
2
+ import { ref, watch } from "vue";
3
+ import type { StorybookGlobalType } from "./types";
4
+
5
+ type RequiredIndicator = "required" | "optional";
6
+
7
+ export const requiredGlobalType = {
8
+ requiredMode: {
9
+ name: "Required mode",
10
+ description: "Switch between 'required' and 'optional' indicator",
11
+ defaultValue: "required",
12
+ toolbar: {
13
+ icon: "flag",
14
+ items: [
15
+ { value: "required", right: "*", title: "Required indicator" },
16
+ { value: "optional", right: "(optional)", title: "Optional indicator" },
17
+ ],
18
+ },
19
+ } satisfies StorybookGlobalType<RequiredIndicator>,
20
+ };
21
+
22
+ const requiredMode = ref<RequiredIndicator>("required");
23
+
24
+ export const withRequired: Decorator = (Story, context) => {
25
+ watch(
26
+ () => context.globals.requiredMode as RequiredIndicator,
27
+ (newRequiredMode) => (requiredMode.value = newRequiredMode),
28
+ { immediate: true },
29
+ );
30
+
31
+ return {
32
+ components: { Story },
33
+ setup: () => ({ requiredMode }),
34
+ template: `<div :class="{ ['onyx-use-optional']: requiredMode === 'optional' }"> <story /> </div>`,
35
+ };
36
+ };
@@ -0,0 +1,236 @@
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
+ generatePropsSourceCode,
10
+ generateSlotSourceCode,
11
+ generateSourceCode,
12
+ parseDocgenInfo,
13
+ type SourceCodeGeneratorContext,
14
+ } from "./source-code-generator";
15
+
16
+ test("should generate source code for props", () => {
17
+ const ctx: SourceCodeGeneratorContext = {
18
+ scriptVariables: {},
19
+ imports: {},
20
+ };
21
+
22
+ const code = generatePropsSourceCode(
23
+ {
24
+ a: "foo",
25
+ b: '"I am double quoted"',
26
+ c: 42,
27
+ d: true,
28
+ e: false,
29
+ f: [1, 2, 3],
30
+ g: {
31
+ g1: "foo",
32
+ g2: 42,
33
+ },
34
+ h: undefined,
35
+ i: null,
36
+ j: "",
37
+ k: BigInt(9007199254740991),
38
+ l: Symbol(),
39
+ m: Symbol("foo"),
40
+ modelValue: "test-v-model",
41
+ otherModelValue: 42,
42
+ default: "default slot",
43
+ testSlot: "test slot",
44
+ },
45
+ ["default", "testSlot"],
46
+ ["update:modelValue", "update:otherModelValue"],
47
+ ctx,
48
+ );
49
+
50
+ expect(code).toBe(
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"`,
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"]);
62
+ });
63
+
64
+ test("should generate source code for slots", () => {
65
+ // slot code generator should support primitive values (string, number etc.)
66
+ // but also VNodes (e.g. created using h()) so custom Vue components can also be used
67
+ // inside slots with proper generated code
68
+
69
+ const slots = {
70
+ default: "default content",
71
+ a: "a content",
72
+ b: 42,
73
+ c: true,
74
+ // single VNode without props
75
+ d: h("div", "d content"),
76
+ // VNode with props and single child
77
+ e: h("div", { style: "color:red" }, "e content"),
78
+ // VNode with props and single child returned as getter
79
+ f: h("div", { style: "color:red" }, () => "f content"),
80
+ // VNode with multiple children
81
+ g: h("div", { style: "color:red" }, [
82
+ "child 1",
83
+ h("span", { style: "color:green" }, "child 2"),
84
+ ]),
85
+ // VNode multiple children but returned as getter
86
+ h: h("div", { style: "color:red" }, () => [
87
+ "child 1",
88
+ h("span", { style: "color:green" }, "child 2"),
89
+ ]),
90
+ // VNode with multiple and nested children
91
+ i: h("div", { style: "color:red" }, [
92
+ "child 1",
93
+ h("span", { style: "color:green" }, ["nested child 1", h("p", "nested child 2")]),
94
+ ]),
95
+ j: ["child 1", "child 2"],
96
+ k: null,
97
+ l: { foo: "bar" },
98
+ m: BigInt(9007199254740991),
99
+ };
100
+
101
+ const expectedCode = `default content
102
+
103
+ <template #a>a content</template>
104
+
105
+ <template #b>42</template>
106
+
107
+ <template #c>true</template>
108
+
109
+ <template #d><div>d content</div></template>
110
+
111
+ <template #e><div style="color:red">e content</div></template>
112
+
113
+ <template #f><div style="color:red">f content</div></template>
114
+
115
+ <template #g><div style="color:red">child 1
116
+ <span style="color:green">child 2</span></div></template>
117
+
118
+ <template #h><div style="color:red">child 1
119
+ <span style="color:green">child 2</span></div></template>
120
+
121
+ <template #i><div style="color:red">child 1
122
+ <span style="color:green">nested child 1
123
+ <p>nested child 2</p></span></div></template>
124
+
125
+ <template #j>child 1
126
+ child 2</template>
127
+
128
+ <template #l>{"foo":"bar"}</template>
129
+
130
+ <template #m>{{ BigInt(9007199254740991) }}</template>`;
131
+
132
+ let actualCode = generateSlotSourceCode(slots, Object.keys(slots), {
133
+ scriptVariables: {},
134
+ imports: {},
135
+ });
136
+ expect(actualCode).toBe(expectedCode);
137
+
138
+ // should generate the same code if getters/functions are used to return the slot content
139
+ const slotsWithGetters = Object.entries(slots).reduce<
140
+ Record<string, () => (typeof slots)[keyof typeof slots]>
141
+ >((obj, [slotName, value]) => {
142
+ obj[slotName] = () => value;
143
+ return obj;
144
+ }, {});
145
+
146
+ actualCode = generateSlotSourceCode(slotsWithGetters, Object.keys(slotsWithGetters), {
147
+ scriptVariables: {},
148
+ imports: {},
149
+ });
150
+ expect(actualCode).toBe(expectedCode);
151
+ });
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
+
210
+ test.each([
211
+ { __docgenInfo: "invalid-value", slotNames: [] },
212
+ { __docgenInfo: {}, slotNames: [] },
213
+ { __docgenInfo: { slots: "invalid-value" }, slotNames: [] },
214
+ { __docgenInfo: { slots: ["invalid-value"] }, slotNames: [] },
215
+ {
216
+ __docgenInfo: { slots: [{ name: "slot-1" }, { name: "slot-2" }, { notName: "slot-3" }] },
217
+ slotNames: ["slot-1", "slot-2"],
218
+ },
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);
236
+ });
@@ -0,0 +1,523 @@
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 { 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
+ 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
+ };
package/src/theme.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import type { ThemeVars, ThemeVarsPartial } from "@storybook/theming";
2
2
  import { create } from "@storybook/theming/create";
3
3
  import onyxVariables from "sit-onyx/src/styles/variables-onyx.json";
4
+ import { ONYX_BREAKPOINTS as RAW_ONYX_BREAKPOINTS, type OnyxBreakpoint } from "sit-onyx/types";
4
5
  import onyxLogo from "./assets/logo-onyx.svg";
5
6
 
6
7
  /**
@@ -90,50 +91,22 @@ const defineTheme = (colors: {
90
91
  };
91
92
 
92
93
  /** All available Storybook breakpoints / viewports supported by onyx. */
93
- export const ONYX_BREAKPOINTS = {
94
- "2xs": {
95
- name: "2xs",
96
- styles: {
97
- width: "320px",
98
- height: "100%",
99
- },
94
+ export const ONYX_BREAKPOINTS = Object.entries(RAW_ONYX_BREAKPOINTS).reduce(
95
+ (obj, [name, width]) => {
96
+ const breakpoint = name as OnyxBreakpoint;
97
+ obj[breakpoint] = { name: breakpoint, styles: { width: `${width}px`, height: "100%" } };
98
+ return obj;
100
99
  },
101
- xs: {
102
- name: "xs",
103
- styles: {
104
- width: "576px",
105
- height: "100%",
106
- },
107
- },
108
- sm: {
109
- name: "sm",
110
- styles: {
111
- width: "768px",
112
- height: "100%",
113
- },
114
- },
115
- md: {
116
- name: "md",
117
- styles: {
118
- width: "992px",
119
- height: "100%",
120
- },
121
- },
122
- lg: {
123
- name: "lg",
124
- styles: {
125
- width: "1440px",
126
- height: "100%",
127
- },
128
- },
129
- xl: {
130
- name: "xl",
131
- styles: {
132
- width: "1920px",
133
- height: "100%",
134
- },
135
- },
136
- } as const;
100
+ {} as Record<OnyxBreakpoint, StorybookBreakpoint>,
101
+ );
102
+
103
+ export type StorybookBreakpoint = {
104
+ name: OnyxBreakpoint;
105
+ styles: {
106
+ width: string;
107
+ height: string;
108
+ };
109
+ };
137
110
 
138
111
  /**
139
112
  * Converts a rem string into a numeric value with a rem base of 16.
package/src/types.ts CHANGED
@@ -46,3 +46,13 @@ export type DefineStorybookActionsAndVModelsOptions<T> = Meta<T> & {
46
46
  component: NonNullable<T>;
47
47
  events: ExtractVueEventNames<T>[];
48
48
  };
49
+
50
+ export type StorybookGlobalType<TValue> = {
51
+ name: string;
52
+ description: string;
53
+ defaultValue: TValue;
54
+ toolbar: {
55
+ icon: string;
56
+ items: { value: TValue; right: string; title: string }[];
57
+ };
58
+ };