From 40c0476bb74a89135347522a57822ca7002dd872 Mon Sep 17 00:00:00 2001 From: Kasper Peulen Date: Mon, 27 Oct 2025 17:12:28 +0100 Subject: [PATCH 01/15] Improve error handling of component manifest generation --- code/core/src/csf-tools/CsfFile.ts | 20 +- code/core/src/types/modules/core-common.ts | 6 +- .../generateCodeSnippet.test.tsx | 11 +- .../componentManifest/generateCodeSnippet.ts | 104 +++++---- .../src/componentManifest/generator.test.ts | 207 +++++++++++++++++- .../react/src/componentManifest/generator.ts | 83 +++++-- .../react/src/componentManifest/utils.ts | 7 + code/renderers/react/src/enrichCsf.ts | 16 +- 8 files changed, 381 insertions(+), 73 deletions(-) diff --git a/code/core/src/csf-tools/CsfFile.ts b/code/core/src/csf-tools/CsfFile.ts index 33b9c0c013c5..3d0b310a21b3 100644 --- a/code/core/src/csf-tools/CsfFile.ts +++ b/code/core/src/csf-tools/CsfFile.ts @@ -33,7 +33,7 @@ interface BabelFile { opts: any; hub: any; metadata: object; - path: any; + path: NodePath; scope: any; inputMap: object | null; code: string; @@ -301,6 +301,8 @@ export class CsfFile { _metaStatement: t.Statement | undefined; + _metaStatementPath: NodePath | undefined; + _metaNode: t.ObjectExpression | undefined; _metaPath: NodePath | undefined; @@ -484,11 +486,22 @@ export class CsfFile { t.isVariableDeclaration(topLevelNode) && topLevelNode.declarations.find(isVariableDeclarator) ); + + self._metaStatementPath = + self._file.path + .get('body') + .find( + (topLevelPath) => + topLevelPath.isVariableDeclaration() && + topLevelPath.node.declarations.some(isVariableDeclarator) + ) ?? undefined; + decl = ((self?._metaStatement as t.VariableDeclaration)?.declarations || []).find( isVariableDeclarator )?.init; } else { self._metaStatement = node; + self._metaStatementPath = path; decl = node.declaration; } @@ -1036,7 +1049,10 @@ export const babelParseFile = ({ filename?: string; ast?: t.File; }): BabelFile => { - return new BabelFileClass({ filename }, { code, ast: ast ?? babelParse(code) }); + return new BabelFileClass( + { filename, highlightCode: false }, + { code, ast: ast ?? babelParse(code) } + ); }; export const loadCsf = (code: string, options: CsfOptions) => { diff --git a/code/core/src/types/modules/core-common.ts b/code/core/src/types/modules/core-common.ts index c24da7a5d473..1bc1c0835fd4 100644 --- a/code/core/src/types/modules/core-common.ts +++ b/code/core/src/types/modules/core-common.ts @@ -347,12 +347,14 @@ export type TagsOptions = Record>; export interface ComponentManifest { id: string; - name: string; + path: string; + name?: string; description?: string; import?: string; summary?: string; - examples: { name: string; snippet: string }[]; + examples: { name: string; snippet?: string; error?: { message: string } }[]; jsDocTags: Record; + error?: { message: string }; } export interface ComponentsManifest { diff --git a/code/renderers/react/src/componentManifest/generateCodeSnippet.test.tsx b/code/renderers/react/src/componentManifest/generateCodeSnippet.test.tsx index 7912d2843e50..88babccebda2 100644 --- a/code/renderers/react/src/componentManifest/generateCodeSnippet.test.tsx +++ b/code/renderers/react/src/componentManifest/generateCodeSnippet.test.tsx @@ -81,8 +81,15 @@ test('Edge case identifier we can not find', () => { const input = withCSF3(` export const Default = someImportOrWhatever; `); - expect(generateExample(input)).toMatchInlineSnapshot( - `"const Default = () => ;"` + expect(() => generateExample(input)).toThrowErrorMatchingInlineSnapshot( + ` + [SyntaxError: Expected story to be csf factory, function or an object expression + 11 | + 12 | + > 13 | export const Default = someImportOrWhatever; + | ^^^^^^^^^^^^^^^^^^^^ + 14 | ] + ` ); }); diff --git a/code/renderers/react/src/componentManifest/generateCodeSnippet.ts b/code/renderers/react/src/componentManifest/generateCodeSnippet.ts index 12fbd31a1a4c..c0256d1c5de5 100644 --- a/code/renderers/react/src/componentManifest/generateCodeSnippet.ts +++ b/code/renderers/react/src/componentManifest/generateCodeSnippet.ts @@ -1,11 +1,6 @@ import { type NodePath, types as t } from 'storybook/internal/babel'; -function invariant(condition: any, message?: string | (() => string)): asserts condition { - if (condition) { - return; - } - throw new Error(typeof message === 'function' ? message() : message); -} +import { invariant } from './utils'; function buildInvalidSpread(entries: Array<[string, t.Node]>): t.JSXSpreadAttribute | null { if (entries.length === 0) { @@ -23,19 +18,34 @@ function buildInvalidSpread(entries: Array<[string, t.Node]>): t.JSXSpreadAttrib export function getCodeSnippet( storyExportPath: NodePath, metaObj: t.ObjectExpression | null | undefined, - componentName: string + componentName?: string ): t.VariableDeclaration { - const declaration = storyExportPath.get('declaration') as NodePath; - invariant(declaration.isVariableDeclaration(), 'Expected variable declaration'); + const declaration = storyExportPath.get('declaration'); + invariant( + declaration.isVariableDeclaration(), + () => storyExportPath.buildCodeFrameError('Expected story to be a variable declaration').message + ); - const declarator = declaration.get('declarations')[0] as NodePath; - const init = declarator.get('init') as NodePath; - invariant(init.isExpression(), 'Expected story initializer to be an expression'); + const declarations = declaration.get('declarations'); + invariant( + declarations.length === 1, + storyExportPath.buildCodeFrameError('Expected one story declaration').message + ); + + const declarator = declarations[0]; + const init = declarator.get('init'); + invariant( + init.isExpression(), + () => declarator.buildCodeFrameError('Expected story initializer to be an expression').message + ); const storyId = declarator.get('id'); - invariant(storyId.isIdentifier(), 'Expected named const story export'); + invariant( + storyId.isIdentifier(), + () => declaration.buildCodeFrameError('Expected story to have a name').message + ); - let story: NodePath | null = init; + let normalizedInit: NodePath = init; if (init.isCallExpression()) { const callee = init.get('callee'); @@ -50,47 +60,63 @@ export function getCodeSnippet( if (obj.isIdentifier() && isBind) { const resolved = resolveBindIdentifierInit(storyExportPath, obj); if (resolved) { - story = resolved; + normalizedInit = resolved; } } } // Fallback: treat call expression as story factory and use first argument - if (story === init) { + if (init === normalizedInit) { const args = init.get('arguments'); - if (args.length === 0) { - story = null; - } else { - const storyArgument = args[0]; - invariant(storyArgument.isExpression()); - story = storyArgument; - } + invariant( + args.length === 1, + () => init.buildCodeFrameError('Could not evaluate story expression').message + ); + const storyArgument = args[0]; + invariant( + storyArgument.isExpression(), + () => init.buildCodeFrameError('Could not evaluate story expression').message + ); + normalizedInit = storyArgument; } } + normalizedInit = normalizedInit.isTSSatisfiesExpression() + ? normalizedInit.get('expression') + : normalizedInit.isTSAsExpression() + ? normalizedInit.get('expression') + : normalizedInit; + // If the story is already a function, try to inline args like in render() when using `{...args}` - // Otherwise it must be an object story - const storyObjPath = - story == null || story.isArrowFunctionExpression() || story.isFunctionExpression() - ? null - : story.isTSSatisfiesExpression() - ? story.get('expression') - : story.isTSAsExpression() - ? story.get('expression') - : story; + let story: NodePath; + if (normalizedInit.isArrowFunctionExpression() || normalizedInit.isFunctionExpression()) { + story = normalizedInit; + } else if (normalizedInit.isObjectExpression()) { + story = normalizedInit; + } else { + throw normalizedInit.buildCodeFrameError( + 'Expected story to be csf factory, function or an object expression' + ); + } - const storyProperties = storyObjPath?.isObjectExpression() - ? storyObjPath.get('properties').filter((p) => p.isObjectProperty()) - : []; + const storyProperties = story?.isObjectExpression() + ? story.get('properties').filter((p) => p.isObjectProperty()) + : // Find CSF2 properties + []; // Prefer an explicit render() when it is a function (arrow/function) const renderPath = storyProperties .filter((p) => keyOf(p.node) === 'render') .map((p) => p.get('value')) - .find((value) => value.isExpression()); + .find( + (value): value is NodePath => + value.isArrowFunctionExpression() || value.isFunctionExpression() + ); - const storyFn = renderPath ?? story; + const storyFn = + renderPath ?? + (story.isArrowFunctionExpression() ? story : story.isFunctionExpression() ? story : undefined); // Collect args: meta.args and story.args as Record const metaArgs = metaArgsRecord(metaObj ?? null); @@ -112,7 +138,7 @@ export function getCodeSnippet( .map(([k, v]) => toAttr(k, v)) .filter((a): a is t.JSXAttribute => Boolean(a)); - if (storyFn?.isArrowFunctionExpression() || storyFn?.isFunctionExpression()) { + if (storyFn) { const fn = storyFn.node; // Only handle arrow function with direct JSX expression body for now @@ -224,6 +250,8 @@ export function getCodeSnippet( // Build spread for invalid-only props, if any const invalidSpread = buildInvalidSpread(invalidEntries); + invariant(componentName, 'Could not generate snippet without component name.'); + const name = t.jsxIdentifier(componentName); const openingElAttrs: Array = [ diff --git a/code/renderers/react/src/componentManifest/generator.test.ts b/code/renderers/react/src/componentManifest/generator.test.ts index d50652724469..f5beadf7aede 100644 --- a/code/renderers/react/src/componentManifest/generator.test.ts +++ b/code/renderers/react/src/componentManifest/generator.test.ts @@ -4,13 +4,12 @@ import { type StoryIndexGenerator } from 'storybook/internal/core-server'; import { vol } from 'memfs'; import { dedent } from 'ts-dedent'; -import { loadConfig } from 'tsconfig-paths'; import { componentManifestGenerator } from './generator'; vi.mock('node:fs/promises', async () => (await import('memfs')).fs.promises); vi.mock('node:fs', async () => (await import('memfs')).fs); -vi.mock('tsconfig-paths', { spy: true }); +vi.mock('tsconfig-paths', () => ({ loadConfig: () => ({ resultType: null!, message: null! }) })); // Use the provided indexJson from this file const indexJson = { @@ -95,7 +94,6 @@ const indexJson = { }; beforeEach(() => { - vi.mocked(loadConfig).mockImplementation(() => ({ resultType: null!, message: null! })); vi.spyOn(process, 'cwd').mockReturnValue('/app'); vol.fromJSON( { @@ -217,6 +215,7 @@ test('componentManifestGenerator generates correct id, name, description and exa "components": { "example-button": { "description": "Primary UI component for user interaction", + "error": undefined, "examples": [ { "name": "Primary", @@ -239,6 +238,7 @@ test('componentManifestGenerator generates correct id, name, description and exa "import": undefined, "jsDocTags": {}, "name": "Button", + "path": "./src/stories/Button.stories.ts", "reactDocgen": { "actualName": "Button", "definedInFile": "/app/src/stories/Button.tsx", @@ -319,6 +319,7 @@ test('componentManifestGenerator generates correct id, name, description and exa }, "example-header": { "description": "Description from meta and very long.", + "error": undefined, "examples": [ { "name": "LoggedIn", @@ -344,6 +345,7 @@ test('componentManifestGenerator generates correct id, name, description and exa ], }, "name": "Header", + "path": "./src/stories/Header.stories.ts", "reactDocgen": { "actualName": "", "definedInFile": "/app/src/stories/Header.tsx", @@ -412,3 +414,202 @@ test('componentManifestGenerator generates correct id, name, description and exa } `); }); + +async function getManifestForStory(code: string) { + vol.fromJSON( + { + ['./src/stories/Button.stories.ts']: code, + ['./src/stories/Button.tsx']: dedent` + import React from 'react'; + export interface ButtonProps { + /** Description of primary */ + primary?: boolean; + } + + /** Primary UI component for user interaction */ + export const Button = ({ + primary = false, + }: ButtonProps) => { + const mode = primary ? 'storybook-button--primary' : 'storybook-button--secondary'; + return ( + + ); + };`, + }, + '/app' + ); + + const generator = await componentManifestGenerator(); + const indexJson = { + v: 5, + entries: { + 'example-button--primary': { + type: 'story', + subtype: 'story', + id: 'example-button--primary', + name: 'Primary', + title: 'Example/Button', + importPath: './src/stories/Button.stories.ts', + componentPath: './src/stories/Button.tsx', + tags: ['dev', 'test', 'vitest', 'autodocs'], + exportName: 'Primary', + }, + }, + }; + + const manifest = await generator({ + getIndex: async () => indexJson, + } as unknown as StoryIndexGenerator); + + return manifest.components['example-button']; +} + +function withCSF3(body: string) { + return dedent` + import type { Meta } from '@storybook/react'; + import { Button } from './Button'; + + const meta = { + component: Button, + args: { onClick: fn() }, + } satisfies Meta; + export default meta; + + ${body} + `; +} + +test('no component field', async () => { + const code = dedent` + import type { Meta } from '@storybook/react'; + import { Button } from './Button'; + + export default { + args: { onClick: fn() }, + }; + + export const Primary = {}; + `; + expect(await getManifestForStory(code)).toMatchInlineSnapshot(` + { + "error": { + "message": "Specify meta.component for the component to be included in the manifest. + 2 | import { Button } from './Button'; + 3 | + > 4 | export default { + | ^ + 5 | args: { onClick: fn() }, + 6 | }; + 7 |", + }, + "examples": [], + "id": "example-button", + "jsDocTags": {}, + "path": "./src/stories/Button.stories.ts", + } + `); +}); + +test('component exported from other file', async () => { + const code = withCSF3(dedent` + export { Primary } from './other-file'; + `); + expect(await getManifestForStory(code)).toMatchInlineSnapshot(` + { + "description": "Primary UI component for user interaction", + "error": undefined, + "examples": [ + { + "error": { + "message": "Expected story to be a variable declaration + 8 | export default meta; + 9 | + > 10 | export { Primary } from './other-file'; + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^", + }, + "name": "Primary", + }, + ], + "id": "example-button", + "import": undefined, + "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", + }, + }, + }, + }, + "summary": undefined, + } + `); +}); + +test('unknown expressions', async () => { + const code = withCSF3(dedent` + export const Primary = someWeirdExpression; + `); + expect(await getManifestForStory(code)).toMatchInlineSnapshot(` + { + "description": "Primary UI component for user interaction", + "error": undefined, + "examples": [ + { + "error": { + "message": "Expected story to be csf factory, function or an object expression + 8 | export default meta; + 9 | + > 10 | export const Primary = someWeirdExpression; + | ^^^^^^^^^^^^^^^^^^^", + }, + "name": "Primary", + }, + ], + "id": "example-button", + "import": undefined, + "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", + }, + }, + }, + }, + "summary": undefined, + } + `); +}); diff --git a/code/renderers/react/src/componentManifest/generator.ts b/code/renderers/react/src/componentManifest/generator.ts index afe58b703144..adf513820ae8 100644 --- a/code/renderers/react/src/componentManifest/generator.ts +++ b/code/renderers/react/src/componentManifest/generator.ts @@ -11,7 +11,7 @@ import path from 'pathe'; import { getCodeSnippet } from './generateCodeSnippet'; import { extractJSDocInfo } from './jsdocTags'; import { type DocObj, getMatchingDocgen, parseWithReactDocgen } from './reactDocgen'; -import { groupBy } from './utils'; +import { groupBy, invariant } from './utils'; interface ReactComponentManifest extends ComponentManifest { reactDocgen?: DocObj; @@ -30,35 +30,75 @@ export const componentManifestGenerator = async () => { group && group?.length > 0 ? [group[0]] : [] ); const components = await Promise.all( - singleEntryPerComponent.flatMap(async (entry) => { + 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 componentName = csf._meta?.component; + const id = entry.id.split('--')[0]; + const importPath = entry.importPath; + + const base = { + id, + path: importPath, + examples: [], + jsDocTags: {}, + } satisfies Partial; if (!componentName) { - // TODO when there is not component name we could generate snippets based on the meta.render - return; + const message = + 'Specify meta.component for the component to be included in the manifest.'; + return { + ...base, + error: { + message: csf._metaStatementPath?.buildCodeFrameError(message).message ?? message, + }, + }; } + const name = componentName; const examples = Object.entries(csf._storyPaths) - .map(([name, path]) => ({ - name, - snippet: recast.print(getCodeSnippet(path, csf._metaNode ?? null, componentName)).code, - })) + .map(([storyName, path]) => { + try { + return { + name: storyName, + snippet: recast.print(getCodeSnippet(path, csf._metaNode ?? null, name)).code, + }; + } catch (e) { + invariant(e instanceof Error); + return { + name: storyName, + error: { + message: e.message, + }, + }; + } + }) .filter(Boolean); - const id = entry.id.split('--')[0]; + if (!entry.componentPath) { + const message = `No component file found for the "${name}" component.`; + return { + ...base, + name, + examples, + error: { message }, + }; + } - const componentFile = await readFile( - path.join(process.cwd(), entry.componentPath!), - 'utf-8' - ).catch(() => { - // TODO This happens too often. We should improve the componentPath resolution. - return null; - }); + let componentFile; - if (!componentFile || !entry.componentPath) { - return { id, name: componentName, examples, jsDocTags: {} }; + try { + componentFile = await readFile(path.join(process.cwd(), entry.componentPath!), 'utf-8'); + } catch (e) { + invariant(e instanceof Error); + return { + ...base, + name, + examples, + error: { + message: `Could not read the component file located at ${entry.componentPath}`, + }, + }; } const docgens = await parseWithReactDocgen({ @@ -67,6 +107,8 @@ export const componentManifestGenerator = async () => { }); const docgen = getMatchingDocgen(docgens, csf); + const error = !docgen ? { message: 'Docgen could not find a component' } : undefined; + const metaDescription = extractDescription(csf._metaStatement); const jsdocComment = metaDescription || docgen?.description; const { tags = {}, description } = jsdocComment ? extractJSDocInfo(jsdocComment) : {}; @@ -74,7 +116,7 @@ export const componentManifestGenerator = async () => { const manifestDescription = (tags?.describe?.[0] || tags?.desc?.[0]) ?? description; return { - id, + ...base, name: componentName, description: manifestDescription?.trim(), summary: tags.summary?.[0], @@ -82,7 +124,8 @@ export const componentManifestGenerator = async () => { reactDocgen: docgen, jsDocTags: tags, examples, - } satisfies ReactComponentManifest; + error, + }; }) ); diff --git a/code/renderers/react/src/componentManifest/utils.ts b/code/renderers/react/src/componentManifest/utils.ts index 69e40f0c5bd0..f92c97ea6555 100644 --- a/code/renderers/react/src/componentManifest/utils.ts +++ b/code/renderers/react/src/componentManifest/utils.ts @@ -10,3 +10,10 @@ export const groupBy = ( return acc; }, {}); }; + +export function invariant(condition: any, message?: string | (() => string)): asserts condition { + if (condition) { + return; + } + throw new Error((typeof message === 'function' ? message() : message) ?? 'Invariant failed'); +} diff --git a/code/renderers/react/src/enrichCsf.ts b/code/renderers/react/src/enrichCsf.ts index d56e2fc2ab3d..30b98b29c016 100644 --- a/code/renderers/react/src/enrichCsf.ts +++ b/code/renderers/react/src/enrichCsf.ts @@ -18,17 +18,21 @@ export const enrichCsf: PresetPropertyFn<'experimental_enrichCsf'> = async (inpu return; } const { format } = await getPrettier(); + let node; + try { + node = getCodeSnippet(storyExport, csfSource._metaNode, csfSource._meta?.component); + } catch (e) { + // don't bother the user if we can't generate a snippet + return; + } let snippet; try { - const code = recast.print( - getCodeSnippet(storyExport, csfSource._metaNode, csfSource._meta?.component) - ).code; - // TODO read the user config - snippet = await format(code, { filepath: join(process.cwd(), 'component.tsx') }); + snippet = await format(recast.print(node).code, { + filepath: join(process.cwd(), 'component.tsx'), + }); } catch (e) { - // don't bother the user if we can't generate a snippet return; } From 4626b94ca9a1c9c0abc231a50c3f48550fb37df7 Mon Sep 17 00:00:00 2001 From: Kasper Peulen Date: Tue, 28 Oct 2025 09:55:56 +0100 Subject: [PATCH 02/15] Refactor --- code/core/src/csf-tools/CsfFile.ts | 24 +++++++++--------------- 1 file changed, 9 insertions(+), 15 deletions(-) diff --git a/code/core/src/csf-tools/CsfFile.ts b/code/core/src/csf-tools/CsfFile.ts index 3d0b310a21b3..233780f7c086 100644 --- a/code/core/src/csf-tools/CsfFile.ts +++ b/code/core/src/csf-tools/CsfFile.ts @@ -478,26 +478,20 @@ export class CsfFile { // export default meta; const variableName = (node.declaration as t.Identifier).name; self._metaVariableName = variableName; - const isVariableDeclarator = (declaration: t.VariableDeclarator) => + const isMetaVariable = (declaration: t.VariableDeclarator) => t.isIdentifier(declaration.id) && declaration.id.name === variableName; - self._metaStatement = self._ast.program.body.find( - (topLevelNode) => - t.isVariableDeclaration(topLevelNode) && - topLevelNode.declarations.find(isVariableDeclarator) - ); + self._metaStatementPath = self._file.path + .get('body') + .find( + (path) => + path.isVariableDeclaration() && path.node.declarations.some(isMetaVariable) + ); - self._metaStatementPath = - self._file.path - .get('body') - .find( - (topLevelPath) => - topLevelPath.isVariableDeclaration() && - topLevelPath.node.declarations.some(isVariableDeclarator) - ) ?? undefined; + self._metaStatement = self._metaStatementPath?.node; decl = ((self?._metaStatement as t.VariableDeclaration)?.declarations || []).find( - isVariableDeclarator + isMetaVariable )?.init; } else { self._metaStatement = node; From 0c1d131ef3920977a0c7030cec185359df2589cc Mon Sep 17 00:00:00 2001 From: Kasper Peulen Date: Tue, 28 Oct 2025 12:43:52 +0100 Subject: [PATCH 03/15] Handle top level function exports --- code/core/src/csf-tools/CsfFile.ts | 26 ++-- .../generateCodeSnippet.test.tsx | 28 +++- .../componentManifest/generateCodeSnippet.ts | 130 +++++++++--------- .../react/src/componentManifest/generator.ts | 5 +- code/renderers/react/src/enrichCsf.ts | 24 +--- 5 files changed, 111 insertions(+), 102 deletions(-) diff --git a/code/core/src/csf-tools/CsfFile.ts b/code/core/src/csf-tools/CsfFile.ts index 233780f7c086..1f2d39e031da 100644 --- a/code/core/src/csf-tools/CsfFile.ts +++ b/code/core/src/csf-tools/CsfFile.ts @@ -297,6 +297,9 @@ export class CsfFile { _storyExports: Record = {}; + _storyDeclarationPath: Record> = + {}; + _storyPaths: Record> = {}; _metaStatement: t.Statement | undefined; @@ -536,23 +539,28 @@ export class CsfFile { ExportNamedDeclaration: { enter(path) { const { node, parent } = path; + const declaration = path.get('declaration'); let declarations; - if (t.isVariableDeclaration(node.declaration)) { - declarations = node.declaration.declarations.filter((d) => t.isVariableDeclarator(d)); - } else if (t.isFunctionDeclaration(node.declaration)) { - declarations = [node.declaration]; + if (declaration.isVariableDeclaration()) { + declarations = declaration.get('declarations').filter((d) => d.isVariableDeclarator()); + } else if (declaration.isFunctionDeclaration()) { + declarations = [declaration]; } if (declarations) { // export const X = ...; - declarations.forEach((decl: t.VariableDeclarator | t.FunctionDeclaration) => { - if (t.isIdentifier(decl.id)) { + declarations.forEach((declPath) => { + const decl = declPath.node; + const id = declPath.node.id; + + if (t.isIdentifier(id)) { let storyIsFactory = false; - const { name: exportName } = decl.id; - if (exportName === '__namedExportsOrder' && t.isVariableDeclarator(decl)) { - self._namedExportsOrder = parseExportsOrder(decl.init as t.Expression); + const { name: exportName } = id; + if (exportName === '__namedExportsOrder' && declPath.isVariableDeclarator()) { + self._namedExportsOrder = parseExportsOrder(declPath.node.init as t.Expression); return; } self._storyExports[exportName] = decl; + self._storyDeclarationPath[exportName] = declPath; self._storyPaths[exportName] = path; self._storyStatements[exportName] = node; let name = storyNameFromExport(exportName); diff --git a/code/renderers/react/src/componentManifest/generateCodeSnippet.test.tsx b/code/renderers/react/src/componentManifest/generateCodeSnippet.test.tsx index 88babccebda2..dd7431e0edf4 100644 --- a/code/renderers/react/src/componentManifest/generateCodeSnippet.test.tsx +++ b/code/renderers/react/src/componentManifest/generateCodeSnippet.test.tsx @@ -1,7 +1,6 @@ import { expect, test } from 'vitest'; import { recast } from 'storybook/internal/babel'; -import type { NodePath } from 'storybook/internal/babel'; import { types as t } from 'storybook/internal/babel'; import { loadCsf } from 'storybook/internal/csf-tools'; @@ -13,10 +12,8 @@ function generateExample(code: string) { const csf = loadCsf(code, { makeTitle: (userTitle?: string) => userTitle ?? 'title' }).parse(); const component = csf._meta?.component ?? 'Unknown'; - const snippets = Object.values(csf._storyPaths) - .map((path: NodePath) => - getCodeSnippet(path, csf._metaNode ?? null, component) - ) + const snippets = Object.entries(csf._storyDeclarationPath) + .map(([name, path]) => getCodeSnippet(path, name, csf._metaNode ?? null, component)) .filter(Boolean); return recast.print(t.program(snippets)).code; @@ -516,3 +513,24 @@ test('top level args injection and spreading in different places', async () => { ;" `); }); + +test('allow top level export functions', async () => { + const input = withCSF3(dedent` + export function Usage(args) { + return ( +
+ +
+ ); + } + `); + expect(generateExample(input)).toMatchInlineSnapshot(` + "function Usage(args) { + return ( +
+ +
+ ); + }" + `); +}); diff --git a/code/renderers/react/src/componentManifest/generateCodeSnippet.ts b/code/renderers/react/src/componentManifest/generateCodeSnippet.ts index c0256d1c5de5..6be8d72a0a8f 100644 --- a/code/renderers/react/src/componentManifest/generateCodeSnippet.ts +++ b/code/renderers/react/src/componentManifest/generateCodeSnippet.ts @@ -16,39 +16,34 @@ function buildInvalidSpread(entries: Array<[string, t.Node]>): t.JSXSpreadAttrib } export function getCodeSnippet( - storyExportPath: NodePath, + storyDeclaration: NodePath, + storyName: string, metaObj: t.ObjectExpression | null | undefined, componentName?: string -): t.VariableDeclaration { - const declaration = storyExportPath.get('declaration'); - invariant( - declaration.isVariableDeclaration(), - () => storyExportPath.buildCodeFrameError('Expected story to be a variable declaration').message - ); - - const declarations = declaration.get('declarations'); - invariant( - declarations.length === 1, - storyExportPath.buildCodeFrameError('Expected one story declaration').message - ); - - const declarator = declarations[0]; - const init = declarator.get('init'); - invariant( - init.isExpression(), - () => declarator.buildCodeFrameError('Expected story initializer to be an expression').message - ); - - const storyId = declarator.get('id'); - invariant( - storyId.isIdentifier(), - () => declaration.buildCodeFrameError('Expected story to have a name').message - ); +): t.VariableDeclaration | t.FunctionDeclaration { + let storyPath: NodePath; + + if (storyDeclaration.isFunctionDeclaration()) { + storyPath = storyDeclaration; + } else if (storyDeclaration.isVariableDeclarator()) { + const init = storyDeclaration.get('init'); + invariant( + init.isExpression(), + () => + storyDeclaration.buildCodeFrameError('Expected story initializer to be an expression') + .message + ); + storyPath = init; + } else { + throw storyDeclaration.buildCodeFrameError( + 'Expected story to be a function or variable declaration' + ); + } - let normalizedInit: NodePath = init; + let normalizedPath: NodePath = storyPath; - if (init.isCallExpression()) { - const callee = init.get('callee'); + if (storyPath.isCallExpression()) { + const callee = storyPath.get('callee'); // Handle Template.bind({}) pattern by resolving the identifier's initialization if (callee.isMemberExpression()) { const obj = callee.get('object'); @@ -58,50 +53,54 @@ export function getCodeSnippet( (t.isStringLiteral((prop as any).node) && ((prop as any).node as t.StringLiteral).value === 'bind'); if (obj.isIdentifier() && isBind) { - const resolved = resolveBindIdentifierInit(storyExportPath, obj); + const resolved = resolveBindIdentifierInit(storyDeclaration, obj); if (resolved) { - normalizedInit = resolved; + normalizedPath = resolved; } } } // Fallback: treat call expression as story factory and use first argument - if (init === normalizedInit) { - const args = init.get('arguments'); + if (storyPath === normalizedPath) { + const args = storyPath.get('arguments'); invariant( args.length === 1, - () => init.buildCodeFrameError('Could not evaluate story expression').message + () => storyPath.buildCodeFrameError('Could not evaluate story expression').message ); const storyArgument = args[0]; invariant( storyArgument.isExpression(), - () => init.buildCodeFrameError('Could not evaluate story expression').message + () => storyPath.buildCodeFrameError('Could not evaluate story expression').message ); - normalizedInit = storyArgument; + normalizedPath = storyArgument; } } - normalizedInit = normalizedInit.isTSSatisfiesExpression() - ? normalizedInit.get('expression') - : normalizedInit.isTSAsExpression() - ? normalizedInit.get('expression') - : normalizedInit; + normalizedPath = normalizedPath.isTSSatisfiesExpression() + ? normalizedPath.get('expression') + : normalizedPath.isTSAsExpression() + ? normalizedPath.get('expression') + : normalizedPath; // If the story is already a function, try to inline args like in render() when using `{...args}` + let storyFn: + | NodePath + | undefined; - let story: NodePath; - if (normalizedInit.isArrowFunctionExpression() || normalizedInit.isFunctionExpression()) { - story = normalizedInit; - } else if (normalizedInit.isObjectExpression()) { - story = normalizedInit; - } else { - throw normalizedInit.buildCodeFrameError( + if ( + normalizedPath.isArrowFunctionExpression() || + normalizedPath.isFunctionExpression() || + normalizedPath.isFunctionDeclaration() + ) { + storyFn = normalizedPath; + } else if (!normalizedPath.isObjectExpression()) { + throw normalizedPath.buildCodeFrameError( 'Expected story to be csf factory, function or an object expression' ); } - const storyProperties = story?.isObjectExpression() - ? story.get('properties').filter((p) => p.isObjectProperty()) + const storyProperties = normalizedPath?.isObjectExpression() + ? normalizedPath.get('properties').filter((p) => p.isObjectProperty()) : // Find CSF2 properties []; @@ -114,9 +113,9 @@ export function getCodeSnippet( value.isArrowFunctionExpression() || value.isFunctionExpression() ); - const storyFn = - renderPath ?? - (story.isArrowFunctionExpression() ? story : story.isFunctionExpression() ? story : undefined); + if (renderPath) { + storyFn = renderPath; + } // Collect args: meta.args and story.args as Record const metaArgs = metaArgsRecord(metaObj ?? null); @@ -215,7 +214,7 @@ export function getCodeSnippet( const newFn = t.arrowFunctionExpression([], newBody, fn.async); return t.variableDeclaration('const', [ - t.variableDeclarator(t.identifier(storyId.node.name), newFn), + t.variableDeclarator(t.identifier(storyName), newFn), ]); } @@ -226,7 +225,7 @@ export function getCodeSnippet( const inlined = inlineArgsInJsx(deepSpread.node as any, merged); const newFn = t.arrowFunctionExpression([], inlined.node as any, fn.async); return t.variableDeclaration('const', [ - t.variableDeclarator(t.identifier(storyId.node.name), newFn), + t.variableDeclarator(t.identifier(storyName), newFn), ]); } @@ -235,16 +234,19 @@ export function getCodeSnippet( if (changed) { const newFn = t.arrowFunctionExpression([], transformedBody, fn.async); return t.variableDeclaration('const', [ - t.variableDeclarator(t.identifier(storyId.node.name), newFn), + t.variableDeclarator(t.identifier(storyName), newFn), ]); } } // Fallback: keep the function as-is - const expr = storyFn.node; // This is already a t.Expression - return t.variableDeclaration('const', [ - t.variableDeclarator(t.identifier(storyId.node.name), expr), - ]); + if (t.isFunctionDeclaration(storyFn.node)) { + return storyFn.node; + } else { + return t.variableDeclaration('const', [ + t.variableDeclarator(t.identifier(storyName), storyFn.node), + ]); + } } // Build spread for invalid-only props, if any @@ -269,9 +271,7 @@ export function getCodeSnippet( ) ); - return t.variableDeclaration('const', [ - t.variableDeclarator(t.identifier(storyId.node.name), arrow), - ]); + return t.variableDeclaration('const', [t.variableDeclarator(t.identifier(storyName), arrow)]); } const keyOf = (p: t.ObjectProperty): string | null => @@ -599,10 +599,10 @@ function transformArgsSpreadsInJsx( // Resolve the initializer path for an identifier used in a `.bind(...)` call function resolveBindIdentifierInit( - storyExportPath: NodePath, + storyPath: NodePath, identifier: NodePath ): NodePath | null { - const programPath = storyExportPath.findParent((p) => p.isProgram()); + const programPath = storyPath.findParent((p) => p.isProgram()); if (!programPath) { return null; diff --git a/code/renderers/react/src/componentManifest/generator.ts b/code/renderers/react/src/componentManifest/generator.ts index adf513820ae8..033c17171034 100644 --- a/code/renderers/react/src/componentManifest/generator.ts +++ b/code/renderers/react/src/componentManifest/generator.ts @@ -56,12 +56,13 @@ export const componentManifestGenerator = async () => { } const name = componentName; - const examples = Object.entries(csf._storyPaths) + const examples = Object.entries(csf._storyDeclarationPath) .map(([storyName, path]) => { try { return { name: storyName, - snippet: recast.print(getCodeSnippet(path, csf._metaNode ?? null, name)).code, + snippet: recast.print(getCodeSnippet(path, storyName, csf._metaNode ?? null, name)) + .code, }; } catch (e) { invariant(e instanceof Error); diff --git a/code/renderers/react/src/enrichCsf.ts b/code/renderers/react/src/enrichCsf.ts index 30b98b29c016..a7509ca6648e 100644 --- a/code/renderers/react/src/enrichCsf.ts +++ b/code/renderers/react/src/enrichCsf.ts @@ -13,14 +13,14 @@ export const enrichCsf: PresetPropertyFn<'experimental_enrichCsf'> = async (inpu return; } return async (csf: CsfFile, csfSource: CsfFile) => { - const promises = Object.entries(csf._storyPaths).map(async ([key, storyExport]) => { + const promises = Object.entries(csf._storyDeclarationPath).map(async ([key, storyExport]) => { if (!csfSource._meta?.component) { return; } const { format } = await getPrettier(); let node; try { - node = getCodeSnippet(storyExport, csfSource._metaNode, csfSource._meta?.component); + node = getCodeSnippet(storyExport, key, csfSource._metaNode, csfSource._meta?.component); } catch (e) { // don't bother the user if we can't generate a snippet return; @@ -36,27 +36,9 @@ export const enrichCsf: PresetPropertyFn<'experimental_enrichCsf'> = async (inpu return; } - const declaration = storyExport.get('declaration'); - if (!declaration.isVariableDeclaration()) { - return; - } - - const declarator = declaration.get('declarations')[0]; - const init = declarator.get('init') as NodePath; - - if (!init.isExpression()) { - return; - } - - const isCsfFactory = - t.isCallExpression(init.node) && - t.isMemberExpression(init.node.callee) && - t.isIdentifier(init.node.callee.object) && - init.node.callee.object.name === 'meta'; - // e.g. Story.input.parameters const originalParameters = t.memberExpression( - isCsfFactory + csf._metaIsFactory ? t.memberExpression(t.identifier(key), t.identifier('input')) : t.identifier(key), t.identifier('parameters') From 9a0e5572031851e8e0cf779780fdc80e5a6b16dc Mon Sep 17 00:00:00 2001 From: Kasper Peulen Date: Tue, 28 Oct 2025 14:03:31 +0100 Subject: [PATCH 04/15] Improve CSF1 parsing a lot --- .gitignore | 2 + .../generateCodeSnippet.test.tsx | 22 ++- .../componentManifest/generateCodeSnippet.ts | 181 ++++++++---------- 3 files changed, 96 insertions(+), 109 deletions(-) diff --git a/.gitignore b/.gitignore index d9481a41fbb1..cf774cdfcaab 100644 --- a/.gitignore +++ b/.gitignore @@ -65,3 +65,5 @@ code/core/report node_modules/.svelte2tsx-language-server-files *storybook.log + +.junie \ No newline at end of file diff --git a/code/renderers/react/src/componentManifest/generateCodeSnippet.test.tsx b/code/renderers/react/src/componentManifest/generateCodeSnippet.test.tsx index dd7431e0edf4..7760c55085aa 100644 --- a/code/renderers/react/src/componentManifest/generateCodeSnippet.test.tsx +++ b/code/renderers/react/src/componentManifest/generateCodeSnippet.test.tsx @@ -252,7 +252,11 @@ test('CustomRenderBlockBody only', async () => { };` ); expect(generateExample(input)).toMatchInlineSnapshot( - `"const CustomRenderBlockBody = (args) => { return };"` + ` + "const CustomRenderBlockBody = () => { + return ; + };" + ` ); }); @@ -508,8 +512,8 @@ test('top level args injection and spreading in different places', async () => { `); expect(generateExample(input)).toMatchInlineSnapshot(` "const MultipleSpreads = () =>
- +
;" `); }); @@ -525,12 +529,12 @@ test('allow top level export functions', async () => { } `); expect(generateExample(input)).toMatchInlineSnapshot(` - "function Usage(args) { - return ( -
- -
- ); + "function Usage() { + return ( +
+ +
+ ); }" `); }); diff --git a/code/renderers/react/src/componentManifest/generateCodeSnippet.ts b/code/renderers/react/src/componentManifest/generateCodeSnippet.ts index 6be8d72a0a8f..43262d3eb145 100644 --- a/code/renderers/react/src/componentManifest/generateCodeSnippet.ts +++ b/code/renderers/react/src/componentManifest/generateCodeSnippet.ts @@ -140,112 +140,70 @@ export function getCodeSnippet( if (storyFn) { const fn = storyFn.node; - // Only handle arrow function with direct JSX expression body for now + // Handle arrow function returning JSX directly: () => ;"` + ); +}); + test('CustomRenderWithNoArgs only', async () => { const input = withCSF3( `export const CustomRenderWithNoArgs = { diff --git a/code/renderers/react/src/componentManifest/generateCodeSnippet.ts b/code/renderers/react/src/componentManifest/generateCodeSnippet.ts index c196b8555a07..68b5497e67b8 100644 --- a/code/renderers/react/src/componentManifest/generateCodeSnippet.ts +++ b/code/renderers/react/src/componentManifest/generateCodeSnippet.ts @@ -3,19 +3,6 @@ import { type CsfFile } from 'storybook/internal/csf-tools'; import { invariant } from './utils'; -function buildInvalidSpread(entries: Array<[string, t.Node]>): t.JSXSpreadAttribute | null { - if (entries.length === 0) { - return null; - } - const objectProps = entries.map(([k, v]) => - t.objectProperty( - t.stringLiteral(k), - t.isExpression(v) ? v : (t.identifier('undefined') as t.Expression) - ) - ); - return t.jsxSpreadAttribute(t.objectExpression(objectProps)); -} - export function getCodeSnippet( csf: CsfFile, storyName: string @@ -28,8 +15,9 @@ export function getCodeSnippet( const message = 'Expected story to be a function or variable declaration'; throw csf._storyPaths[storyName]?.buildCodeFrameError(message) ?? message; } - let storyPath: NodePath; + // Normalize to NodePath + let storyPath: NodePath; if (storyDeclaration.isFunctionDeclaration()) { storyPath = storyDeclaration; } else if (storyDeclaration.isVariableDeclarator()) { @@ -49,46 +37,45 @@ export function getCodeSnippet( let normalizedPath: NodePath = storyPath; + // Handle Template.bind(...) or factory(story) if (storyPath.isCallExpression()) { const callee = storyPath.get('callee'); - // Handle Template.bind({}) pattern by resolving the identifier's initialization if (callee.isMemberExpression()) { const obj = callee.get('object'); const prop = callee.get('property'); const isBind = (prop.isIdentifier() && prop.node.name === 'bind') || (t.isStringLiteral(prop.node) && prop.node.value === 'bind'); + if (obj.isIdentifier() && isBind) { const resolved = resolveBindIdentifierInit(storyDeclaration, obj); - if (resolved) { - normalizedPath = resolved; - } + if (resolved) normalizedPath = resolved; } } - // Fallback: treat call expression as story factory and use first argument if (storyPath === normalizedPath) { const args = storyPath.get('arguments'); invariant( args.length === 1, () => storyPath.buildCodeFrameError('Could not evaluate story expression').message ); - const storyArgument = args[0]; + const storyArg = args[0]; invariant( - storyArgument.isExpression(), + storyArg.isExpression(), () => storyPath.buildCodeFrameError('Could not evaluate story expression').message ); - normalizedPath = storyArgument; + normalizedPath = storyArg; } } + // Strip TS `satisfies` / `as` normalizedPath = normalizedPath.isTSSatisfiesExpression() ? normalizedPath.get('expression') : normalizedPath.isTSAsExpression() ? normalizedPath.get('expression') : normalizedPath; - // If the story is already a function, try to inline args like in render() when using `{...args}` + // Find a function (explicit story fn or render()) let storyFn: | NodePath | undefined; @@ -105,129 +92,107 @@ export function getCodeSnippet( ); } - const storyProperties = normalizedPath?.isObjectExpression() + const storyProps = normalizedPath.isObjectExpression() ? normalizedPath.get('properties').filter((p) => p.isObjectProperty()) - : // Find CSF2 properties - []; + : []; + + const metaPath = pathForNode(csf._file.path, metaObj); + const metaProps = metaPath?.isObjectExpression() + ? metaPath.get('properties').filter((p) => p.isObjectProperty()) + : []; + + const getRenderPath = (object: NodePath[]) => + object + .filter((p) => keyOf(p.node) === 'render') + .map((p) => p.get('value')) + .find( + (v): v is NodePath => + v.isArrowFunctionExpression() || v.isFunctionExpression() + ); - // Prefer an explicit render() when it is a function (arrow/function) - const renderPath = storyProperties - .filter((p) => keyOf(p.node) === 'render') - .map((p) => p.get('value')) - .find( - (value): value is NodePath => - value.isArrowFunctionExpression() || value.isFunctionExpression() - ); + const renderPath = getRenderPath(storyProps); + const metaRenderPath = getRenderPath(metaProps); - if (renderPath) { - storyFn = renderPath; - } + storyFn ??= renderPath ?? metaRenderPath; - // Collect args: meta.args and story.args as Record + // Collect args const metaArgs = metaArgsRecord(metaObj ?? null); - const storyArgsPath = storyProperties + const storyArgsPath = storyProps .filter((p) => keyOf(p.node) === 'args') .map((p) => p.get('value')) - .find((value) => value.isObjectExpression()); - + .find((v) => v.isObjectExpression()); const storyArgs = argsRecordFromObjectPath(storyArgsPath); - - // Merge (story overrides meta) const merged: Record = { ...metaArgs, ...storyArgs }; + // For no-function fallback const entries = Object.entries(merged).filter(([k]) => k !== 'children'); const validEntries = entries.filter(([k, v]) => isValidJsxAttrName(k) && v != null); const invalidEntries = entries.filter(([k, v]) => !isValidJsxAttrName(k) && v != null); + const injectedAttrs = validEntries.map(([k, v]) => toAttr(k, v)).filter((a) => a != null); - const injectedAttrs = validEntries - .map(([k, v]) => toAttr(k, v)) - .filter((a): a is t.JSXAttribute => Boolean(a)); - + // If we have a function, transform returned JSX if (storyFn) { const fn = storyFn.node; - // Handle arrow function returning JSX directly: () => ;", }, ], "id": "example-button", + "import": undefined, "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", + }, + }, + }, + }, + "summary": undefined, } `); }); diff --git a/code/renderers/react/src/componentManifest/generator.ts b/code/renderers/react/src/componentManifest/generator.ts index 6d55494a9a94..d0ad010097ca 100644 --- a/code/renderers/react/src/componentManifest/generator.ts +++ b/code/renderers/react/src/componentManifest/generator.ts @@ -20,6 +20,7 @@ interface ReactComponentManifest extends ComponentManifest { export const componentManifestGenerator = async () => { return (async (storyIndexGenerator) => { const index = await storyIndexGenerator.getIndex(); + const groupByComponentId = groupBy( Object.values(index.entries) .filter((entry) => entry.type === 'story') @@ -33,18 +34,16 @@ export const componentManifestGenerator = async () => { 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 componentName = csf._meta?.component; + const name = csf._meta?.component ?? entry.title.split('/').at(-1)!; const id = entry.id.split('--')[0]; const importPath = entry.importPath; - const name = componentName; - const examples = Object.keys(csf._stories) .map((storyName) => { try { return { name: storyName, - snippet: recast.print(getCodeSnippet(csf, storyName)).code, + snippet: recast.print(getCodeSnippet(csf, storyName, name)).code, }; } catch (e) { invariant(e instanceof Error); @@ -60,22 +59,12 @@ export const componentManifestGenerator = async () => { const base = { id, + name, path: importPath, examples, jsDocTags: {}, } satisfies Partial; - if (!componentName) { - const message = - 'Specify meta.component for reactDocgen data to be included in the manifest.'; - return { - ...base, - error: { - message: csf._metaStatementPath?.buildCodeFrameError(message).message ?? message, - }, - }; - } - if (!entry.componentPath) { const message = `No component file found for the "${name}" component.`; return { @@ -118,7 +107,7 @@ export const componentManifestGenerator = async () => { return { ...base, - name: componentName, + name, description: manifestDescription?.trim(), summary: tags.summary?.[0], import: tags.import?.[0], diff --git a/code/renderers/react/src/enrichCsf.ts b/code/renderers/react/src/enrichCsf.ts index cb564f9d2b6b..a804ce7e2479 100644 --- a/code/renderers/react/src/enrichCsf.ts +++ b/code/renderers/react/src/enrichCsf.ts @@ -19,20 +19,31 @@ export const enrichCsf: PresetPropertyFn<'experimental_enrichCsf'> = async (inpu } const { format } = await getPrettier(); let node; + let snippet; try { - node = getCodeSnippet(csfSource, key); + node = getCodeSnippet(csfSource, key, csfSource._meta?.component); } catch (e) { - // don't bother the user if we can't generate a snippet - return; + if (!(e instanceof Error)) { + return; + } + snippet = e.message; } - let snippet; try { // TODO read the user config - snippet = await format(recast.print(node).code, { - filepath: join(process.cwd(), 'component.tsx'), - }); + if (!snippet && node) { + snippet = await format(recast.print(node).code, { + filepath: join(process.cwd(), 'component.tsx'), + }); + } } catch (e) { + if (!(e instanceof Error)) { + return; + } + snippet = e.message; + } + + if (!snippet) { return; } From 8e4219c7dd26e87c61c024b1b1a09117f0c6a4da Mon Sep 17 00:00:00 2001 From: Kasper Peulen Date: Wed, 29 Oct 2025 13:23:27 +0100 Subject: [PATCH 12/15] Adress review --- code/renderers/react/src/componentManifest/utils.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/code/renderers/react/src/componentManifest/utils.ts b/code/renderers/react/src/componentManifest/utils.ts index f92c97ea6555..bd61797f98ad 100644 --- a/code/renderers/react/src/componentManifest/utils.ts +++ b/code/renderers/react/src/componentManifest/utils.ts @@ -11,7 +11,11 @@ export const groupBy = ( }, {}); }; -export function invariant(condition: any, message?: string | (() => string)): asserts condition { +// This invariant allows for lazy evaluation of the message, which we need to avoid excessive computation. +export function invariant( + condition: unknown, + message?: string | (() => string) +): asserts condition { if (condition) { return; } From 97c90e87a9a3d8c669b0456a2cfaa8cae376f161 Mon Sep 17 00:00:00 2001 From: Kasper Peulen Date: Wed, 29 Oct 2025 13:27:52 +0100 Subject: [PATCH 13/15] Adress review --- code/core/src/types/modules/core-common.ts | 2 +- code/renderers/react/src/componentManifest/generator.test.ts | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/code/core/src/types/modules/core-common.ts b/code/core/src/types/modules/core-common.ts index 1bc1c0835fd4..36063b164e73 100644 --- a/code/core/src/types/modules/core-common.ts +++ b/code/core/src/types/modules/core-common.ts @@ -348,7 +348,7 @@ export type TagsOptions = Record>; export interface ComponentManifest { id: string; path: string; - name?: string; + name: string; description?: string; import?: string; summary?: string; diff --git a/code/renderers/react/src/componentManifest/generator.test.ts b/code/renderers/react/src/componentManifest/generator.test.ts index 7d16365d209f..6196a2637956 100644 --- a/code/renderers/react/src/componentManifest/generator.test.ts +++ b/code/renderers/react/src/componentManifest/generator.test.ts @@ -490,7 +490,7 @@ test('fall back to index title when no component name', async () => { args: { onClick: fn() }, }; - export const Primary = {}; + export const Primary = () => ;", + "snippet": "const Primary = () => ;"` + ); +}); + test('Replace children', () => { const input = withCSF3(dedent` export const WithEmoji: Story = { diff --git a/code/renderers/react/src/componentManifest/generateCodeSnippet.ts b/code/renderers/react/src/componentManifest/generateCodeSnippet.ts index 91bc78aaae23..583765a9b8d1 100644 --- a/code/renderers/react/src/componentManifest/generateCodeSnippet.ts +++ b/code/renderers/react/src/componentManifest/generateCodeSnippet.ts @@ -58,16 +58,21 @@ export function getCodeSnippet( if (storyPath === normalizedPath) { const args = storyPath.get('arguments'); - invariant( - args.length === 1, - () => storyPath.buildCodeFrameError('Could not evaluate story expression').message - ); - const storyArg = args[0]; - invariant( - storyArg.isExpression(), - () => storyPath.buildCodeFrameError('Could not evaluate story expression').message - ); - normalizedPath = storyArg; + // Allow meta.story() with zero args (CSF4) + if (args.length === 0) { + // Leave normalizedPath as the CallExpression; we'll treat it as an empty story config later + } else { + invariant( + args.length === 1, + () => storyPath.buildCodeFrameError('Could not evaluate story expression').message + ); + const storyArg = args[0]; + invariant( + storyArg.isExpression(), + () => storyPath.buildCodeFrameError('Could not evaluate story expression').message + ); + normalizedPath = storyArg; + } } } @@ -90,9 +95,18 @@ export function getCodeSnippet( ) { storyFn = normalizedPath; } else if (!normalizedPath.isObjectExpression()) { - throw normalizedPath.buildCodeFrameError( - 'Expected story to be csf factory, function or an object expression' - ); + // Allow CSF4 meta.story() without arguments, which is equivalent to an empty object story config + if ( + normalizedPath.isCallExpression() && + Array.isArray(normalizedPath.node.arguments) && + normalizedPath.node.arguments.length === 0 + ) { + // No-op: treat as an object story with no properties + } else { + throw normalizedPath.buildCodeFrameError( + 'Expected story to be csf factory, function or an object expression' + ); + } } const storyProps = normalizedPath.isObjectExpression()