diff --git a/code/addons/vitest/tsconfig.json b/code/addons/vitest/tsconfig.json
index 8f0586c10653..d2318b7bb29f 100644
--- a/code/addons/vitest/tsconfig.json
+++ b/code/addons/vitest/tsconfig.json
@@ -5,5 +5,5 @@
"types": ["vitest"],
"strict": true
},
- "include": ["src/**/*", "./typings.d.ts"],
+ "include": ["src/**/*", "./typings.d.ts"]
}
diff --git a/code/core/src/core-server/build-static.ts b/code/core/src/core-server/build-static.ts
index ef1bc814288f..8107b53b9c57 100644
--- a/code/core/src/core-server/build-static.ts
+++ b/code/core/src/core-server/build-static.ts
@@ -167,7 +167,7 @@ export async function buildStaticStandalone(options: BuildStaticStandaloneOption
);
if (features?.experimentalComponentsManifest) {
- const componentManifestGenerator: ComponentManifestGenerator = await presets.apply(
+ const componentManifestGenerator = await presets.apply(
'experimental_componentManifestGenerator'
);
const indexGenerator = await initializedStoryIndexGenerator;
diff --git a/code/core/src/core-server/dev-server.ts b/code/core/src/core-server/dev-server.ts
index b5ec0b04dc90..594f1e886065 100644
--- a/code/core/src/core-server/dev-server.ts
+++ b/code/core/src/core-server/dev-server.ts
@@ -144,7 +144,7 @@ export async function storybookDevServer(options: Options) {
if (features?.experimentalComponentsManifest) {
app.use('/manifests/components.json', async (req, res) => {
try {
- const componentManifestGenerator: ComponentManifestGenerator = await options.presets.apply(
+ const componentManifestGenerator = await options.presets.apply(
'experimental_componentManifestGenerator'
);
const indexGenerator = await initializedStoryIndexGenerator;
@@ -169,7 +169,7 @@ export async function storybookDevServer(options: Options) {
app.get('/manifests/components.html', async (req, res) => {
try {
- const componentManifestGenerator: ComponentManifestGenerator = await options.presets.apply(
+ const componentManifestGenerator = await options.presets.apply(
'experimental_componentManifestGenerator'
);
const indexGenerator = await initializedStoryIndexGenerator;
@@ -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 591d1f6aa382..6c3f136b2dea 100644
--- a/code/core/src/core-server/manifest.ts
+++ b/code/core/src/core-server/manifest.ts
@@ -40,7 +40,7 @@ export function renderManifestComponentsPage(manifest: ComponentsManifest) {
entries.map(([, it]) => it).filter((it) => it.error),
(manifest) => manifest.error?.name ?? 'Error'
)
- );
+ ).sort(([, a], [, b]) => b.length - a.length);
const errorGroupsHTML = errorGroups
.map(([error, grouped]) => {
@@ -517,15 +517,23 @@ export function renderManifestComponentsPage(manifest: ComponentsManifest) {
.card > .tg-err:checked ~ .panels .panel-err {
display: grid;
}
-
+
.card > .tg-warn:checked ~ .panels .panel-warn {
display: grid;
}
-
+
.card > .tg-stories:checked ~ .panels .panel-stories {
display: grid;
}
+ /* Add vertical spacing around panels only when any panel is visible */
+ .card > .tg-err:checked ~ .panels,
+ .card > .tg-warn:checked ~ .panels,
+ .card > .tg-stories:checked ~ .panels,
+ .card > .tg-props:checked ~ .panels {
+ margin: 10px 0;
+ }
+
/* Optional: a subtle 1px ring on the active badge, using :has() if available */
@supports selector(.card:has(.tg-err:checked)) {
.card:has(.tg-err:checked) label[for$='-err'],
@@ -536,6 +544,25 @@ export function renderManifestComponentsPage(manifest: ComponentsManifest) {
border-color: currentColor;
}
}
+
+ /* Wrap long lines in code blocks at ~120 characters */
+ pre, code {
+ font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
+ }
+ pre {
+ white-space: pre-wrap;
+ overflow-wrap: anywhere;
+ word-break: break-word;
+ overflow-x: auto; /* fallback for extremely long tokens */
+ margin: 8px 0 0;
+ }
+ pre > code {
+ display: block;
+ white-space: inherit;
+ overflow-wrap: inherit;
+ word-break: inherit;
+ inline-size: min(100%, 120ch);
+ }
@@ -747,19 +774,36 @@ function renderComponentCard(key: string, c: ComponentManifest, id: string) {
${esc(ex.name)}
story error
+ ${ex?.summary ? `Summary: ${esc(ex.summary)}
` : ''}
+ ${ex?.description ? `${esc(ex.description)}
` : ''}
${ex?.snippet ? `${esc(ex.snippet)}
` : ''}
${ex?.error?.message ? `${esc(ex.error.message)}
` : ''}
`
)
.join('')}
+
+
+ ${
+ c.import
+ ? `
+
+ Imports
+
+
${c.import}
+
`
+ : ''
+ }
+
${okStories
.map(
- (ex, k) => `
+ (ex) => `
${esc(ex.name)}
story ok
+ ${ex?.summary ? `
${esc(ex.summary)}
` : ''}
+ ${ex?.description ? `
${esc(ex.description)}
` : ''}
${ex?.snippet ? `
${esc(ex.snippet)}
` : ''}
`
)
diff --git a/code/core/src/types/modules/core-common.ts b/code/core/src/types/modules/core-common.ts
index 229e7489608e..2b7d75a0fe9a 100644
--- a/code/core/src/types/modules/core-common.ts
+++ b/code/core/src/types/modules/core-common.ts
@@ -352,7 +352,13 @@ export interface ComponentManifest {
description?: string;
import?: string;
summary?: string;
- stories: { name: string; snippet?: string; error?: { name: string; message: string } }[];
+ stories: {
+ name: string;
+ snippet?: string;
+ description?: string;
+ summary?: string;
+ error?: { name: string; message: string };
+ }[];
jsDocTags: Record;
error?: { name: string; message: string };
}
diff --git a/code/renderers/react/package.json b/code/renderers/react/package.json
index edef2a81a8d0..c352db9790b4 100644
--- a/code/renderers/react/package.json
+++ b/code/renderers/react/package.json
@@ -67,6 +67,7 @@
"acorn-walk": "^7.2.0",
"babel-plugin-react-docgen": "^4.2.1",
"comment-parser": "^1.4.1",
+ "empathic": "^2.0.0",
"es-toolkit": "^1.36.0",
"escodegen": "^2.1.0",
"expect-type": "^0.15.0",
diff --git a/code/renderers/react/src/componentManifest/generator.test.ts b/code/renderers/react/src/componentManifest/generator.test.ts
index fa13535eb68c..16097f5c6c9a 100644
--- a/code/renderers/react/src/componentManifest/generator.test.ts
+++ b/code/renderers/react/src/componentManifest/generator.test.ts
@@ -6,9 +6,33 @@ import { vol } from 'memfs';
import { dedent } from 'ts-dedent';
import { componentManifestGenerator } from './generator';
+import { fsMocks } from './test-utils';
+vi.mock('storybook/internal/common', async (importOriginal) => ({
+ ...(await importOriginal()),
+ // Keep it simple: hardcode known inputs to expected outputs for this test.
+ resolveImport: (id: string) => {
+ return {
+ './Button': './src/stories/Button.tsx',
+ './Header': './src/stories/Header.tsx',
+ }[id];
+ },
+ 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('empathic/find', async () => ({
+ up: (path: string) => '/app/package.json',
+}));
vi.mock('tsconfig-paths', () => ({ loadConfig: () => ({ resultType: null!, message: null! }) }));
// Use the provided indexJson from this file
@@ -95,118 +119,13 @@ const indexJson = {
beforeEach(() => {
vi.spyOn(process, 'cwd').mockReturnValue('/app');
- vol.fromJSON(
- {
- ['./src/stories/Button.stories.ts']: dedent`
- import type { Meta, StoryObj } from '@storybook/react';
- import { fn } from 'storybook/test';
- import { Button } from './Button';
-
- const meta = {
- component: Button,
- args: { onClick: fn() },
- } satisfies Meta;
- export default meta;
- type Story = StoryObj;
-
- export const Primary: Story = { args: { primary: true, label: 'Button' } };
- export const Secondary: Story = { args: { label: 'Button' } };
- export const Large: Story = { args: { size: 'large', label: 'Button' } };
- export const Small: Story = { args: { size: 'small', label: 'Button' } };`,
- ['./src/stories/Button.tsx']: dedent`
- import React from 'react';
- export interface ButtonProps {
- /** Description of primary */
- primary?: boolean;
- backgroundColor?: string;
- size?: 'small' | 'medium' | 'large';
- label: string;
- onClick?: () => void;
- }
-
- /** Primary UI component for user interaction */
- export const Button = ({
- primary = false,
- size = 'medium',
- backgroundColor,
- label,
- ...props
- }: ButtonProps) => {
- const mode = primary ? 'storybook-button--primary' : 'storybook-button--secondary';
- return (
-
- );
- };`,
- ['./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) => (
-
- );`,
- },
- '/app'
- );
+ vol.fromJSON(fsMocks, '/app');
return () => vol.reset();
});
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,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": undefined,
- "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": [],
@@ -299,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,
@@ -321,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';",
@@ -334,8 +266,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": {
@@ -395,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",
@@ -418,6 +354,7 @@ test('componentManifestGenerator generates correct id, name, description and exa
async function getManifestForStory(code: string) {
vol.fromJSON(
{
+ ['./package.json']: JSON.stringify({ name: 'some-package' }),
['./src/stories/Button.stories.ts']: code,
['./src/stories/Button.tsx']: dedent`
import React from 'react';
@@ -441,7 +378,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 +396,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,13 +434,13 @@ 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",
"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",
@@ -524,8 +461,10 @@ test('fall back to index title when no component name', async () => {
},
"stories": [
{
+ "description": undefined,
"name": "Primary",
"snippet": "const Primary = () => ;",
+ "summary": undefined,
},
],
"summary": undefined,
@@ -542,13 +481,13 @@ 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",
"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",
@@ -594,13 +533,13 @@ 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",
"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 2f1a1df70f73..c5da08b2b5dd 100644
--- a/code/renderers/react/src/componentManifest/generator.ts
+++ b/code/renderers/react/src/componentManifest/generator.ts
@@ -1,25 +1,34 @@
-import { readFile } from 'node:fs/promises';
-
-import { recast } from 'storybook/internal/babel';
-import { loadCsf } from 'storybook/internal/csf-tools';
-import { extractDescription } from 'storybook/internal/csf-tools';
-import { type ComponentManifestGenerator } from 'storybook/internal/types';
-import { type ComponentManifest } from 'storybook/internal/types';
-
import path from 'pathe';
+import { recast } from 'storybook/internal/babel';
+import { extractDescription, loadCsf } from 'storybook/internal/csf-tools';
+import { logger } from 'storybook/internal/node-logger';
+import {
+ type ComponentManifest,
+ type ComponentManifestGenerator,
+ type PresetPropertyFn,
+} from 'storybook/internal/types';
import { getCodeSnippet } from './generateCodeSnippet';
+import { getComponentImports } from './getComponentImports';
import { extractJSDocInfo } from './jsdocTags';
-import { type DocObj, getMatchingDocgen, parseWithReactDocgen } from './reactDocgen';
-import { groupBy, invariant } from './utils';
+import { type DocObj, getReactDocgen } from './reactDocgen';
+import { cachedFindUp, cachedReadFileSync, groupBy, invalidateCache, invariant } from './utils';
interface ReactComponentManifest extends ComponentManifest {
reactDocgen?: DocObj;
}
-export const componentManifestGenerator = async () => {
+export const componentManifestGenerator: PresetPropertyFn<
+ 'experimental_componentManifestGenerator'
+> = async () => {
return (async (storyIndexGenerator) => {
+ invalidateCache();
+
+ const startIndex = performance.now();
const index = await storyIndexGenerator.getIndex();
+ logger.verbose(`Story index generation took ${performance.now() - startIndex}ms`);
+
+ const startPerformance = performance.now();
const groupByComponentId = groupBy(
Object.values(index.entries)
@@ -30,117 +39,139 @@ export const componentManifestGenerator = async () => {
const singleEntryPerComponent = Object.values(groupByComponentId).flatMap((group) =>
group && group?.length > 0 ? [group[0]] : []
);
- const components = await Promise.all(
- singleEntryPerComponent.flatMap(async (entry): Promise => {
- const storyFile = await readFile(path.join(process.cwd(), entry.importPath), 'utf-8');
- const csf = loadCsf(storyFile, { makeTitle: (title) => title ?? 'No title' }).parse();
- const name = csf._meta?.component ?? entry.title.split('/').at(-1)!;
- const id = entry.id.split('--')[0];
- const importPath = entry.importPath;
-
- const stories = Object.keys(csf._stories)
- .map((storyName) => {
- try {
- return {
- name: storyName,
- snippet: recast.print(getCodeSnippet(csf, storyName, name)).code,
- };
- } catch (e) {
- invariant(e instanceof Error);
- return {
- name: storyName,
- error: { name: e.name, message: e.message },
- };
- }
- })
- .filter(Boolean);
-
- const base = {
- id,
- name,
- path: importPath,
- stories,
- jsDocTags: {},
- } satisfies Partial;
-
- if (!entry.componentPath) {
- const componentName = csf._meta?.component;
-
- const error = !componentName
- ? {
- name: 'No meta.component specified',
- message: 'Specify meta.component for the component to be included in the manifest.',
- }
- : {
- name: 'No component import found',
- message: `No component file found for the "${componentName}" component.`,
- };
- return {
- ...base,
- name,
- stories,
- error: {
- name: error.name,
- message:
- csf._metaStatementPath?.buildCodeFrameError(error.message).message ?? error.message,
- },
- };
- }
-
- let componentFile;
-
- try {
- componentFile = await readFile(path.join(process.cwd(), entry.componentPath!), 'utf-8');
- } catch (e) {
- invariant(e instanceof Error);
- return {
- ...base,
- name,
- stories,
- error: {
- name: 'Component file could not be read',
- message: `Could not read the component file located at "${entry.componentPath}".\nPrefer relative imports.`,
- },
- };
- }
-
- const docgens = await parseWithReactDocgen({
- code: componentFile,
- filename: path.join(process.cwd(), entry.componentPath),
- });
- const docgen = getMatchingDocgen(docgens, csf);
-
- const error = !docgen
+ const components = singleEntryPerComponent.map((entry): ReactComponentManifest | undefined => {
+ const absoluteImportPath = path.join(process.cwd(), entry.importPath);
+ const storyFile = cachedReadFileSync(absoluteImportPath, 'utf-8') as string;
+ 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, '');
+
+ const id = entry.id.split('--')[0];
+ const importPath = entry.importPath;
+
+ const nearestPkg = cachedFindUp('package.json', {
+ cwd: path.dirname(absoluteImportPath),
+ last: process.cwd(),
+ });
+ const packageName = nearestPkg
+ ? JSON.parse(cachedReadFileSync(nearestPkg, 'utf-8') as string).name
+ : undefined;
+
+ const fallbackImport =
+ packageName && componentName ? `import { ${componentName} } from "${packageName}";` : '';
+ const componentImports = getComponentImports({
+ csf,
+ packageName,
+ storyFilePath: absoluteImportPath,
+ });
+
+ const calculatedImports = componentImports.imports.join('\n').trim() ?? fallbackImport;
+
+ const component = componentImports.components.find((it) => {
+ const nameMatch = componentName
+ ? it.componentName === componentName ||
+ it.localImportName === componentName ||
+ it.importName === componentName
+ : false;
+ const titleMatch = !componentName
+ ? (it.localImportName ? title.includes(it.localImportName) : false) ||
+ (it.importName ? title.includes(it.importName) : false)
+ : false;
+ return nameMatch || titleMatch;
+ });
+
+ componentName ??=
+ component?.componentName ?? component?.localImportName ?? component?.importName;
+
+ const componentPath = component?.path;
+ const importName = component?.importName;
+
+ 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);
+ return {
+ name: storyName,
+ error: { name: e.name, message: e.message },
+ };
+ }
+ })
+ .filter((it) => it != null);
+
+ const base = {
+ id,
+ name: componentName ?? title,
+ path: importPath,
+ stories,
+ import: calculatedImports,
+ jsDocTags: {},
+ } satisfies Partial;
+
+ if (!componentPath) {
+ const error = !componentName
? {
- name: 'Docgen evaluation failed',
- message:
- `Could not parse props information for the component file located at "${entry.componentPath}"\n` +
- `Avoid barrel files when importing your component file.\n` +
- `Prefer relative imports if possible.\n` +
- `Avoid pointing to transpiled files.\n` +
- `You can debug your component file in this playground: https://react-docgen.dev/playground`,
+ name: 'No meta.component specified',
+ message: 'Specify meta.component for the component to be included in the manifest.',
}
- : undefined;
-
- const metaDescription = extractDescription(csf._metaStatement);
- const jsdocComment = metaDescription || docgen?.description;
- const { tags = {}, description } = jsdocComment ? extractJSDocInfo(jsdocComment) : {};
-
- const manifestDescription = (tags?.describe?.[0] || tags?.desc?.[0]) ?? description;
-
+ : {
+ name: 'No component import found',
+ message: `No component file found for the "${componentName}" component.`,
+ };
return {
...base,
- name,
- description: manifestDescription?.trim(),
- summary: tags.summary?.[0],
- import: tags.import?.[0],
- reactDocgen: docgen,
- jsDocTags: tags,
- stories,
- error,
+ error: {
+ name: error.name,
+ message:
+ csf._metaStatementPath?.buildCodeFrameError(error.message).message ?? error.message,
+ },
};
- })
- );
+ }
+
+ const docgenResult = getReactDocgen(
+ componentPath,
+ component ? component : { componentName: componentName ?? title }
+ );
+
+ const docgen = docgenResult.type === 'success' ? docgenResult.data : undefined;
+ const error = docgenResult.type === 'error' ? docgenResult.error : undefined;
+
+ const metaDescription = extractDescription(csf._metaStatement);
+ const jsdocComment = metaDescription || docgen?.description;
+ const { tags = {}, description } = jsdocComment ? extractJSDocInfo(jsdocComment) : {};
+
+ const manifestDescription = (tags?.describe?.[0] || tags?.desc?.[0]) ?? description;
+
+ return {
+ ...base,
+ description: manifestDescription?.trim(),
+ summary: tags.summary?.[0],
+ import: calculatedImports,
+ reactDocgen: docgen,
+ jsDocTags: tags,
+ error,
+ };
+ });
+
+ logger.verbose(`Component manifest generation took ${performance.now() - startPerformance}ms`);
return {
v: 0,
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..7896473a80ce
--- /dev/null
+++ b/code/renderers/react/src/componentManifest/getComponentImports.test.ts
@@ -0,0 +1,1071 @@
+import { beforeEach, expect, test, vi } from 'vitest';
+
+import { loadCsf } from 'storybook/internal/csf-tools';
+
+import { vol } from 'memfs';
+import { dedent } from 'ts-dedent';
+
+import { getImports as buildImports, 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, storyFilePath?: string) =>
+ getComponentImports({
+ csf: loadCsf(code, { makeTitle: (t?: string) => t ?? 'title' }).parse(),
+ packageName,
+ storyFilePath,
+ });
+
+test('Get imports from multiple components', async () => {
+ 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(await getImports(code)).toMatchInlineSnapshot(
+ `
+ {
+ "components": [
+ {
+ "componentName": "Button",
+ "importId": "@design-system/button",
+ "importName": "Button",
+ "localImportName": "Button",
+ "path": undefined,
+ },
+ {
+ "componentName": "ButtonGroup",
+ "importId": "@design-system/button-group",
+ "importName": "ButtonGroup",
+ "localImportName": "ButtonGroup",
+ "path": undefined,
+ },
+ ],
+ "imports": [
+ "import { Button } from "@design-system/button";",
+ "import { ButtonGroup } from "@design-system/button-group";",
+ ],
+ }
+ `
+ );
+});
+
+test('Namespace import with member usage', async () => {
+ const code = dedent`
+ import * as Accordion from '@ds/accordion';
+
+ const meta = {};
+ export default meta;
+ export const S = Hi;
+ `;
+ expect(await getImports(code)).toMatchInlineSnapshot(
+ `
+ {
+ "components": [
+ {
+ "componentName": "Accordion.Root",
+ "importId": "@ds/accordion",
+ "importName": "Root",
+ "localImportName": "Accordion",
+ "namespace": "Accordion",
+ "path": undefined,
+ },
+ ],
+ "imports": [
+ "import * as Accordion from "@ds/accordion";",
+ ],
+ }
+ `
+ );
+});
+
+test('Named import used as namespace object', async () => {
+ const code = dedent`
+ import { Accordion } from '@ds/accordion';
+
+ const meta = {};
+ export default meta;
+ export const S = Hi;
+ `;
+ expect(await getImports(code)).toMatchInlineSnapshot(
+ `
+ {
+ "components": [
+ {
+ "componentName": "Accordion.Root",
+ "importId": "@ds/accordion",
+ "importName": "Accordion",
+ "localImportName": "Accordion",
+ "path": undefined,
+ },
+ ],
+ "imports": [
+ "import { Accordion } from "@ds/accordion";",
+ ],
+ }
+ `
+ );
+});
+
+test('Default import', async () => {
+ const code = dedent`
+ import Button from '@ds/button';
+
+ const meta = {};
+ export default meta;
+ export const S = ;
+ `;
+ expect(await getImports(code)).toMatchInlineSnapshot(
+ `
+ {
+ "components": [
+ {
+ "componentName": "Button",
+ "importId": "@ds/button",
+ "importName": "default",
+ "localImportName": "Button",
+ "path": undefined,
+ },
+ ],
+ "imports": [
+ "import Button from "@ds/button";",
+ ],
+ }
+ `
+ );
+});
+
+test('Alias named import and meta.component inclusion', async () => {
+ const code = dedent`
+ import DefaultComponent, { Button as Btn, Other } from '@ds/button';
+
+ const meta = { component: Btn };
+ export default meta;
+ export const S = ;
+ `;
+ expect(await getImports(code)).toMatchInlineSnapshot(
+ `
+ {
+ "components": [
+ {
+ "componentName": "Btn",
+ "importId": "@ds/button",
+ "importName": "Button",
+ "localImportName": "Btn",
+ "path": undefined,
+ },
+ {
+ "componentName": "Other",
+ "importId": "@ds/button",
+ "importName": "Other",
+ "localImportName": "Other",
+ "path": undefined,
+ },
+ ],
+ "imports": [
+ "import { Button as Btn, Other } from "@ds/button";",
+ ],
+ }
+ `
+ );
+});
+
+test('Strip unused specifiers from the same import statement', async () => {
+ const code = dedent`
+ import { Button as Btn, useSomeHook } from '@ds/button';
+
+ const meta = {};
+ export default meta;
+ export const S = ;
+ `;
+ expect(await getImports(code)).toMatchInlineSnapshot(
+ `
+ {
+ "components": [
+ {
+ "componentName": "Btn",
+ "importId": "@ds/button",
+ "importName": "Button",
+ "localImportName": "Btn",
+ "path": undefined,
+ },
+ ],
+ "imports": [
+ "import { Button as Btn } from "@ds/button";",
+ ],
+ }
+ `
+ );
+});
+
+test('Meta component with member and star import', async () => {
+ const code = dedent`
+ import * as Accordion from '@ds/accordion';
+
+ const meta = { component: Accordion.Root };
+ export default meta;
+ `;
+ expect(await getImports(code)).toMatchInlineSnapshot(
+ `
+ {
+ "components": [
+ {
+ "componentName": "Accordion.Root",
+ "importId": "@ds/accordion",
+ "importName": "Root",
+ "localImportName": "Accordion",
+ "namespace": "Accordion",
+ "path": undefined,
+ },
+ ],
+ "imports": [
+ "import * as Accordion from "@ds/accordion";",
+ ],
+ }
+ `
+ );
+});
+
+test('Keeps multiple named specifiers and drops unused ones from same import', async () => {
+ const code = dedent`
+ import { Button, ButtonGroup, useHook } from '@ds/button';
+
+ const meta = {};
+ export default meta;
+ export const S =
;
+ `;
+ expect(await getImports(code)).toMatchInlineSnapshot(
+ `
+ {
+ "components": [
+ {
+ "componentName": "Button",
+ "importId": "@ds/button",
+ "importName": "Button",
+ "localImportName": "Button",
+ "path": undefined,
+ },
+ {
+ "componentName": "ButtonGroup",
+ "importId": "@ds/button",
+ "importName": "ButtonGroup",
+ "localImportName": "ButtonGroup",
+ "path": undefined,
+ },
+ ],
+ "imports": [
+ "import { Button, ButtonGroup } from "@ds/button";",
+ ],
+ }
+ `
+ );
+});
+
+test('Mixed default + named import: keep only default when only default used', async () => {
+ const code = dedent`
+ import Button, { useHook } from '@ds/button';
+
+ const meta = {};
+ export default meta;
+ export const S = ;
+ `;
+ expect(await getImports(code)).toMatchInlineSnapshot(
+ `
+ {
+ "components": [
+ {
+ "componentName": "Button",
+ "importId": "@ds/button",
+ "importName": "default",
+ "localImportName": "Button",
+ "path": undefined,
+ },
+ ],
+ "imports": [
+ "import Button from "@ds/button";",
+ ],
+ }
+ `
+ );
+});
+
+test('Mixed default + named import: keep only named when only named (alias) used', async () => {
+ const code = dedent`
+ import Button, { Button as Btn } from '@ds/button';
+
+ const meta = {};
+ export default meta;
+ export const S = ;
+ `;
+ expect(await getImports(code)).toMatchInlineSnapshot(
+ `
+ {
+ "components": [
+ {
+ "componentName": "Btn",
+ "importId": "@ds/button",
+ "importName": "Button",
+ "localImportName": "Btn",
+ "path": undefined,
+ },
+ ],
+ "imports": [
+ "import { Button as Btn } from "@ds/button";",
+ ],
+ }
+ `
+ );
+});
+
+test('Per-specifier type import is dropped when mixing with value specifiers', async () => {
+ const code = dedent`
+ import type { Meta } from '@storybook/react';
+ import { type Meta as M, Button } from '@ds/button';
+
+ const meta = {};
+ export default meta;
+ export const S = ;
+ `;
+ expect(await getImports(code)).toMatchInlineSnapshot(
+ `
+ {
+ "components": [
+ {
+ "componentName": "Button",
+ "importId": "@ds/button",
+ "importName": "Button",
+ "localImportName": "Button",
+ "path": undefined,
+ },
+ ],
+ "imports": [
+ "import { Button } from "@ds/button";",
+ ],
+ }
+ `
+ );
+});
+
+test('Namespace import used for multiple members kept once', async () => {
+ const code = dedent`
+ import * as DS from '@ds/ds';
+
+ const meta = {};
+ export default meta;
+ export const S =
;
+ `;
+ expect(await getImports(code)).toMatchInlineSnapshot(
+ `
+ {
+ "components": [
+ {
+ "componentName": "DS.A",
+ "importId": "@ds/ds",
+ "importName": "A",
+ "localImportName": "DS",
+ "namespace": "DS",
+ "path": undefined,
+ },
+ {
+ "componentName": "DS.B",
+ "importId": "@ds/ds",
+ "importName": "B",
+ "localImportName": "DS",
+ "namespace": "DS",
+ "path": undefined,
+ },
+ ],
+ "imports": [
+ "import * as DS from "@ds/ds";",
+ ],
+ }
+ `
+ );
+});
+
+test('Default import kept when referenced only via meta.component', async () => {
+ const code = dedent`
+ import Button from '@ds/button';
+
+ const meta = { component: Button };
+ export default meta;
+ `;
+ expect(await getImports(code)).toMatchInlineSnapshot(
+ `
+ {
+ "components": [
+ {
+ "componentName": "Button",
+ "importId": "@ds/button",
+ "importName": "default",
+ "localImportName": "Button",
+ "path": undefined,
+ },
+ ],
+ "imports": [
+ "import Button from "@ds/button";",
+ ],
+ }
+ `
+ );
+});
+
+test('Side-effect-only import is ignored', async () => {
+ const code = dedent`
+ import '@ds/global.css';
+ import { Button } from '@ds/button';
+
+ const meta = {};
+ export default meta;
+ export const S = ;
+ `;
+ expect(await getImports(code)).toMatchInlineSnapshot(
+ `
+ {
+ "components": [
+ {
+ "componentName": "Button",
+ "importId": "@ds/button",
+ "importName": "Button",
+ "localImportName": "Button",
+ "path": undefined,
+ },
+ ],
+ "imports": [
+ "import { Button } from "@ds/button";",
+ ],
+ }
+ `
+ );
+});
+
+// New tests for packageName behavior
+
+test('Converts default relative import to named when packageName provided', async () => {
+ const code = dedent`
+ import Header from './Header';
+
+ const meta = {};
+ export default meta;
+ export const S = ;
+ `;
+ 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 "@design-system/components/Header";",
+ ],
+ }
+ `
+ );
+});
+
+test('Converts relative import to provided packageName', async () => {
+ const code = dedent`
+ import { Button } from './Button';
+
+ const meta = {};
+ export default meta;
+ export const S = ;
+ `;
+ expect(
+ await getImports(code, 'my-package', '/app/src/stories/Button.stories.tsx')
+ ).toMatchInlineSnapshot(
+ `
+ {
+ "components": [
+ {
+ "componentName": "Button",
+ "importId": "./Button",
+ "importName": "Button",
+ "importOverride": "import { Button } from '@design-system/components/Button';",
+ "localImportName": "Button",
+ "path": "./src/stories/Button.tsx",
+ "reactDocgen": {
+ "data": {
+ "actualName": "Button",
+ "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": [],
+ "props": {
+ "backgroundColor": {
+ "description": "",
+ "required": false,
+ "tsType": {
+ "name": "string",
+ },
+ },
+ "label": {
+ "description": "",
+ "required": true,
+ "tsType": {
+ "name": "string",
+ },
+ },
+ "onClick": {
+ "description": "",
+ "required": false,
+ "tsType": {
+ "name": "signature",
+ "raw": "() => void",
+ "signature": {
+ "arguments": [],
+ "return": {
+ "name": "void",
+ },
+ },
+ "type": "function",
+ },
+ },
+ "primary": {
+ "defaultValue": {
+ "computed": false,
+ "value": "false",
+ },
+ "description": "Description of primary",
+ "required": false,
+ "tsType": {
+ "name": "boolean",
+ },
+ },
+ "size": {
+ "defaultValue": {
+ "computed": false,
+ "value": "'medium'",
+ },
+ "description": "",
+ "required": false,
+ "tsType": {
+ "elements": [
+ {
+ "name": "literal",
+ "value": "'small'",
+ },
+ {
+ "name": "literal",
+ "value": "'medium'",
+ },
+ {
+ "name": "literal",
+ "value": "'large'",
+ },
+ ],
+ "name": "union",
+ "raw": "'small' | 'medium' | 'large'",
+ },
+ },
+ },
+ },
+ "type": "success",
+ },
+ },
+ ],
+ "imports": [
+ "import { Button } from "@design-system/components/Button";",
+ ],
+ }
+ `
+ );
+});
+
+test('Keeps relative import when packageName is missing', async () => {
+ const code = dedent`
+ import { Button } from './components/Button';
+
+ const meta = {};
+ export default meta;
+ export const S = ;
+ `;
+ expect(await getImports(code)).toMatchInlineSnapshot(
+ `
+ {
+ "components": [
+ {
+ "componentName": "Button",
+ "importId": "./components/Button",
+ "importName": "Button",
+ "localImportName": "Button",
+ "path": undefined,
+ },
+ ],
+ "imports": [
+ "import { Button } from "./components/Button";",
+ ],
+ }
+ `
+ );
+});
+
+test('Non-relative import remains unchanged even if packageName provided', async () => {
+ const code = dedent`
+ import { Button } from '@ds/button';
+
+ const meta = {};
+ export default meta;
+ export const S = ;
+ `;
+ expect(await getImports(code, 'my-package')).toMatchInlineSnapshot(
+ `
+ {
+ "components": [
+ {
+ "componentName": "Button",
+ "importId": "@ds/button",
+ "importName": "Button",
+ "localImportName": "Button",
+ "path": undefined,
+ },
+ ],
+ "imports": [
+ "import { Button } from \"@ds/button\";",
+ ],
+ }
+ `
+ );
+});
+
+// Merging imports from same package
+
+test('Merges multiple imports from the same package (defaults and named)', async () => {
+ const code = dedent`
+ import { CopilotIcon } from '@primer/octicons-react';
+ import { Banner } from "@primer/react";
+ import Link from "@primer/react";
+ import { Dialog } from "@primer/react";
+ import { Stack } from "@primer/react";
+ import Heading from "@primer/react";
+
+ const meta = {};
+ export default meta;
+ export const S =
;
+ `;
+ expect(await getImports(code)).toMatchInlineSnapshot(
+ `
+ {
+ "components": [
+ {
+ "componentName": "Banner",
+ "importId": "@primer/react",
+ "importName": "Banner",
+ "localImportName": "Banner",
+ "path": undefined,
+ },
+ {
+ "componentName": "CopilotIcon",
+ "importId": "@primer/octicons-react",
+ "importName": "CopilotIcon",
+ "localImportName": "CopilotIcon",
+ "path": undefined,
+ },
+ {
+ "componentName": "Dialog",
+ "importId": "@primer/react",
+ "importName": "Dialog",
+ "localImportName": "Dialog",
+ "path": undefined,
+ },
+ {
+ "componentName": "Heading",
+ "importId": "@primer/react",
+ "importName": "default",
+ "localImportName": "Heading",
+ "path": undefined,
+ },
+ {
+ "componentName": "Link",
+ "importId": "@primer/react",
+ "importName": "default",
+ "localImportName": "Link",
+ "path": undefined,
+ },
+ {
+ "componentName": "Stack",
+ "importId": "@primer/react",
+ "importName": "Stack",
+ "localImportName": "Stack",
+ "path": undefined,
+ },
+ ],
+ "imports": [
+ "import Heading, { Banner, Dialog, Stack } from "@primer/react";",
+ "import Link from "@primer/react";",
+ "import { CopilotIcon } from "@primer/octicons-react";",
+ ],
+ }
+ `
+ );
+});
+
+test('Merges namespace with default and separates named for same package', async () => {
+ const code = dedent`
+ import * as PR from '@primer/react';
+ import { Banner } from '@primer/react';
+ import Link from '.';
+
+ const meta = {};
+ export default meta;
+ export const S = ;
+ `;
+ expect(await getImports(code)).toMatchInlineSnapshot(
+ `
+ {
+ "components": [
+ {
+ "componentName": "Banner",
+ "importId": "@primer/react",
+ "importName": "Banner",
+ "localImportName": "Banner",
+ "path": undefined,
+ },
+ {
+ "componentName": "Link",
+ "importId": ".",
+ "importName": "default",
+ "localImportName": "Link",
+ "path": undefined,
+ },
+ {
+ "componentName": "PR.Box",
+ "importId": "@primer/react",
+ "importName": "Box",
+ "localImportName": "PR",
+ "namespace": "PR",
+ "path": undefined,
+ },
+ ],
+ "imports": [
+ "import * as PR from "@primer/react";",
+ "import { Banner } from "@primer/react";",
+ "import Link from ".";",
+ ],
+ }
+ `
+ );
+});
+
+test('Object.assign aliasing of imported component retains correct import', async () => {
+ const code = dedent`
+ import type { Meta } from '@storybook/react';
+ import {ActionList as _ActionList} from '../../deprecated/ActionList'
+ import {Header} from '../../deprecated/ActionList/Header'
+ const ActionList = Object.assign(_ActionList, {
+ Header,
+ })
+
+ const meta: Meta = {
+ component: ActionList,
+ }
+ export default meta;
+
+ const Story = () =>
+ `;
+ expect(await getImports(code)).toMatchInlineSnapshot(
+ `
+ {
+ "components": [],
+ "imports": [],
+ }
+ `
+ );
+});
+
+test('Component not imported returns undefined importId and importName', async () => {
+ const code = dedent`
+ const meta = {};
+ export default meta;
+ export const S = ;
+ `;
+ expect(await getImports(code)).toMatchInlineSnapshot(
+ `
+ {
+ "components": [
+ {
+ "componentName": "Missing",
+ "path": undefined,
+ },
+ ],
+ "imports": [],
+ }
+ `
+ );
+});
+
+test('Namespace component not imported returns undefined importId and importName', async () => {
+ const code = dedent`
+ const meta = {};
+ export default meta;
+ export const S = ;
+ `;
+ expect(await getImports(code)).toMatchInlineSnapshot(
+ `
+ {
+ "components": [
+ {
+ "componentName": "PR.Box",
+ "path": undefined,
+ },
+ ],
+ "imports": [],
+ }
+ `
+ );
+});
+
+test('Filters out locally defined components', async () => {
+ const code = dedent`
+ const Local = () => ;
+
+ const meta = { component: Local };
+ export default meta;
+ export const S = ;
+ `;
+ expect(await getImports(code)).toMatchInlineSnapshot(
+ `
+ {
+ "components": [],
+ "imports": [],
+ }
+ `
+ );
+});
+
+test('importOverride: default override forces default import (keeps local name)', async () => {
+ const code = dedent`
+ import { Button } from './Button';
+
+ const meta = {};
+ export default meta;
+ export const S = ;
+ `;
+ const csf = loadCsf(code, { makeTitle: (t) => t ?? 'No title' }).parse();
+ const base = await getComponentImports({
+ csf,
+ packageName: 'my-package',
+ storyFilePath: '/app/src/stories/Button.stories.tsx',
+ });
+ const patched = base.components.map((c) =>
+ c.componentName === 'Button' ? { ...c, importOverride: "import Button from '@pkg/button';" } : c
+ );
+ const out = buildImports({ components: patched, packageName: 'my-package' });
+ expect(out).toMatchInlineSnapshot(`
+ [
+ "import Button from \"@pkg/button\";",
+ ]
+ `);
+});
+
+test('importOverride: named override aliases imported to local name', async () => {
+ const code = dedent`
+ import Button from './Button';
+
+ const meta = {};
+ export default meta;
+ export const S = ;
+ `;
+ const csf = loadCsf(code, { makeTitle: (t) => t ?? 'No title' }).parse();
+ const base = await getComponentImports({
+ csf,
+ packageName: 'pkg',
+ storyFilePath: '/app/src/stories/Button.stories.tsx',
+ });
+ const patched = base.components.map((c) =>
+ c.componentName === 'Button'
+ ? { ...c, importOverride: "import { DSButton } from '@pkg/button';" }
+ : c
+ );
+ const out = buildImports({ components: patched, packageName: 'pkg' });
+ expect(out).toMatchInlineSnapshot(`
+ [
+ "import { DSButton as Button } from \"@pkg/button\";",
+ ]
+ `);
+});
+
+test('importOverride: ignores namespace override and falls back', async () => {
+ const code = dedent`
+ import * as UI from './ui';
+
+ const meta = {};
+ export default meta;
+ export const S = ;
+ `;
+ const csf = loadCsf(code, { makeTitle: (t) => t ?? 'No title' }).parse();
+ const discovered = await getComponentImports({
+ csf,
+ packageName: 'pkg',
+ storyFilePath: '/app/src/stories/ui.stories.tsx',
+ });
+ const patched = discovered.components.map((c) =>
+ c.componentName === 'UI.Button' ? { ...c, importOverride: "import * as UI from '@pkg/ui';" } : c
+ );
+ const out = buildImports({ components: patched, packageName: 'pkg' });
+ expect(out).toMatchInlineSnapshot(`
+ [
+ "import { Button } from \"pkg\";",
+ ]
+ `);
+});
+
+test('importOverride: malformed string is ignored and behavior falls back', async () => {
+ const code = dedent`
+ import { Header } from './Header';
+
+ const meta = {};
+ export default meta;
+ export const S = ;
+ `;
+ const csf = loadCsf(code, { makeTitle: (t) => t ?? 'No title' }).parse();
+ const base = await getComponentImports({
+ csf,
+ packageName: 'pkg',
+ storyFilePath: '/app/src/stories/Header.stories.tsx',
+ });
+ const patched = base.components.map((c) =>
+ c.componentName === 'Header' ? { ...c, importOverride: 'import oops not valid' } : c
+ );
+ const out = buildImports({ components: patched, packageName: 'pkg' });
+ expect(out).toMatchInlineSnapshot(`
+ [
+ "import { Header } from \"pkg\";",
+ ]
+ `);
+});
+
+test('importOverride: merges multiple components into a single declaration per source', async () => {
+ const code = dedent`
+ import Button from './Button';
+ import { Header } from './Header';
+
+ const meta = {};
+ export default meta;
+ export const A = ;
+ export const B = ;
+ `;
+ const csf = loadCsf(code, { makeTitle: (t) => t ?? 'No title' }).parse();
+ const base = await getComponentImports({
+ csf,
+ packageName: 'pkg',
+ storyFilePath: '/app/src/stories/multi.stories.tsx',
+ });
+ const patched = base.components.map((c) =>
+ c.componentName === 'Button'
+ ? { ...c, importOverride: "import { DSButton } from '@ds/ui';" }
+ : c.componentName === 'Header'
+ ? { ...c, importOverride: "import { Header } from '@ds/ui';" }
+ : c
+ );
+ const out = buildImports({ components: patched, packageName: 'pkg' });
+ expect(out).toMatchInlineSnapshot(`
+ [
+ "import { DSButton as Button, Header } from \"@ds/ui\";",
+ ]
+ `);
+});
diff --git a/code/renderers/react/src/componentManifest/getComponentImports.ts b/code/renderers/react/src/componentManifest/getComponentImports.ts
new file mode 100644
index 000000000000..6d985397620e
--- /dev/null
+++ b/code/renderers/react/src/componentManifest/getComponentImports.ts
@@ -0,0 +1,466 @@
+import { dirname } from 'node:path';
+
+import { type NodePath, recast, types as t } from 'storybook/internal/babel';
+import { babelParse } from 'storybook/internal/babel';
+import { type CsfFile } from 'storybook/internal/csf-tools';
+
+import { getImportTag, getReactDocgen, matchPath } from './reactDocgen';
+import { cachedResolveImport } from './utils';
+
+// Public component metadata type used across passes
+export type ComponentRef = {
+ componentName: string;
+ localImportName?: string;
+ importId?: string;
+ importOverride?: string;
+ importName?: string;
+ namespace?: string;
+ path?: string;
+};
+
+const baseIdentifier = (component: string) => component.split('.')[0] ?? component;
+
+const isTypeSpecifier = (
+ s: t.ImportSpecifier | t.ImportDefaultSpecifier | t.ImportNamespaceSpecifier
+) => t.isImportSpecifier(s) && s.importKind === 'type';
+
+const importedName = (im: t.Identifier | t.StringLiteral) =>
+ t.isIdentifier(im) ? im.name : im.value;
+
+const addUniqueBy = (arr: T[], item: T, eq: (a: T) => boolean) => {
+ if (!arr.find(eq)) {
+ arr.push(item);
+ }
+};
+
+export const getComponents = ({
+ csf,
+ storyFilePath,
+}: {
+ csf: CsfFile;
+ storyFilePath?: string;
+}): ComponentRef[] => {
+ const program: NodePath = csf._file.path;
+
+ const componentSet = new Set();
+ const localToImport = new Map();
+
+ // Gather components from all JSX opening elements
+ program.traverse({
+ JSXOpeningElement(p) {
+ const n = p.node.name;
+ if (t.isJSXIdentifier(n)) {
+ const name = n.name;
+ if (name && /[A-Z]/.test(name.charAt(0))) {
+ componentSet.add(name);
+ }
+ } else if (t.isJSXMemberExpression(n)) {
+ const jsxNameToString = (name: t.JSXIdentifier | t.JSXMemberExpression): string =>
+ t.isJSXIdentifier(name)
+ ? name.name
+ : `${jsxNameToString(name.object)}.${jsxNameToString(name.property)}`;
+ const full = jsxNameToString(n);
+ componentSet.add(full);
+ }
+ },
+ });
+
+ // Add meta.component if present
+ const metaComp = csf._meta?.component;
+ if (metaComp) {
+ componentSet.add(metaComp);
+ }
+
+ const components = Array.from(componentSet).sort((a, b) => a.localeCompare(b));
+
+ const body = program.get('body');
+
+ // Collect import local bindings for component resolution (no package rewrite here)
+ for (const stmt of body) {
+ if (!stmt.isImportDeclaration()) {
+ continue;
+ }
+ const decl = stmt.node;
+
+ if (decl.importKind === 'type') {
+ continue;
+ }
+ const specifiers = decl.specifiers ?? [];
+
+ if (specifiers.length === 0) {
+ continue;
+ }
+
+ for (const s of specifiers) {
+ if (!('local' in s) || !s.local) {
+ continue;
+ }
+
+ if (isTypeSpecifier(s)) {
+ continue;
+ }
+
+ const importId = decl.source.value;
+ if (t.isImportDefaultSpecifier(s)) {
+ localToImport.set(s.local.name, { importId, importName: 'default' });
+ } else if (t.isImportNamespaceSpecifier(s)) {
+ localToImport.set(s.local.name, { importId, importName: '*' });
+ } else if (t.isImportSpecifier(s)) {
+ const imported = importedName(s.imported);
+ localToImport.set(s.local.name, { importId, importName: imported });
+ }
+ }
+ }
+
+ // Filter out locally defined components (those whose base identifier has a local, non-import binding)
+ const isLocallyDefinedWithoutImport = (base: string): boolean => {
+ const binding = program.scope.getBinding(base);
+
+ if (!binding) {
+ return false;
+ } // missing binding -> keep (will become null import) // missing binding -> keep (will become null import)
+ const isImportBinding = Boolean(
+ binding.path.isImportSpecifier?.() ||
+ binding.path.isImportDefaultSpecifier?.() ||
+ binding.path.isImportNamespaceSpecifier?.()
+ );
+ return !isImportBinding;
+ };
+
+ const filteredComponents = components.filter(
+ (c) => !isLocallyDefinedWithoutImport(baseIdentifier(c))
+ );
+
+ const componentObjs = filteredComponents
+ .map((c) => {
+ const dot = c.indexOf('.');
+ if (dot !== -1) {
+ const ns = c.slice(0, dot);
+ const member = c.slice(dot + 1);
+ const direct = localToImport.get(ns);
+ return !direct
+ ? { componentName: c }
+ : direct.importName === '*'
+ ? {
+ componentName: c,
+ localImportName: ns,
+ importId: direct.importId,
+ importName: member,
+ namespace: ns,
+ }
+ : {
+ componentName: c,
+ localImportName: ns,
+ importId: direct.importId,
+ importName: direct.importName,
+ };
+ }
+ const direct = localToImport.get(c);
+ return direct
+ ? {
+ componentName: c,
+ localImportName: c,
+ importId: direct.importId,
+ importName: direct.importName,
+ }
+ : { componentName: c };
+ })
+ .map((component) => {
+ let path;
+ try {
+ if (component.importId && storyFilePath) {
+ path = cachedResolveImport(matchPath(component.importId), {
+ basedir: dirname(storyFilePath),
+ });
+ }
+ } catch (e) {
+ console.error(e);
+ }
+ return { ...component, path };
+ })
+ .sort((a, b) => a.componentName.localeCompare(b.componentName));
+
+ return componentObjs;
+};
+
+export const getImports = ({
+ components,
+ packageName,
+}: {
+ components: ComponentRef[];
+ packageName?: string;
+}): string[] => {
+ // Group by source (after potential rewrite)
+ type Bucket = {
+ source: t.StringLiteral;
+ defaults: t.Identifier[];
+ namespaces: t.Identifier[];
+ named: t.ImportSpecifier[];
+ order: number;
+ };
+
+ const isRelative = (id: string) => id.startsWith('.') || id === '.';
+
+ const withSource = components
+ .filter((c) => Boolean(c.importId))
+ .map((c, idx) => {
+ const importId = c.importId!;
+ // If an importOverride is provided (and not a namespace import), override only the package/source
+ const overrideSource = (() => {
+ if (!c.importOverride || c.namespace) {
+ return undefined;
+ }
+ try {
+ const parsed = babelParse(c.importOverride);
+ const decl = parsed.program.body.find((n) => t.isImportDeclaration(n)) as
+ | t.ImportDeclaration
+ | undefined;
+ const src = decl?.source?.value;
+ return typeof src === 'string' ? src : undefined;
+ } catch {
+ return undefined;
+ }
+ })();
+ const rewritten =
+ overrideSource !== undefined
+ ? overrideSource
+ : packageName && isRelative(importId)
+ ? packageName
+ : importId;
+ return { c, src: t.stringLiteral(rewritten), key: rewritten, ord: idx };
+ });
+
+ const orderOfSource: Record = {};
+ for (const w of withSource) {
+ if (orderOfSource[w.key] === undefined) {
+ orderOfSource[w.key] = w.ord;
+ }
+ }
+
+ const buckets = new Map();
+
+ const ensureBucket = (key: string, src: t.StringLiteral): Bucket => {
+ const prev = buckets.get(key);
+
+ if (prev) {
+ return prev;
+ }
+ const b: Bucket = {
+ source: src,
+ defaults: [],
+ namespaces: [],
+ named: [],
+ order: orderOfSource[key] ?? 0,
+ };
+ buckets.set(key, b);
+ return b;
+ };
+
+ for (const { c, src, key } of withSource) {
+ const b = ensureBucket(key, src);
+
+ // Determine if this bucket was rewritten
+ const rewritten = src.value !== c.importId;
+
+ // If an importOverride provides a concrete specifier (default or named), respect it.
+ // Keep localImportName and componentName intact. Ignore namespace overrides.
+ const overrideSpec = (() => {
+ if (!c.importOverride || c.namespace) {
+ return undefined;
+ }
+ try {
+ const parsed = babelParse(c.importOverride);
+ const decl = parsed.program.body.find((n) => t.isImportDeclaration(n)) as
+ | t.ImportDeclaration
+ | undefined;
+ if (!decl) {
+ return undefined;
+ }
+ const spec = (decl.specifiers ?? []).find((s) => !isTypeSpecifier(s as any));
+ if (!spec) {
+ return undefined;
+ }
+ if (t.isImportNamespaceSpecifier(spec)) {
+ return undefined; // ignore namespace override
+ }
+ if (t.isImportDefaultSpecifier(spec)) {
+ return { kind: 'default' as const };
+ }
+ if (t.isImportSpecifier(spec)) {
+ const imported = t.isIdentifier(spec.imported) ? spec.imported.name : spec.imported.value;
+ return { kind: 'named' as const, imported };
+ }
+ return undefined;
+ } catch {
+ return undefined;
+ }
+ })();
+
+ if (overrideSpec) {
+ if (!c.localImportName) {
+ continue;
+ }
+ if (overrideSpec.kind === 'default') {
+ const id = t.identifier(c.localImportName);
+ // If the source was rewritten from relative, we still honor default per override
+ addUniqueBy(b.defaults, id, (d) => d.name === id.name);
+ continue;
+ }
+ if (overrideSpec.kind === 'named') {
+ const local = t.identifier(c.localImportName);
+ const imported = t.identifier(overrideSpec.imported);
+ addUniqueBy(
+ b.named,
+ t.importSpecifier(local, imported),
+ (n) => n.local.name === local.name && importedName(n.imported) === imported.name
+ );
+ continue;
+ }
+ }
+
+ if (c.namespace) {
+ // Real namespace import usage (only present for `* as` imports)
+ if (rewritten) {
+ // Convert to named members actually used; require a concrete member name
+ if (!c.importName) {
+ continue;
+ }
+ const member = c.importName;
+ const id = t.identifier(member);
+ addUniqueBy(
+ b.named,
+ t.importSpecifier(id, id),
+ (n) => n.local.name === member && importedName(n.imported) === member
+ );
+ } else {
+ // Keep namespace import by base identifier once
+ const ns = t.identifier(c.namespace);
+ addUniqueBy(b.namespaces, ns, (n) => n.name === ns.name);
+ }
+ continue;
+ }
+
+ if (c.importName === 'default') {
+ // localImportName is only emitted for imported components; add a defensive guard for TS
+ if (!c.localImportName) {
+ continue;
+ }
+ if (rewritten) {
+ // default from relative becomes named using local identifier
+ const id = t.identifier(c.localImportName);
+ addUniqueBy(
+ b.named,
+ t.importSpecifier(id, id),
+ (n) => n.local.name === id.name && importedName(n.imported) === id.name
+ );
+ } else {
+ const id = t.identifier(c.localImportName);
+ addUniqueBy(b.defaults, id, (d) => d.name === id.name);
+ }
+ continue;
+ }
+
+ if (c.importName) {
+ // named import (including named used as namespace base)
+ if (!c.localImportName) {
+ continue;
+ }
+ const local = t.identifier(c.localImportName);
+ const imported = t.identifier(c.importName);
+ addUniqueBy(
+ b.named,
+ t.importSpecifier(local, imported),
+ (n) => n.local.name === local.name && importedName(n.imported) === imported.name
+ );
+ continue;
+ }
+ }
+
+ // Print merged declarations
+ const merged: string[] = [];
+ const printDecl = (
+ specs: (t.ImportDefaultSpecifier | t.ImportNamespaceSpecifier | t.ImportSpecifier)[],
+ src: t.StringLiteral
+ ) => {
+ const node = t.importDeclaration(specs, src);
+ const code = recast.print(node, {}).code;
+ merged.push(code);
+ };
+
+ const sortedBuckets = Array.from(buckets.values()).sort((a, b) => a.order - b.order);
+ for (const bucket of sortedBuckets) {
+ const { source, defaults, namespaces, named } = bucket;
+
+ if (defaults.length === 0 && namespaces.length === 0 && named.length === 0) {
+ continue;
+ }
+
+ if (namespaces.length > 0) {
+ const firstSpecs: (t.ImportDefaultSpecifier | t.ImportNamespaceSpecifier)[] = [];
+
+ if (defaults[0]) {
+ firstSpecs.push(t.importDefaultSpecifier(defaults[0]));
+ }
+ firstSpecs.push(t.importNamespaceSpecifier(namespaces[0]));
+ printDecl(firstSpecs, source);
+
+ if (named.length > 0) {
+ printDecl(named, source);
+ }
+
+ for (const d of defaults.slice(1)) {
+ printDecl([t.importDefaultSpecifier(d)], source);
+ }
+
+ for (const ns of namespaces.slice(1)) {
+ printDecl([t.importNamespaceSpecifier(ns)], source);
+ }
+ } else {
+ if (defaults.length > 0 || named.length > 0) {
+ const specs: (t.ImportDefaultSpecifier | t.ImportSpecifier)[] = [];
+
+ if (defaults[0]) {
+ specs.push(t.importDefaultSpecifier(defaults[0]));
+ }
+ specs.push(...named);
+ printDecl(specs, source);
+ }
+
+ for (const d of defaults.slice(1)) {
+ printDecl([t.importDefaultSpecifier(d)], source);
+ }
+ }
+ }
+
+ return merged;
+};
+
+export function getComponentImports({
+ csf,
+ packageName,
+ storyFilePath,
+}: {
+ csf: CsfFile;
+ packageName?: string;
+ storyFilePath?: string;
+}): {
+ components: ComponentRef[];
+ imports: string[];
+} {
+ const components = getComponents({ csf, storyFilePath });
+ const withDocgen = components.map((component) => {
+ if (component.path) {
+ const docgen = getReactDocgen(component.path, component);
+ const importOverride = docgen.type === 'success' ? getImportTag(docgen.data) : undefined;
+ return {
+ ...component,
+ reactDocgen: docgen,
+ importOverride,
+ };
+ }
+ return component;
+ });
+
+ const imports = getImports({ components: withDocgen, packageName });
+ return { components: withDocgen, imports };
+}
diff --git a/code/renderers/react/src/componentManifest/reactDocgen.test.ts b/code/renderers/react/src/componentManifest/reactDocgen.test.ts
index e17922bfde73..4e1bdee58294 100644
--- a/code/renderers/react/src/componentManifest/reactDocgen.test.ts
+++ b/code/renderers/react/src/componentManifest/reactDocgen.test.ts
@@ -6,7 +6,7 @@ import { parseWithReactDocgen } from './reactDocgen';
async function parse(code: string, name = 'Component.tsx') {
const filename = `/virtual/${name}`;
- return parseWithReactDocgen({ code, filename });
+ return parseWithReactDocgen(code, filename);
}
describe('parseWithReactDocgen exportName coverage', () => {
diff --git a/code/renderers/react/src/componentManifest/reactDocgen.ts b/code/renderers/react/src/componentManifest/reactDocgen.ts
index 8031d64e8a8b..0ab92865d439 100644
--- a/code/renderers/react/src/componentManifest/reactDocgen.ts
+++ b/code/renderers/react/src/componentManifest/reactDocgen.ts
@@ -1,15 +1,12 @@
import { existsSync } from 'node:fs';
-import { sep } from 'node:path';
+import { dirname, sep } from 'node:path';
-import { types as t } from 'storybook/internal/babel';
-import { getProjectRoot } from 'storybook/internal/common';
-import { supportedExtensions } from 'storybook/internal/common';
-import { resolveImport } from 'storybook/internal/common';
-import { type CsfFile } from 'storybook/internal/csf-tools';
+import { babelParse, types as t } from 'storybook/internal/babel';
+import { getProjectRoot, supportedExtensions } from 'storybook/internal/common';
import * as find from 'empathic/find';
-import { type Documentation, ERROR_CODES } from 'react-docgen';
import {
+ type Documentation,
builtinHandlers as docgenHandlers,
builtinResolvers as docgenResolver,
makeFsImporter,
@@ -17,9 +14,12 @@ import {
} from 'react-docgen';
import * as TsconfigPaths from 'tsconfig-paths';
+import { type ComponentRef } from './getComponentImports';
+import { extractJSDocInfo } from './jsdocTags';
import actualNameHandler from './reactDocgen/actualNameHandler';
import { ReactDocgenResolveError } from './reactDocgen/docgenResolver';
import exportNameHandler from './reactDocgen/exportNameHandler';
+import { cached, cachedReadFileSync, cachedResolveImport } from './utils';
export type DocObj = Documentation & {
actualName: string;
@@ -32,72 +32,191 @@ const defaultHandlers = Object.values(docgenHandlers).map((handler) => handler);
const defaultResolver = new docgenResolver.FindExportedDefinitionsResolver();
const handlers = [...defaultHandlers, actualNameHandler, exportNameHandler];
-export function getMatchingDocgen(docgens: DocObj[], csf: CsfFile) {
- const componentName = csf._meta?.component;
+export function getMatchingDocgen(docgens: DocObj[], component: ComponentRef) {
if (docgens.length === 1) {
return docgens[0];
}
- const importSpecifier = csf._componentImportSpecifier;
-
- let importName: string;
- if (t.isImportSpecifier(importSpecifier)) {
- const imported = importSpecifier.imported;
- importName = t.isIdentifier(imported) ? imported.name : imported.value;
- } else if (t.isImportDefaultSpecifier(importSpecifier)) {
- importName = 'default';
- }
- const docgen = docgens.find((docgen) => docgen.exportName === importName);
- if (docgen || !componentName) {
- return docgen;
- }
- return docgens.find(
- (docgen) => docgen.displayName === componentName || docgen.actualName === componentName
- );
+ const matchingDocgen =
+ docgens.find((docgen) =>
+ [component.importName, component.localImportName].includes(docgen.exportName)
+ ) ??
+ docgens.find(
+ (docgen) =>
+ [component.importName, component.localImportName, component.componentName].includes(
+ docgen.displayName
+ ) ||
+ [component.importName, component.localImportName, component.componentName].includes(
+ docgen.actualName
+ )
+ );
+
+ return matchingDocgen ?? docgens[0];
}
-export async function parseWithReactDocgen({ code, filename }: { code: string; filename: string }) {
- const tsconfigPath = find.up('tsconfig.json', { cwd: process.cwd(), last: getProjectRoot() });
- const tsconfig = TsconfigPaths.loadConfig(tsconfigPath);
-
- let matchPath: TsconfigPaths.MatchPath | undefined;
+export function matchPath(id: string) {
+ const tsconfig = getTsConfig(process.cwd());
if (tsconfig.resultType === 'success') {
- matchPath = TsconfigPaths.createMatchPath(tsconfig.absoluteBaseUrl, tsconfig.paths, [
+ const match = TsconfigPaths.createMatchPath(tsconfig.absoluteBaseUrl, tsconfig.paths, [
'browser',
'module',
'main',
]);
+ return match(id, undefined, undefined, supportedExtensions) ?? id;
}
+ return id;
+}
- try {
+export const getTsConfig = cached(
+ (cwd: string) => {
+ const tsconfigPath = find.up('tsconfig.json', { cwd, last: getProjectRoot() });
+ return TsconfigPaths.loadConfig(tsconfigPath);
+ },
+ { name: 'getTsConfig' }
+);
+
+export const parseWithReactDocgen = cached(
+ (code: string, path: string) => {
return parse(code, {
resolver: defaultResolver,
handlers,
- importer: getReactDocgenImporter(matchPath),
- filename,
+ importer: getReactDocgenImporter(),
+ filename: path,
}) as DocObj[];
- } catch (e) {
- // Ignore the error when react-docgen cannot find a react component
- if (!(e instanceof Error && 'code' in e && e.code === ERROR_CODES.MISSING_DEFINITION)) {
- console.error(e);
+ },
+ { key: (code, path) => path, name: 'parseWithReactDocgen' }
+);
+
+const getExportPaths = cached(
+ (code: string, filePath: string) => {
+ const ast = (() => {
+ try {
+ return babelParse(code);
+ } catch (_) {
+ return undefined;
+ }
+ })();
+
+ if (!ast) {
+ return [] as string[];
}
- return [];
- }
-}
+ const basedir = dirname(filePath);
+ const body = ast.program.body;
+ return body
+ .flatMap((n) =>
+ t.isExportAllDeclaration(n)
+ ? [n.source.value]
+ : t.isExportNamedDeclaration(n) && !!n.source && !n.declaration
+ ? [n.source.value]
+ : []
+ )
+ .map((s) => matchPath(s))
+ .map((s) => {
+ try {
+ return cachedResolveImport(s, { basedir });
+ } catch {
+ return undefined;
+ }
+ })
+ .filter((p): p is string => !!p && !p.includes('node_modules'));
+ },
+ { name: 'getExportPaths' }
+);
+
+const gatherDocgensForPath = cached(
+ (
+ filePath: string,
+ depth: number
+ ): { docgens: DocObj[]; analyzed: { path: string; code: string }[] } => {
+ if (depth > 5 || filePath.includes('node_modules')) {
+ return { docgens: [], analyzed: [] };
+ }
+
+ let code: string | undefined;
+ try {
+ code = cachedReadFileSync(filePath, 'utf-8') as string;
+ } catch {}
-export function getReactDocgenImporter(matchPath: TsconfigPaths.MatchPath | undefined) {
+ if (!code) {
+ return { docgens: [], analyzed: [{ path: filePath, code: code! }] };
+ }
+
+ const reexportResults = getExportPaths(code, filePath).map((p) =>
+ gatherDocgensForPath(p, depth + 1)
+ );
+ const fromReexports = reexportResults.flatMap((r) => r.docgens);
+ const analyzedChildren = reexportResults.flatMap((r) => r.analyzed);
+
+ const locals = (() => {
+ try {
+ return parseWithReactDocgen(code as string, filePath);
+ } catch {
+ return [] as DocObj[];
+ }
+ })();
+
+ return {
+ docgens: [...locals, ...fromReexports],
+ analyzed: [{ path: filePath, code }, ...analyzedChildren],
+ };
+ },
+ { name: 'gatherDocgensWithTrace', key: (filePath) => filePath }
+);
+
+export const getReactDocgen = cached(
+ (
+ path: string,
+ component: ComponentRef
+ ):
+ | { type: 'success'; data: DocObj }
+ | { type: 'error'; error: { name: string; message: string } } => {
+ if (path.includes('node_modules')) {
+ return {
+ type: 'error',
+ error: {
+ name: 'Component file in node_modules',
+ message: `Component files in node_modules are not supported. Please import your component file directly.`,
+ },
+ };
+ }
+
+ const docgenWithInfo = gatherDocgensForPath(path, 0);
+ const docgens = docgenWithInfo.docgens;
+
+ const noCompDefError = {
+ type: 'error' as const,
+ error: {
+ name: 'No component definition found',
+ message:
+ `Could not find a component definition.\n` +
+ `Prefer relative imports if possible.\n` +
+ `Avoid pointing to transpiled files.\n` +
+ `You can debug your component file in this playground: https://react-docgen.dev/playground\n\n` +
+ docgenWithInfo.analyzed.map(({ path, code }) => `File: ${path}\n${code}`).join('\n\n'),
+ },
+ };
+
+ if (!docgens || docgens.length === 0) {
+ return noCompDefError;
+ }
+
+ const docgen = getMatchingDocgen(docgens, component);
+ if (!docgen) {
+ return noCompDefError;
+ }
+ return { type: 'success', data: docgen };
+ },
+ { name: 'getReactDocgen', key: (path, component) => path + JSON.stringify(component) }
+);
+
+export function getReactDocgenImporter() {
return makeFsImporter((filename, basedir) => {
const mappedFilenameByPaths = (() => {
- if (matchPath) {
- const match = matchPath(filename, undefined, undefined, supportedExtensions);
- return match || filename;
- } else {
- return filename;
- }
+ return matchPath(filename);
})();
- const result = resolveImport(mappedFilenameByPaths, { basedir });
+ const result = cachedResolveImport(mappedFilenameByPaths, { basedir });
if (result.includes(`${sep}react-native${sep}index.js`)) {
const replaced = result.replace(
@@ -117,3 +236,9 @@ export function getReactDocgenImporter(matchPath: TsconfigPaths.MatchPath | unde
throw new ReactDocgenResolveError(filename);
});
}
+
+export function getImportTag(docgen: DocObj) {
+ const jsdocComment = docgen?.description;
+ const tags = jsdocComment ? extractJSDocInfo(jsdocComment).tags : undefined;
+ return tags?.import?.[0];
+}
diff --git a/code/renderers/react/src/componentManifest/test-utils.ts b/code/renderers/react/src/componentManifest/test-utils.ts
new file mode 100644
index 000000000000..63da8f0c2778
--- /dev/null
+++ b/code/renderers/react/src/componentManifest/test-utils.ts
@@ -0,0 +1,112 @@
+import { dedent } from 'ts-dedent';
+
+export const fsMocks = {
+ ['./package.json']: JSON.stringify({ name: 'some-package' }),
+ ['./src/stories/Button.stories.ts']: dedent`
+ import type { Meta, StoryObj } from '@storybook/react';
+ import { fn } from 'storybook/test';
+ import { Button } from './Button';
+
+ const meta = {
+ component: Button,
+ args: { onClick: fn() },
+ } satisfies Meta;
+ export default meta;
+ type Story = StoryObj;
+
+ export const Primary: Story = { args: { primary: true, label: 'Button' } };
+ export const Secondary: Story = { args: { label: 'Button' } };
+ export const Large: Story = { args: { size: 'large', label: 'Button' } };
+ export const Small: Story = { args: { size: 'small', label: 'Button' } };`,
+ ['./src/stories/Button.tsx']: dedent`
+ import React from 'react';
+ export interface ButtonProps {
+ /** Description of primary */
+ primary?: boolean;
+ backgroundColor?: string;
+ size?: 'small' | 'medium' | 'large';
+ label: string;
+ onClick?: () => void;
+ }
+
+ /**
+ * Primary UI component for user interaction
+ * @import import { Button } from '@design-system/components/Button';
+ */
+ export const Button = ({
+ primary = false,
+ size = 'medium',
+ backgroundColor,
+ label,
+ ...props
+ }: ButtonProps) => {
+ const mode = primary ? 'storybook-button--primary' : 'storybook-button--secondary';
+ return (
+
+ );
+ };`,
+ ['./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) => (
+
+ );`,
+};
diff --git a/code/renderers/react/src/componentManifest/utils.test.ts b/code/renderers/react/src/componentManifest/utils.test.ts
new file mode 100644
index 000000000000..7b89145e8276
--- /dev/null
+++ b/code/renderers/react/src/componentManifest/utils.test.ts
@@ -0,0 +1,131 @@
+import { expect, test, vi } from 'vitest';
+
+import { cached, groupBy, invalidateCache, invariant } from './utils';
+
+// Helpers
+const calls = () => {
+ let n = 0;
+ return {
+ inc: () => ++n,
+ count: () => n,
+ };
+};
+
+test('groupBy groups items by key function', () => {
+ const items = [
+ { k: 'a', v: 1 },
+ { k: 'b', v: 2 },
+ { k: 'a', v: 3 },
+ ];
+ const grouped = groupBy(items, (it) => it.k);
+ expect(grouped).toMatchInlineSnapshot(`
+ {
+ "a": [
+ {
+ "k": "a",
+ "v": 1,
+ },
+ {
+ "k": "a",
+ "v": 3,
+ },
+ ],
+ "b": [
+ {
+ "k": "b",
+ "v": 2,
+ },
+ ],
+ }
+ `);
+});
+
+test('invariant throws only when condition is falsy and lazily evaluates message', () => {
+ const spy = vi.fn(() => 'Expensive message');
+
+ // True branch: does not throw and does not call message factory
+ expect(() => invariant(true, spy)).not.toThrow();
+ expect(spy).not.toHaveBeenCalled();
+
+ // False branch: throws and evaluates message lazily
+ expect(() => invariant(false, spy)).toThrowError('Expensive message');
+ expect(spy).toHaveBeenCalledTimes(1);
+});
+
+test('cached memoizes by default on first argument value', () => {
+ const c = calls();
+ const fn = (x: number) => (c.inc(), x * 2);
+ const m = cached(fn);
+
+ expect(m(2)).toBe(4);
+ expect(m(2)).toBe(4);
+ expect(m(3)).toBe(6);
+ expect(m(3)).toBe(6);
+
+ // Underlying function should have been called only once per distinct key (2 keys => 2 calls)
+ expect(c.count()).toBe(2);
+});
+
+test('cached supports custom key selector', () => {
+ const c = calls();
+ const fn = (x: number, y: number) => (c.inc(), x + y);
+ // Cache only by the first arg
+ const m = cached(fn, { key: (x) => `${x}` });
+
+ expect(m(1, 10)).toBe(11);
+ expect(m(1, 99)).toBe(11); // cached by key 1, result should be from first call
+ expect(m(2, 5)).toBe(7);
+ expect(m(2, 8)).toBe(7);
+
+ expect(c.count()).toBe(2);
+});
+
+test('cached stores and returns undefined results without recomputing', () => {
+ const c = calls();
+ const fn = (x: string) => {
+ c.inc();
+ return x === 'hit' ? undefined : x.toUpperCase();
+ };
+ const m = cached(fn);
+
+ expect(m('hit')).toBeUndefined();
+ expect(m('hit')).toBeUndefined();
+ expect(m('miss')).toBe('MISS');
+ expect(m('miss')).toBe('MISS');
+
+ expect(c.count()).toBe(2);
+});
+
+test('cached shares cache across wrappers of the same function', () => {
+ const c = calls();
+ const f = (x: string) => (c.inc(), x.length);
+
+ const m1 = cached(f, { key: (x) => x });
+ const m2 = cached(f, { key: (x) => x });
+
+ // First computes via m1 and caches the value 3 for key 'foo'
+ expect(m1('foo')).toBe(3);
+ // m2 should now return the cached value (from shared module store), not call f again
+ expect(m2('foo')).toBe(3);
+
+ // Verify call counts: underlying function called once
+ expect(c.count()).toBe(1);
+});
+
+test('invalidateCache clears the module-level memo store', () => {
+ const c = calls();
+ const f = (x: number) => (c.inc(), x * 2);
+ const m = cached(f);
+
+ expect(m(2)).toBe(4);
+ expect(c.count()).toBe(1);
+
+ // Cached result
+ expect(m(2)).toBe(4);
+ expect(c.count()).toBe(1);
+
+ // Invalidate and ensure it recomputes
+ invalidateCache();
+ expect(m(2)).toBe(4);
+ expect(c.count()).toBe(2);
+});
diff --git a/code/renderers/react/src/componentManifest/utils.ts b/code/renderers/react/src/componentManifest/utils.ts
index bd61797f98ad..9b03823211c5 100644
--- a/code/renderers/react/src/componentManifest/utils.ts
+++ b/code/renderers/react/src/componentManifest/utils.ts
@@ -1,11 +1,20 @@
// Object.groupBy polyfill
+import { readFileSync } from 'node:fs';
+
+import { resolveImport } from 'storybook/internal/common';
+import { logger } from 'storybook/internal/node-logger';
+
+import * as find from 'empathic/find';
+
export const groupBy = (
items: T[],
keySelector: (item: T, index: number) => K
) => {
return items.reduce>>((acc = {}, item, index) => {
const key = keySelector(item, index);
- acc[key] ??= [];
+ if (!Array.isArray(acc[key])) {
+ acc[key] = [];
+ }
acc[key].push(item);
return acc;
}, {});
@@ -21,3 +30,99 @@ export function invariant(
}
throw new Error((typeof message === 'function' ? message() : message) ?? 'Invariant failed');
}
+
+// Module-level cache stores: per-function caches keyed by derived string keys
+// memoStore caches synchronous function results
+let memoStore: WeakMap<(...args: any[]) => any, Map> = new WeakMap();
+// asyncMemoStore caches resolved values for async functions (never stores Promises)
+let asyncMemoStore: WeakMap<(...args: any[]) => any, Map> = new WeakMap();
+
+// Generic cache/memoization helper
+// - Caches by a derived key from the function arguments (must be a string)
+// - Supports caching of `undefined` results (uses Map.has to distinguish)
+// - Uses module-level stores so multiple wrappers around the same function share cache
+// - Never stores a Promise; for async functions, we cache only the resolved value. Concurrent calls are not de-duped.
+export const cached = (
+ fn: (...args: A) => R,
+ opts: { key?: (...args: A) => string; name?: string } = {}
+): ((...args: A) => R) => {
+ const keyOf: (...args: A) => string =
+ opts.key ??
+ ((...args: A) => {
+ try {
+ // Prefer a stable string key based on the full arguments list
+ return JSON.stringify(args) ?? String(args[0]);
+ } catch {
+ // Fallback: use the first argument if it is not serializable
+ return String(args[0]);
+ }
+ });
+
+ return (...args: A) => {
+ const k = keyOf(...args);
+ const name = fn.name || opts.name || 'anonymous';
+
+ // Ensure stores exist for this function
+ let syncStore = memoStore.get(fn);
+ if (!syncStore) {
+ syncStore = new Map();
+ memoStore.set(fn, syncStore);
+ }
+ let asyncStore = asyncMemoStore.get(fn);
+ if (!asyncStore) {
+ asyncStore = new Map();
+ asyncMemoStore.set(fn, asyncStore);
+ }
+
+ // Fast path: sync cached
+ if (syncStore.has(k)) {
+ // Log cache hit
+ try {
+ logger.verbose(`[cache] hit (sync) ${name} key=${k}`);
+ } catch {}
+ return syncStore.get(k);
+ }
+
+ // Fast path: async resolved cached
+ if (asyncStore.has(k)) {
+ logger.verbose(`[cache] hit (async) ${name} key=${k}`);
+ return Promise.resolve(asyncStore.get(k));
+ }
+
+ // Compute result with benchmarking
+ const start = Date.now();
+ const result = fn(...args);
+
+ // If it's a promise-returning function, cache the resolved value later
+ const isPromise =
+ result &&
+ typeof result === 'object' &&
+ 'then' in result &&
+ typeof (result as any).then === 'function';
+ if (isPromise) {
+ return (result as any).then((val: any) => {
+ const duration = Date.now() - start;
+ asyncStore!.set(k, val);
+ logger.verbose(`[cache] miss ${name} took ${duration}ms key=${k}`);
+ return val as R;
+ });
+ } else {
+ const duration = Date.now() - start;
+ syncStore.set(k, result);
+ logger.verbose(`[cache] miss ${name} took ${duration}ms key=${k}`);
+ return result;
+ }
+ };
+};
+
+export const invalidateCache = () => {
+ // Reinitialize the module-level stores
+ memoStore = new WeakMap();
+ asyncMemoStore = new WeakMap();
+};
+
+export const cachedReadFileSync = cached(readFileSync, { name: 'cachedReadFile' });
+
+export const cachedFindUp = cached(find.up, { name: 'findUp' });
+
+export const cachedResolveImport = cached(resolveImport, { name: 'resolveImport' });
diff --git a/code/renderers/react/vitest.setup.ts b/code/renderers/react/vitest.setup.ts
index 3245d9759235..9d94dc3342d7 100644
--- a/code/renderers/react/vitest.setup.ts
+++ b/code/renderers/react/vitest.setup.ts
@@ -1,6 +1,10 @@
import { afterEach, vi } from 'vitest';
+import { invalidateCache } from './src/componentManifest/utils';
+
afterEach(() => {
// can not run in beforeEach because then all { spy: true } mocks get removed
vi.restoreAllMocks();
+
+ invalidateCache();
});
diff --git a/code/vitest-setup.ts b/code/vitest-setup.ts
index a13755321eb5..c0b325e01289 100644
--- a/code/vitest-setup.ts
+++ b/code/vitest-setup.ts
@@ -90,6 +90,7 @@ vi.mock('storybook/internal/node-logger', async (importOriginal) => {
info: vi.fn(),
trace: vi.fn(),
debug: vi.fn(),
+ verbose: vi.fn(),
logBox: vi.fn(),
intro: vi.fn(),
outro: vi.fn(),
diff --git a/code/yarn.lock b/code/yarn.lock
index d6028e53f751..d324b60ab7ad 100644
--- a/code/yarn.lock
+++ b/code/yarn.lock
@@ -6824,6 +6824,7 @@ __metadata:
acorn-walk: "npm:^7.2.0"
babel-plugin-react-docgen: "npm:^4.2.1"
comment-parser: "npm:^1.4.1"
+ empathic: "npm:^2.0.0"
es-toolkit: "npm:^1.36.0"
escodegen: "npm:^2.1.0"
expect-type: "npm:^0.15.0"