From f1b8e7dafcb84309c47942817b0d9ae295325c19 Mon Sep 17 00:00:00 2001 From: elisezhg Date: Fri, 26 Jul 2024 20:23:49 -0400 Subject: [PATCH 01/51] export getPreferredColorScheme --- code/core/src/manager/globals/exports.ts | 3 +++ code/core/src/theming/index.ts | 2 +- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/code/core/src/manager/globals/exports.ts b/code/core/src/manager/globals/exports.ts index f83d30717328..0463ad2eee9d 100644 --- a/code/core/src/manager/globals/exports.ts +++ b/code/core/src/manager/globals/exports.ts @@ -663,6 +663,7 @@ export default { 'css', 'darken', 'ensure', + 'getPreferredColorScheme', 'ignoreSsrWarning', 'isPropValid', 'jsx', @@ -689,6 +690,7 @@ export default { 'css', 'darken', 'ensure', + 'getPreferredColorScheme', 'ignoreSsrWarning', 'isPropValid', 'jsx', @@ -715,6 +717,7 @@ export default { 'css', 'darken', 'ensure', + 'getPreferredColorScheme', 'ignoreSsrWarning', 'isPropValid', 'jsx', diff --git a/code/core/src/theming/index.ts b/code/core/src/theming/index.ts index c533e8387803..1f4397863bd7 100644 --- a/code/core/src/theming/index.ts +++ b/code/core/src/theming/index.ts @@ -40,7 +40,7 @@ export * from './create'; export * from './convert'; export * from './ensure'; -export { lightenColor as lighten, darkenColor as darken } from './utils'; +export { lightenColor as lighten, darkenColor as darken, getPreferredColorScheme } from './utils'; export const ignoreSsrWarning = '/* emotion-disable-server-rendering-unsafe-selector-warning-please-do-not-use-this-the-warning-exists-for-a-reason */'; From 7ca451640ca5ddb0a0e6d8a2858815a3c2a9da24 Mon Sep 17 00:00:00 2001 From: elisezhg Date: Fri, 26 Jul 2024 22:19:31 -0400 Subject: [PATCH 02/51] set themes.normal according to user preference --- code/core/src/theming/create.ts | 18 +++++++++++---- code/core/src/theming/tests/create.test.js | 27 ++++++++++++++++++++++ 2 files changed, 41 insertions(+), 4 deletions(-) diff --git a/code/core/src/theming/create.ts b/code/core/src/theming/create.ts index d28769b5eb06..d05c696d0e31 100644 --- a/code/core/src/theming/create.ts +++ b/code/core/src/theming/create.ts @@ -5,18 +5,28 @@ import darkThemeVars from './themes/dark'; import type { ThemeVars, ThemeVarsPartial } from './types'; import { getPreferredColorScheme } from './utils'; -export const themes: { light: ThemeVars; dark: ThemeVars; normal: ThemeVars } = { +interface Themes { + light: ThemeVars; + dark: ThemeVars; + normal: ThemeVars; +} + +const themesBase: Omit = { light: lightThemeVars, dark: darkThemeVars, - normal: lightThemeVars, +}; + +const preferredColorScheme = getPreferredColorScheme(); + +export const themes: Themes = { + ...themesBase, + normal: themesBase[preferredColorScheme], }; interface Rest { [key: string]: any; } -const preferredColorScheme = getPreferredColorScheme(); - export const create = ( vars: ThemeVarsPartial = { base: preferredColorScheme }, rest?: Rest diff --git a/code/core/src/theming/tests/create.test.js b/code/core/src/theming/tests/create.test.js index b5f037e0a2e0..46f820295da4 100644 --- a/code/core/src/theming/tests/create.test.js +++ b/code/core/src/theming/tests/create.test.js @@ -1,5 +1,10 @@ import { describe, it, expect } from 'vitest'; import { create } from '../create'; +import { getPreferredColorScheme } from './../utils'; + +vi.mock('./../utils', () => ({ + getPreferredColorScheme: vi.fn().mockReturnValue('light'), +})); describe('create base', () => { it('should create a theme with minimal viable theme', () => { @@ -141,3 +146,25 @@ describe('create extend', () => { expect(result.base).toEqual('light'); }); }); + +describe('themes', () => { + beforeEach(() => { + vi.resetModules(); + }); + + it('should set `normal` to `light` theme when user preference is `light`', async () => { + getPreferredColorScheme.mockReturnValue('light'); + + const { themes } = await import('./../create'); + + expect(themes.normal).toBe(themes.light); + }); + + it('should set `normal` to `dark` theme when user preference is `dark`', async () => { + getPreferredColorScheme.mockReturnValue('dark'); + + const { themes } = await import('./../create'); + + expect(themes.normal).toBe(themes.dark); + }); +}); From cb96d28f87c7def566dab6c4c91a0ea17ca394e4 Mon Sep 17 00:00:00 2001 From: Steve Dodier-Lazaro Date: Mon, 13 Oct 2025 19:59:00 +0200 Subject: [PATCH 03/51] Docs: Update theming doc --- docs/configure/user-interface/theming.mdx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/configure/user-interface/theming.mdx b/docs/configure/user-interface/theming.mdx index d1b78d791399..bf264e2235f8 100644 --- a/docs/configure/user-interface/theming.mdx +++ b/docs/configure/user-interface/theming.mdx @@ -11,7 +11,7 @@ Storybook is theme-able using a lightweight theming API. It's possible to theme Storybook globally. -Storybook includes two themes that look good out of the box: "light" and "dark". Unless you've set your preferred color scheme as dark, Storybook will use the light theme as default. +Storybook includes two themes that look good out of the box: "light" and "dark". It also includes the "normal" theme which applies the "light" or "dark" one based on your preferred color scheme. Unless you've explicitly set a theme, Storybook will use the normal theme as default. As an example, you can tell Storybook to use the "dark" theme by modifying [`.storybook/manager.js`](./features-and-behavior.mdx): @@ -25,7 +25,7 @@ When setting a theme, set a complete theme object. The theme is replaced, not co ## Theming docs -[Storybook Docs](../../writing-docs/index.mdx) uses the same theme system as Storybook’s UI but is themed independently from the main UI. +[Storybook Docs](../../writing-docs/index.mdx) uses the same theme system as Storybook’s UI but is themed independently from the main UI. The default theme for Docs is always the "light" theme, regardless of the main UI theme. Supposing you have a Storybook theme defined for the main UI in [`.storybook/manager.js`](./features-and-behavior.mdx): From c7d0a67a05e45872fdb8a9133d9d525e301c1705 Mon Sep 17 00:00:00 2001 From: Steve Dodier-Lazaro Date: Mon, 13 Oct 2025 20:05:21 +0200 Subject: [PATCH 04/51] Tests: Fix missing import in PR code --- code/core/src/theming/tests/create.test.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/code/core/src/theming/tests/create.test.js b/code/core/src/theming/tests/create.test.js index 379209dc0377..f0d53234c620 100644 --- a/code/core/src/theming/tests/create.test.js +++ b/code/core/src/theming/tests/create.test.js @@ -1,4 +1,4 @@ -import { describe, expect, it } from 'vitest'; +import { describe, expect, it, vi } from 'vitest'; import { create } from '../create'; import { getPreferredColorScheme } from './../utils'; From 7760b5b673b6f3890a4087e5eaed680ae1642445 Mon Sep 17 00:00:00 2001 From: Steve Dodier-Lazaro Date: Mon, 13 Oct 2025 20:18:36 +0200 Subject: [PATCH 05/51] Tests: Fix missing import in PR code --- code/core/src/theming/tests/create.test.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/code/core/src/theming/tests/create.test.js b/code/core/src/theming/tests/create.test.js index f0d53234c620..1236300bc9f0 100644 --- a/code/core/src/theming/tests/create.test.js +++ b/code/core/src/theming/tests/create.test.js @@ -1,4 +1,4 @@ -import { describe, expect, it, vi } from 'vitest'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; import { create } from '../create'; import { getPreferredColorScheme } from './../utils'; From 3e1f9fd11f614890acd2f7ece821e1cac475ed71 Mon Sep 17 00:00:00 2001 From: Steve Dodier-Lazaro Date: Mon, 27 Oct 2025 13:12:39 +0100 Subject: [PATCH 06/51] Update docs/configure/user-interface/theming.mdx Co-authored-by: jonniebigodes --- docs/configure/user-interface/theming.mdx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/configure/user-interface/theming.mdx b/docs/configure/user-interface/theming.mdx index bf264e2235f8..4af2f214d2fd 100644 --- a/docs/configure/user-interface/theming.mdx +++ b/docs/configure/user-interface/theming.mdx @@ -11,7 +11,7 @@ Storybook is theme-able using a lightweight theming API. It's possible to theme Storybook globally. -Storybook includes two themes that look good out of the box: "light" and "dark". It also includes the "normal" theme which applies the "light" or "dark" one based on your preferred color scheme. Unless you've explicitly set a theme, Storybook will use the normal theme as default. +Storybook includes a set of built-in themes that you can use to customize the appearance of your Storybook UI. The built-in themes are light, dark, and the "normal" theme that matches your preferred color scheme. Unless you specify otherwise, Storybook uses the normal theme by default. As an example, you can tell Storybook to use the "dark" theme by modifying [`.storybook/manager.js`](./features-and-behavior.mdx): From 069e59fe532ceb3f373c938db2e44abaadf721cb Mon Sep 17 00:00:00 2001 From: silverwind Date: Wed, 29 Oct 2025 12:27:13 +0100 Subject: [PATCH 07/51] Docs: Specify boolean env var as `true` instead of `1` --- docs/_snippets/storybook-disable-telemetry-env.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/_snippets/storybook-disable-telemetry-env.md b/docs/_snippets/storybook-disable-telemetry-env.md index a6b2a0a94a45..a314e0bb2885 100644 --- a/docs/_snippets/storybook-disable-telemetry-env.md +++ b/docs/_snippets/storybook-disable-telemetry-env.md @@ -1,3 +1,3 @@ ```shell renderer="common" language="js" -STORYBOOK_DISABLE_TELEMETRY=1 yarn storybook +STORYBOOK_DISABLE_TELEMETRY=true yarn storybook ``` From a5f4646e38ae127c18adbfc1cf167370f9282e72 Mon Sep 17 00:00:00 2001 From: Kasper Peulen Date: Fri, 31 Oct 2025 15:52:23 +0100 Subject: [PATCH 08/51] Add automatic component imports --- .../src/componentManifest/generator.test.ts | 213 +++++++- .../react/src/componentManifest/generator.ts | 18 +- .../getComponentImports.test.ts | 464 ++++++++++++++++++ .../componentManifest/getComponentImports.ts | 204 ++++++++ 4 files changed, 886 insertions(+), 13 deletions(-) create mode 100644 code/renderers/react/src/componentManifest/getComponentImports.test.ts create mode 100644 code/renderers/react/src/componentManifest/getComponentImports.ts diff --git a/code/renderers/react/src/componentManifest/generator.test.ts b/code/renderers/react/src/componentManifest/generator.test.ts index fa13535eb68c..4eafcc9972c4 100644 --- a/code/renderers/react/src/componentManifest/generator.test.ts +++ b/code/renderers/react/src/componentManifest/generator.test.ts @@ -7,6 +7,20 @@ import { dedent } from 'ts-dedent'; import { componentManifestGenerator } from './generator'; +vi.mock('storybook/internal/common', async (importOriginal) => { + return { + ...(await importOriginal()), + JsPackageManagerFactory: { + getPackageManager: () => ({ + primaryPackageJson: { + packageJson: { + name: 'some-package', + }, + }, + }), + }, + }; +}); vi.mock('node:fs/promises', async () => (await import('memfs')).fs.promises); vi.mock('node:fs', async () => (await import('memfs')).fs); vi.mock('tsconfig-paths', () => ({ loadConfig: () => ({ resultType: null!, message: null! }) })); @@ -205,8 +219,8 @@ beforeEach(() => { }); test('componentManifestGenerator generates correct id, name, description and examples ', async () => { - const generator = await componentManifestGenerator(); - const manifest = await generator({ + const generator = await componentManifestGenerator(undefined, { configDir: '.storybook' } as any); + const manifest = await generator?.({ getIndex: async () => indexJson, } as unknown as StoryIndexGenerator); @@ -217,7 +231,7 @@ test('componentManifestGenerator generates correct id, name, description and exa "description": "Primary UI component for user interaction", "error": undefined, "id": "example-button", - "import": undefined, + "import": "import { Button } from "some-package";", "jsDocTags": {}, "name": "Button", "path": "./src/stories/Button.stories.ts", @@ -321,7 +335,7 @@ test('componentManifestGenerator generates correct id, name, description and exa "description": "Description from meta and very long.", "error": undefined, "id": "example-header", - "import": "import { Header } from '@design-system/components/Header';", + "import": "import Header from "some-package";", "jsDocTags": { "import": [ "import { Header } from '@design-system/components/Header';", @@ -441,7 +455,7 @@ async function getManifestForStory(code: string) { '/app' ); - const generator = await componentManifestGenerator(); + const generator = await componentManifestGenerator(undefined, { configDir: '.storybook' } as any); const indexJson = { v: 5, entries: { @@ -459,11 +473,11 @@ async function getManifestForStory(code: string) { }, }; - const manifest = await generator({ + const manifest = await generator?.({ getIndex: async () => indexJson, } as unknown as StoryIndexGenerator); - return manifest.components['example-button']; + return manifest?.components?.['example-button']; } function withCSF3(body: string) { @@ -497,7 +511,7 @@ test('fall back to index title when no component name', async () => { "description": "Primary UI component for user interaction", "error": undefined, "id": "example-button", - "import": undefined, + "import": "import { Button } from "some-package";", "jsDocTags": {}, "name": "Button", "path": "./src/stories/Button.stories.ts", @@ -542,7 +556,7 @@ test('component exported from other file', async () => { "description": "Primary UI component for user interaction", "error": undefined, "id": "example-button", - "import": undefined, + "import": "import { Button } from "some-package";", "jsDocTags": {}, "name": "Button", "path": "./src/stories/Button.stories.ts", @@ -594,7 +608,7 @@ test('unknown expressions', async () => { "description": "Primary UI component for user interaction", "error": undefined, "id": "example-button", - "import": undefined, + "import": "import { Button } from "some-package";", "jsDocTags": {}, "name": "Button", "path": "./src/stories/Button.stories.ts", @@ -636,3 +650,182 @@ test('unknown expressions', async () => { } `); }); + +test('unknown expressions', async () => { + const code = dedent` +import ActionBar from '.' +import { + BoldIcon, + CodeIcon, + ItalicIcon, + SearchIcon, + LinkIcon, + FileAddedIcon, + QuoteIcon, + ListUnorderedIcon, + ListOrderedIcon, + TasklistIcon, +} from '@primer/octicons-react' +import type {Meta, StoryObj} from '@storybook/react-vite' + +const meta: Meta = { + title: 'Experimental/Components/ActionBar', +} as Meta + +export default meta +type Story = StoryObj + +export const Playground: Story = { + render: ({'aria-labelledby': _, ...args}) => ( + + + + + + + ), +} +Playground.argTypes = { + size: { + control: { + type: 'radio', + }, + options: ['small', 'medium', 'large'], + }, + flush: { + control: { + type: 'boolean', + }, + }, + gap: { + control: {type: 'radio'}, + options: ['none', 'condensed'], + description: 'Horizontal gap scale between items', + table: {defaultValue: {summary: 'condensed'}}, + }, +} +Playground.args = { + size: 'medium', + flush: false, + gap: 'condensed', +} + +export const Default = () => ( + + + + + + + + + + + + + +) + +const BoldButton = () => + +const FormattingButtons = () => ( + <> + + + + + +) + +const AdvancedFormattingButtons = () => ( + <> + + + + + + + +) + +export const DeepChildTree = () => ( + + + + + +) + `; + expect(await getManifestForStory(code)).toMatchInlineSnapshot(` + { + "description": "Primary UI component for user interaction", + "error": undefined, + "id": "example-button", + "import": "import ActionBar from "some-package";", + "jsDocTags": {}, + "name": "Button", + "path": "./src/stories/Button.stories.ts", + "reactDocgen": { + "actualName": "Button", + "definedInFile": "/app/src/stories/Button.tsx", + "description": "Primary UI component for user interaction", + "displayName": "Button", + "exportName": "Button", + "methods": [], + "props": { + "primary": { + "defaultValue": { + "computed": false, + "value": "false", + }, + "description": "Description of primary", + "required": false, + "tsType": { + "name": "boolean", + }, + }, + }, + }, + "stories": [ + { + "name": "Playground", + "snippet": "const Playground = () => + + + + + ;", + }, + { + "name": "Default", + "snippet": "const Default = () => ( + + + + + + + + + + + + + + );", + }, + { + "name": "DeepChildTree", + "snippet": "const DeepChildTree = () => ( + + + + + + );", + }, + ], + "summary": undefined, + } + `); +}); diff --git a/code/renderers/react/src/componentManifest/generator.ts b/code/renderers/react/src/componentManifest/generator.ts index 2f1a1df70f73..b033edba872e 100644 --- a/code/renderers/react/src/componentManifest/generator.ts +++ b/code/renderers/react/src/componentManifest/generator.ts @@ -1,14 +1,16 @@ import { readFile } from 'node:fs/promises'; import { recast } from 'storybook/internal/babel'; +import { JsPackageManagerFactory } from 'storybook/internal/common'; import { loadCsf } from 'storybook/internal/csf-tools'; import { extractDescription } from 'storybook/internal/csf-tools'; -import { type ComponentManifestGenerator } from 'storybook/internal/types'; +import { type ComponentManifestGenerator, type PresetPropertyFn } from 'storybook/internal/types'; import { type ComponentManifest } from 'storybook/internal/types'; import path from 'pathe'; import { getCodeSnippet } from './generateCodeSnippet'; +import { getComponentImports } from './getComponentImports'; import { extractJSDocInfo } from './jsdocTags'; import { type DocObj, getMatchingDocgen, parseWithReactDocgen } from './reactDocgen'; import { groupBy, invariant } from './utils'; @@ -17,7 +19,12 @@ interface ReactComponentManifest extends ComponentManifest { reactDocgen?: DocObj; } -export const componentManifestGenerator = async () => { +export const componentManifestGenerator: PresetPropertyFn< + 'experimental_componentManifestGenerator' +> = async (config, options) => { + const packageManager = JsPackageManagerFactory.getPackageManager({ + configDir: options.configDir, + }); return (async (storyIndexGenerator) => { const index = await storyIndexGenerator.getIndex(); @@ -30,6 +37,7 @@ export const componentManifestGenerator = async () => { const singleEntryPerComponent = Object.values(groupByComponentId).flatMap((group) => group && group?.length > 0 ? [group[0]] : [] ); + const packageName = packageManager.primaryPackageJson.packageJson.name; const components = await Promise.all( singleEntryPerComponent.flatMap(async (entry): Promise => { const storyFile = await readFile(path.join(process.cwd(), entry.importPath), 'utf-8'); @@ -55,11 +63,16 @@ export const componentManifestGenerator = async () => { }) .filter(Boolean); + const fallbackImport = packageName ? `import { ${name} } from "${packageName}";` : ''; + const calculatedImports = + getComponentImports(csf, packageName).imports.join('\n').trim() ?? fallbackImport; + const base = { id, name, path: importPath, stories, + import: calculatedImports, jsDocTags: {}, } satisfies Partial; @@ -133,7 +146,6 @@ export const componentManifestGenerator = async () => { name, description: manifestDescription?.trim(), summary: tags.summary?.[0], - import: tags.import?.[0], reactDocgen: docgen, jsDocTags: tags, stories, diff --git a/code/renderers/react/src/componentManifest/getComponentImports.test.ts b/code/renderers/react/src/componentManifest/getComponentImports.test.ts new file mode 100644 index 000000000000..a82011855101 --- /dev/null +++ b/code/renderers/react/src/componentManifest/getComponentImports.test.ts @@ -0,0 +1,464 @@ +import { expect, test } from 'vitest'; + +import { loadCsf } from 'storybook/internal/csf-tools'; + +import { dedent } from 'ts-dedent'; + +import { getComponentImports } from './getComponentImports'; + +const getImports = (code: string, packageName?: string) => + getComponentImports( + loadCsf(code, { makeTitle: (t?: string) => t ?? 'title' }).parse(), + packageName + ); + +test('Get imports from multiple components', () => { + const code = dedent` + import type { Meta } from '@storybook/react'; + import { ButtonGroup } from '@design-system/button-group'; + import { Button } from '@design-system/button'; + + const meta: Meta = { + component: Button, + args: { + children: 'Click me' + } + }; + export default meta; + export const Default: Story = ; + `; + expect(getImports(code)).toMatchInlineSnapshot( + ` + { + "components": [ + "Button", + "ButtonGroup", + ], + "imports": [ + "import { ButtonGroup } from '@design-system/button-group';", + "import { Button } from '@design-system/button';", + ], + } + ` + ); +}); + +test('Namespace import with member usage', () => { + const code = dedent` + import * as Accordion from '@ds/accordion'; + + const meta = {}; + export default meta; + export const S = Hi; + `; + expect(getImports(code)).toMatchInlineSnapshot( + ` + { + "components": [ + "Accordion.Root", + ], + "imports": [ + "import * as Accordion from '@ds/accordion';", + ], + } + ` + ); +}); + +test('Named import used as namespace object', () => { + const code = dedent` + import { Accordion } from '@ds/accordion'; + + const meta = {}; + export default meta; + export const S = Hi; + `; + expect(getImports(code)).toMatchInlineSnapshot( + ` + { + "components": [ + "Accordion.Root", + ], + "imports": [ + "import { Accordion } from '@ds/accordion';", + ], + } + ` + ); +}); + +test('Default import', () => { + const code = dedent` + import Button from '@ds/button'; + + const meta = {}; + export default meta; + export const S = ; `; - expect(getImports(code)).toMatchInlineSnapshot( + expect(await getImports(code)).toMatchInlineSnapshot( ` { "components": [ @@ -36,12 +36,14 @@ test('Get imports from multiple components', () => { "importId": "@design-system/button", "importName": "Button", "localImportName": "Button", + "path": undefined, }, { "componentName": "ButtonGroup", "importId": "@design-system/button-group", "importName": "ButtonGroup", "localImportName": "ButtonGroup", + "path": undefined, }, ], "imports": [ @@ -53,7 +55,7 @@ test('Get imports from multiple components', () => { ); }); -test('Namespace import with member usage', () => { +test('Namespace import with member usage', async () => { const code = dedent` import * as Accordion from '@ds/accordion'; @@ -61,7 +63,7 @@ test('Namespace import with member usage', () => { export default meta; export const S = Hi; `; - expect(getImports(code)).toMatchInlineSnapshot( + expect(await getImports(code)).toMatchInlineSnapshot( ` { "components": [ @@ -71,6 +73,7 @@ test('Namespace import with member usage', () => { "importName": "Root", "localImportName": "Accordion", "namespace": "Accordion", + "path": undefined, }, ], "imports": [ @@ -81,7 +84,7 @@ test('Namespace import with member usage', () => { ); }); -test('Named import used as namespace object', () => { +test('Named import used as namespace object', async () => { const code = dedent` import { Accordion } from '@ds/accordion'; @@ -89,7 +92,7 @@ test('Named import used as namespace object', () => { export default meta; export const S = Hi; `; - expect(getImports(code)).toMatchInlineSnapshot( + expect(await getImports(code)).toMatchInlineSnapshot( ` { "components": [ @@ -98,6 +101,7 @@ test('Named import used as namespace object', () => { "importId": "@ds/accordion", "importName": "Accordion", "localImportName": "Accordion", + "path": undefined, }, ], "imports": [ @@ -108,7 +112,7 @@ test('Named import used as namespace object', () => { ); }); -test('Default import', () => { +test('Default import', async () => { const code = dedent` import Button from '@ds/button'; @@ -116,7 +120,7 @@ test('Default import', () => { export default meta; export const S = - ); - };`, - ['./src/stories/Header.stories.ts']: dedent` - import type { Meta, StoryObj } from '@storybook/react'; - import { fn } from 'storybook/test'; - import Header from './Header'; - - /** - * Description from meta and very long. - * @summary Component summary - * @import import { Header } from '@design-system/components/Header'; - */ - const meta = { - component: Header, - args: { - onLogin: fn(), - onLogout: fn(), - onCreateAccount: fn(), - } - } satisfies Meta; - export default meta; - type Story = StoryObj; - export const LoggedIn: Story = { args: { user: { name: 'Jane Doe' } } }; - export const LoggedOut: Story = {}; - `, - ['./src/stories/Header.tsx']: dedent` - import { Button } from './Button'; - - export interface HeaderProps { - user?: User; - onLogin?: () => void; - onLogout?: () => void; - onCreateAccount?: () => void; - } - - export default ({ user, onLogin, onLogout, onCreateAccount }: HeaderProps) => ( -
-
-
- {user ? ( - <> - - Welcome, {user.name}! - -
-
-
- );`, - }, - '/app' - ); + vol.fromJSON(fsMocks); return () => vol.reset(); }); @@ -243,14 +136,19 @@ test('componentManifestGenerator generates correct id, name, description and exa "description": "Primary UI component for user interaction", "error": undefined, "id": "example-button", - "import": "import { Button } from "some-package";", - "jsDocTags": {}, + "import": "import { Button } from '@design-system/components/Button';", + "jsDocTags": { + "import": [ + "import { Button } from '@design-system/components/Button';", + ], + }, "name": "Button", "path": "./src/stories/Button.stories.ts", "reactDocgen": { "actualName": "Button", - "definedInFile": "/app/src/stories/Button.tsx", - "description": "Primary UI component for user interaction", + "definedInFile": "./src/stories/Button.tsx", + "description": "Primary UI component for user interaction + @import import { Button } from '@design-system/components/Button';", "displayName": "Button", "exportName": "Button", "methods": [], @@ -347,7 +245,7 @@ test('componentManifestGenerator generates correct id, name, description and exa "description": "Description from meta and very long.", "error": undefined, "id": "example-header", - "import": "import { Header } from "some-package";", + "import": "import { Header } from '@design-system/components/Header';", "jsDocTags": { "import": [ "import { Header } from '@design-system/components/Header';", @@ -360,8 +258,8 @@ test('componentManifestGenerator generates correct id, name, description and exa "path": "./src/stories/Header.stories.ts", "reactDocgen": { "actualName": "", - "definedInFile": "/app/src/stories/Header.tsx", - "description": "", + "definedInFile": "./src/stories/Header.tsx", + "description": "@import import { Header } from '@design-system/components/Header';", "exportName": "default", "methods": [], "props": { @@ -530,7 +428,7 @@ test('fall back to index title when no component name', async () => { "path": "./src/stories/Button.stories.ts", "reactDocgen": { "actualName": "Button", - "definedInFile": "/app/src/stories/Button.tsx", + "definedInFile": "./src/stories/Button.tsx", "description": "Primary UI component for user interaction", "displayName": "Button", "exportName": "Button", @@ -575,7 +473,7 @@ test('component exported from other file', async () => { "path": "./src/stories/Button.stories.ts", "reactDocgen": { "actualName": "Button", - "definedInFile": "/app/src/stories/Button.tsx", + "definedInFile": "./src/stories/Button.tsx", "description": "Primary UI component for user interaction", "displayName": "Button", "exportName": "Button", @@ -627,7 +525,7 @@ test('unknown expressions', async () => { "path": "./src/stories/Button.stories.ts", "reactDocgen": { "actualName": "Button", - "definedInFile": "/app/src/stories/Button.tsx", + "definedInFile": "./src/stories/Button.tsx", "description": "Primary UI component for user interaction", "displayName": "Button", "exportName": "Button", diff --git a/code/renderers/react/src/componentManifest/generator.ts b/code/renderers/react/src/componentManifest/generator.ts index 4d00358bd92b..f6c3f9b04dc4 100644 --- a/code/renderers/react/src/componentManifest/generator.ts +++ b/code/renderers/react/src/componentManifest/generator.ts @@ -38,10 +38,14 @@ export const componentManifestGenerator: PresetPropertyFn< group && group?.length > 0 ? [group[0]] : [] ); const components = await Promise.all( - singleEntryPerComponent.flatMap(async (entry): Promise => { + singleEntryPerComponent.map(async (entry): Promise => { const absoluteImportPath = path.join(process.cwd(), entry.importPath); const storyFile = await readFile(absoluteImportPath, 'utf-8'); const csf = loadCsf(storyFile, { makeTitle: (title) => title ?? 'No title' }).parse(); + + if (csf.meta.tags?.includes('!manifest')) { + return; + } let componentName = csf._meta?.component; const title = entry.title.replace(/\s+/g, ''); @@ -87,10 +91,20 @@ export const componentManifestGenerator: PresetPropertyFn< const stories = Object.keys(csf._stories) .map((storyName) => { + const story = csf._stories[storyName]; + if (story.tags?.includes('!manifest')) { + return; + } try { + const jsdocComment = extractDescription(csf._storyStatements[storyName]); + const { tags = {}, description } = jsdocComment ? extractJSDocInfo(jsdocComment) : {}; + const finalDescription = (tags?.describe?.[0] || tags?.desc?.[0]) ?? description; + return { name: storyName, snippet: recast.print(getCodeSnippet(csf, storyName, componentName)).code, + description: finalDescription?.trim(), + summary: tags.summary?.[0], }; } catch (e) { invariant(e instanceof Error); @@ -100,7 +114,7 @@ export const componentManifestGenerator: PresetPropertyFn< }; } }) - .filter(Boolean); + .filter((it) => it != null); const base = { id, diff --git a/code/renderers/react/src/componentManifest/getComponentImports.test.ts b/code/renderers/react/src/componentManifest/getComponentImports.test.ts index c3ad6361ecd8..8f2bf0675178 100644 --- a/code/renderers/react/src/componentManifest/getComponentImports.test.ts +++ b/code/renderers/react/src/componentManifest/getComponentImports.test.ts @@ -1,15 +1,39 @@ -import { expect, test } from 'vitest'; +import { beforeEach, expect, test, vi } from 'vitest'; import { loadCsf } from 'storybook/internal/csf-tools'; +import { vol } from 'memfs'; import { dedent } from 'ts-dedent'; import { getComponentImports } from './getComponentImports'; +import { fsMocks } from './test-utils'; + +vi.mock('node:fs/promises', async () => (await import('memfs')).fs.promises); +vi.mock('node:fs', async () => (await import('memfs')).fs); +vi.mock('tsconfig-paths', () => ({ loadConfig: () => ({ resultType: null!, message: null! }) })); + +// Mock resolveImport to deterministically resolve known relative imports for these tests +vi.mock('storybook/internal/common', async (importOriginal) => ({ + ...(await importOriginal()), + resolveImport: (id: string) => { + return { + './Button': './src/stories/Button.tsx', + './Header': './src/stories/Header.tsx', + }[id]; + }, +})); + +beforeEach(() => { + vi.spyOn(process, 'cwd').mockReturnValue('/app'); + vol.fromJSON(fsMocks, '/app'); + return () => vol.reset(); +}); -const getImports = (code: string, packageName?: string) => +const getImports = (code: string, packageName?: string, storyFilePath?: string) => getComponentImports({ csf: loadCsf(code, { makeTitle: (t?: string) => t ?? 'title' }).parse(), packageName, + storyFilePath, }); test('Get imports from multiple components', async () => { @@ -454,21 +478,201 @@ test('Converts default relative import to named when packageName provided', asyn export default meta; export const S =
; `; - expect(getImports(code, 'my-package')).toMatchInlineSnapshot( - `Promise {}` + expect( + await getImports(code, 'my-package', '/app/src/stories/Header.stories.tsx') + ).toMatchInlineSnapshot( + ` + { + "components": [ + { + "componentName": "Header", + "importId": "./Header", + "importName": "default", + "importOverride": "import { Header } from '@design-system/components/Header';", + "localImportName": "Header", + "path": "./src/stories/Header.tsx", + "reactDocgen": { + "data": { + "actualName": "", + "definedInFile": "./src/stories/Header.tsx", + "description": "@import import { Header } from '@design-system/components/Header';", + "exportName": "default", + "methods": [], + "props": { + "onCreateAccount": { + "description": "", + "required": false, + "tsType": { + "name": "signature", + "raw": "() => void", + "signature": { + "arguments": [], + "return": { + "name": "void", + }, + }, + "type": "function", + }, + }, + "onLogin": { + "description": "", + "required": false, + "tsType": { + "name": "signature", + "raw": "() => void", + "signature": { + "arguments": [], + "return": { + "name": "void", + }, + }, + "type": "function", + }, + }, + "onLogout": { + "description": "", + "required": false, + "tsType": { + "name": "signature", + "raw": "() => void", + "signature": { + "arguments": [], + "return": { + "name": "void", + }, + }, + "type": "function", + }, + }, + "user": { + "description": "", + "required": false, + "tsType": { + "name": "User", + }, + }, + }, + }, + "type": "success", + }, + }, + ], + "imports": [ + "import { Header } from "my-package";", + ], + } + ` ); }); test('Converts relative import to provided packageName', async () => { const code = dedent` - import { Button } from './components/Button'; + import { Button } from './Button'; const meta = {}; export default meta; export const S = + ); + };`, + ['./src/stories/Header.stories.ts']: dedent` + import type { Meta, StoryObj } from '@storybook/react'; + import { fn } from 'storybook/test'; + import Header from './Header'; + + /** + * Description from meta and very long. + * @summary Component summary + * @import import { Header } from '@design-system/components/Header'; + */ + const meta = { + component: Header, + args: { + onLogin: fn(), + onLogout: fn(), + onCreateAccount: fn(), + } + } satisfies Meta; + export default meta; + type Story = StoryObj; + export const LoggedIn: Story = { args: { user: { name: 'Jane Doe' } } }; + export const LoggedOut: Story = {}; + `, + ['./src/stories/Header.tsx']: dedent` + import { Button } from './Button'; + + export interface HeaderProps { + user?: User; + onLogin?: () => void; + onLogout?: () => void; + onCreateAccount?: () => void; + } + + /** + * @import import { Header } from '@design-system/components/Header'; + */ + export default ({ user, onLogin, onLogout, onCreateAccount }: HeaderProps) => ( +
+
+
+ {user ? ( + <> + + Welcome, {user.name}! + +
+
+
+ );`, +}; From 70e8504b38ad405a427e2e2548ef20f576425f62 Mon Sep 17 00:00:00 2001 From: Kasper Peulen Date: Mon, 3 Nov 2025 20:00:07 +0100 Subject: [PATCH 29/51] Fix auto imports --- code/core/src/core-server/dev-server.ts | 3 +- code/core/src/core-server/manifest.ts | 2 +- .../src/componentManifest/generator.test.ts | 20 +- .../react/src/componentManifest/generator.ts | 263 +++++++++--------- .../getComponentImports.test.ts | 139 ++++++++- .../componentManifest/getComponentImports.ts | 121 ++++++-- .../src/componentManifest/reactDocgen.ts | 59 ++-- .../react/src/componentManifest/utils.ts | 36 ++- 8 files changed, 458 insertions(+), 185 deletions(-) diff --git a/code/core/src/core-server/dev-server.ts b/code/core/src/core-server/dev-server.ts index 4c373f9f0723..594f1e886065 100644 --- a/code/core/src/core-server/dev-server.ts +++ b/code/core/src/core-server/dev-server.ts @@ -191,7 +191,8 @@ export async function storybookDevServer(options: Options) { // logger?.error?.(e instanceof Error ? e : String(e)); res.statusCode = 500; res.setHeader('Content-Type', 'text/html; charset=utf-8'); - res.end(`
${e instanceof Error ? e.toString() : String(e)}
`); + invariant(e instanceof Error); + res.end(`
${e.toString()}\n${e.stack}
`); } }); } diff --git a/code/core/src/core-server/manifest.ts b/code/core/src/core-server/manifest.ts index b289cd01a357..6c3f136b2dea 100644 --- a/code/core/src/core-server/manifest.ts +++ b/code/core/src/core-server/manifest.ts @@ -774,7 +774,7 @@ function renderComponentCard(key: string, c: ComponentManifest, id: string) { ${esc(ex.name)} story error - ${ex?.summary ? `
${esc(ex.summary)}
` : ''} + ${ex?.summary ? `
Summary: ${esc(ex.summary)}
` : ''} ${ex?.description ? `
${esc(ex.description)}
` : ''} ${ex?.snippet ? `
${esc(ex.snippet)}
` : ''} ${ex?.error?.message ? `
${esc(ex.error.message)}
` : ''} diff --git a/code/renderers/react/src/componentManifest/generator.test.ts b/code/renderers/react/src/componentManifest/generator.test.ts index 69f28c540bde..e9fcd06c2a82 100644 --- a/code/renderers/react/src/componentManifest/generator.test.ts +++ b/code/renderers/react/src/componentManifest/generator.test.ts @@ -136,7 +136,7 @@ test('componentManifestGenerator generates correct id, name, description and exa "description": "Primary UI component for user interaction", "error": undefined, "id": "example-button", - "import": "import { Button } from '@design-system/components/Button';", + "import": "import { Button } from \"@design-system/components/Button\";", "jsDocTags": { "import": [ "import { Button } from '@design-system/components/Button';", @@ -223,20 +223,28 @@ test('componentManifestGenerator generates correct id, name, description and exa }, "stories": [ { + "description": undefined, "name": "Primary", "snippet": "const Primary = () => ;", + "summary": undefined, }, { + "description": undefined, "name": "Secondary", "snippet": "const Secondary = () => ;", + "summary": undefined, }, { + "description": undefined, "name": "Large", "snippet": "const Large = () => ;", + "summary": undefined, }, { + "description": undefined, "name": "Small", "snippet": "const Small = () => ;", + "summary": undefined, }, ], "summary": undefined, @@ -245,7 +253,7 @@ test('componentManifestGenerator generates correct id, name, description and exa "description": "Description from meta and very long.", "error": undefined, "id": "example-header", - "import": "import { Header } from '@design-system/components/Header';", + "import": "import { Header } from \"@design-system/components/Header\";", "jsDocTags": { "import": [ "import { Header } from '@design-system/components/Header';", @@ -319,16 +327,20 @@ test('componentManifestGenerator generates correct id, name, description and exa }, "stories": [ { + "description": undefined, "name": "LoggedIn", "snippet": "const LoggedIn = () =>
;", + "summary": undefined, }, { + "description": undefined, "name": "LoggedOut", "snippet": "const LoggedOut = () =>
;", + "summary": undefined, }, ], "summary": "Component summary", @@ -422,7 +434,7 @@ test('fall back to index title when no component name', async () => { "description": "Primary UI component for user interaction", "error": undefined, "id": "example-button", - "import": "import { Button } from "some-package";", + "import": "import { Button } from \"some-package\";", "jsDocTags": {}, "name": "Button", "path": "./src/stories/Button.stories.ts", @@ -449,8 +461,10 @@ test('fall back to index title when no component name', async () => { }, "stories": [ { + "description": undefined, "name": "Primary", "snippet": "const Primary = () =>
; `; - expect(await getImports(code)).toMatchInlineSnapshot( + expect(getImports(code)).toMatchInlineSnapshot( ` { "components": [ @@ -44,14 +44,12 @@ test('Get imports from multiple components', async () => { "importId": "@design-system/button", "importName": "Button", "localImportName": "Button", - "path": undefined, }, { "componentName": "ButtonGroup", "importId": "@design-system/button-group", "importName": "ButtonGroup", "localImportName": "ButtonGroup", - "path": undefined, }, ], "imports": [ @@ -63,7 +61,7 @@ test('Get imports from multiple components', async () => { ); }); -test('Namespace import with member usage', async () => { +test('Namespace import with member usage', () => { const code = dedent` import * as Accordion from '@ds/accordion'; @@ -71,7 +69,7 @@ test('Namespace import with member usage', async () => { export default meta; export const S = Hi; `; - expect(await getImports(code)).toMatchInlineSnapshot( + expect(getImports(code)).toMatchInlineSnapshot( ` { "components": [ @@ -92,7 +90,7 @@ test('Namespace import with member usage', async () => { ); }); -test('Named import used as namespace object', async () => { +test('Named import used as namespace object', () => { const code = dedent` import { Accordion } from '@ds/accordion'; @@ -100,7 +98,7 @@ test('Named import used as namespace object', async () => { export default meta; export const S = Hi; `; - expect(await getImports(code)).toMatchInlineSnapshot( + expect(getImports(code)).toMatchInlineSnapshot( ` { "components": [ @@ -120,7 +118,7 @@ test('Named import used as namespace object', async () => { ); }); -test('Default import', async () => { +test('Default import', () => { const code = dedent` import Button from '@ds/button'; @@ -128,7 +126,7 @@ test('Default import', async () => { export default meta; export const S =