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/core/src/csf-tools/CsfFile.ts b/code/core/src/csf-tools/CsfFile.ts index 33b9c0c013c5..1f2d39e031da 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; @@ -297,10 +297,15 @@ export class CsfFile { _storyExports: Record = {}; + _storyDeclarationPath: Record> = + {}; + _storyPaths: Record> = {}; _metaStatement: t.Statement | undefined; + _metaStatementPath: NodePath | undefined; + _metaNode: t.ObjectExpression | undefined; _metaPath: NodePath | undefined; @@ -476,19 +481,24 @@ 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._metaStatement = self._metaStatementPath?.node; + decl = ((self?._metaStatement as t.VariableDeclaration)?.declarations || []).find( - isVariableDeclarator + isMetaVariable )?.init; } else { self._metaStatement = node; + self._metaStatementPath = path; decl = node.declaration; } @@ -529,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); @@ -1036,7 +1051,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/csf-tools/enrichCsf.test.ts b/code/core/src/csf-tools/enrichCsf.test.ts index fe766ff30588..f4de55edc8df 100644 --- a/code/core/src/csf-tools/enrichCsf.test.ts +++ b/code/core/src/csf-tools/enrichCsf.test.ts @@ -14,11 +14,9 @@ expect.addSnapshotSerializer({ const enrich = async (code: string, originalCode: string, options?: EnrichCsfOptions) => { // we don't actually care about the title - const csf = loadCsf(code, { - makeTitle: (userTitle) => userTitle || 'default', - }).parse(); + const csf = loadCsf(code, { makeTitle: (userTitle) => userTitle ?? 'Unknown' }).parse(); const csfSource = loadCsf(originalCode, { - makeTitle: (userTitle) => userTitle || 'default', + makeTitle: (userTitle) => userTitle ?? 'Unknown', }).parse(); await enrichCsf(csf, csfSource, options); return formatCsf(csf); diff --git a/code/core/src/types/modules/core-common.ts b/code/core/src/types/modules/core-common.ts index c24da7a5d473..36063b164e73 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; + 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..582343a7c0f5 100644 --- a/code/renderers/react/src/componentManifest/generateCodeSnippet.test.tsx +++ b/code/renderers/react/src/componentManifest/generateCodeSnippet.test.tsx @@ -1,8 +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 { recast, types as t } from 'storybook/internal/babel'; import { loadCsf } from 'storybook/internal/csf-tools'; import { dedent } from 'ts-dedent'; @@ -11,12 +9,9 @@ import { getCodeSnippet } from './generateCodeSnippet'; 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.keys(csf._storyExports) + .map((name) => getCodeSnippet(csf, name, csf._meta?.component ?? 'ComponentTitle')) .filter(Boolean); return recast.print(t.program(snippets)).code; @@ -81,8 +76,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 | ] + ` ); }); @@ -95,6 +97,15 @@ test('Default- CSF4', () => { ); }); +test('StoryWithoutArguments - CSF4', () => { + const input = withCSF4(` + export const StoryWithoutArguments = meta.story(); + `); + expect(generateExample(input)).toMatchInlineSnapshot( + `"const StoryWithoutArguments = () => ;"` + ); +}); + test('Replace children', () => { const input = withCSF3(dedent` export const WithEmoji: Story = { @@ -205,6 +216,28 @@ test('CustomRenderWithOverideArgs only', async () => { ); }); +test('Meta level render', async () => { + const input = dedent` + import type { Meta } from '@storybook/react'; + import { Button } from '@design-system/button'; + + const meta: Meta = { + render: (args) => ;"` + ); +}); + test('CustomRenderWithNoArgs only', async () => { const input = withCSF3( `export const CustomRenderWithNoArgs = { @@ -248,7 +281,11 @@ test('CustomRenderBlockBody only', async () => { };` ); expect(generateExample(input)).toMatchInlineSnapshot( - `"const CustomRenderBlockBody = (args) => { return };"` + ` + "const CustomRenderBlockBody = () => { + return ; + };" + ` ); }); @@ -504,8 +541,29 @@ test('top level args injection and spreading in different places', async () => { `); expect(generateExample(input)).toMatchInlineSnapshot(` "const MultipleSpreads = () =>
- +
;" `); }); + +test('allow top level export functions', async () => { + const input = withCSF3(dedent` + export function Usage(args) { + return ( +
+ +
+ ); + } + `); + expect(generateExample(input)).toMatchInlineSnapshot(` + "function Usage() { + return ( +
+ +
+ ); + }" + `); +}); diff --git a/code/renderers/react/src/componentManifest/generateCodeSnippet.ts b/code/renderers/react/src/componentManifest/generateCodeSnippet.ts index 12fbd31a1a4c..583765a9b8d1 100644 --- a/code/renderers/react/src/componentManifest/generateCodeSnippet.ts +++ b/code/renderers/react/src/componentManifest/generateCodeSnippet.ts @@ -1,235 +1,215 @@ import { type NodePath, types as t } from 'storybook/internal/babel'; +import { type CsfFile } from 'storybook/internal/csf-tools'; -function invariant(condition: any, message?: string | (() => string)): asserts condition { - if (condition) { - return; - } - throw new Error(typeof message === 'function' ? message() : message); -} - -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)); -} +import { invariant } from './utils'; export function getCodeSnippet( - storyExportPath: NodePath, - metaObj: t.ObjectExpression | null | undefined, - componentName: string -): t.VariableDeclaration { - const declaration = storyExportPath.get('declaration') as NodePath; - invariant(declaration.isVariableDeclaration(), 'Expected variable declaration'); - - 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'); + csf: CsfFile, + storyName: string, + componentName?: string +): t.VariableDeclaration | t.FunctionDeclaration { + const storyDeclaration = csf._storyDeclarationPath[storyName]; + const metaObj = csf._metaNode; + + if (!storyDeclaration) { + const message = 'Expected story to be a function or variable declaration'; + throw csf._storyPaths[storyName]?.buildCodeFrameError(message) ?? message; + } - const storyId = declarator.get('id'); - invariant(storyId.isIdentifier(), 'Expected named const story export'); + // Normalize to NodePath + 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 story: NodePath | null = init; + let normalizedPath: NodePath = storyPath; - if (init.isCallExpression()) { - const callee = init.get('callee'); - // Handle Template.bind({}) pattern by resolving the identifier's initialization + // Handle Template.bind(...) or factory(story) + if (storyPath.isCallExpression()) { + const callee = storyPath.get('callee'); if (callee.isMemberExpression()) { const obj = callee.get('object'); const prop = callee.get('property'); const isBind = (prop.isIdentifier() && prop.node.name === 'bind') || - (t.isStringLiteral((prop as any).node) && - ((prop as any).node as t.StringLiteral).value === 'bind'); + (t.isStringLiteral(prop.node) && prop.node.value === 'bind'); + if (obj.isIdentifier() && isBind) { - const resolved = resolveBindIdentifierInit(storyExportPath, obj); + const resolved = resolveBindIdentifierInit(storyDeclaration, obj); + if (resolved) { - story = resolved; + normalizedPath = resolved; } } } - // Fallback: treat call expression as story factory and use first argument - if (story === init) { - const args = init.get('arguments'); + if (storyPath === normalizedPath) { + const args = storyPath.get('arguments'); + // Allow meta.story() with zero args (CSF4) if (args.length === 0) { - story = null; + // Leave normalizedPath as the CallExpression; we'll treat it as an empty story config later } else { - const storyArgument = args[0]; - invariant(storyArgument.isExpression()); - story = storyArgument; + 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; } } } - // If the story is already a function, try to inline args like in render() when using `{...args}` + // Strip TS `satisfies` / `as` + normalizedPath = normalizedPath.isTSSatisfiesExpression() + ? normalizedPath.get('expression') + : normalizedPath.isTSAsExpression() + ? normalizedPath.get('expression') + : normalizedPath; - // 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; + // Find a function (explicit story fn or render()) + let storyFn: + | NodePath + | undefined; - const storyProperties = storyObjPath?.isObjectExpression() - ? storyObjPath.get('properties').filter((p) => p.isObjectProperty()) + if ( + normalizedPath.isArrowFunctionExpression() || + normalizedPath.isFunctionExpression() || + normalizedPath.isFunctionDeclaration() + ) { + storyFn = normalizedPath; + } else if (!normalizedPath.isObjectExpression()) { + // 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() + ? normalizedPath.get('properties').filter((p) => p.isObjectProperty()) : []; - // 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()); + const metaPath = pathForNode(csf._file.path, metaObj); + const metaProps = metaPath?.isObjectExpression() + ? metaPath.get('properties').filter((p) => p.isObjectProperty()) + : []; - const storyFn = renderPath ?? story; + 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() + ); - // Collect args: meta.args and story.args as Record + const renderPath = getRenderPath(storyProps); + const metaRenderPath = getRenderPath(metaProps); + + storyFn ??= renderPath ?? metaRenderPath; + + // 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 (storyFn?.isArrowFunctionExpression() || storyFn?.isFunctionExpression()) { + // If we have a function, transform returned JSX + if (storyFn) { const fn = storyFn.node; - // Only handle arrow function with direct JSX expression body for now - if (t.isArrowFunctionExpression(fn) && t.isJSXElement(fn.body)) { - const body = fn.body; - const opening = body.openingElement; - const attrs = opening.attributes; - const firstSpreadIndex = attrs.findIndex( - (a) => t.isJSXSpreadAttribute(a) && t.isIdentifier(a.argument) && a.argument.name === 'args' - ); - if (firstSpreadIndex !== -1) { - // Build a list of non-args attributes and compute insertion index at the position of the first args spread - const nonArgsAttrs: (t.JSXAttribute | t.JSXSpreadAttribute)[] = []; - let insertionIndex = 0; - for (let i = 0; i < attrs.length; i++) { - const a = attrs[i]!; - const isArgsSpread = - t.isJSXSpreadAttribute(a) && t.isIdentifier(a.argument) && a.argument.name === 'args'; - if (isArgsSpread) { - if (i === firstSpreadIndex) { - insertionIndex = nonArgsAttrs.length; - } - continue; // drop all {...args} - } - nonArgsAttrs.push(a as any); - } - - // Determine names of explicitly set attributes (excluding any args spreads) - const existingAttrNames = new Set( - nonArgsAttrs - .filter((a) => t.isJSXAttribute(a) && t.isJSXIdentifier(a.name)) - .map((a) => (a as t.JSXAttribute).name.name) - ); - - // Filter out any injected attrs that would duplicate an existing explicit attribute - const filteredInjected = injectedAttrs.filter( - (a) => t.isJSXIdentifier(a.name) && !existingAttrNames.has(a.name.name) - ); - - // Build a spread containing only invalid-key props, if any, and also exclude keys already explicitly present - const invalidProps = invalidEntries.filter(([k]) => !existingAttrNames.has(k)); - const invalidSpread: t.JSXSpreadAttribute | null = buildInvalidSpread(invalidProps); - - // Handle children injection if the element currently has no children, using merged children (story overrides meta) - const mergedChildren = Object.prototype.hasOwnProperty.call(merged, 'children') - ? merged['children'] - : undefined; - const canInjectChildren = - !!mergedChildren && (body.children == null || body.children.length === 0); - - // Always transform when `{...args}` exists: remove spreads and empty params - const pieces = [...filteredInjected, ...(invalidSpread ? [invalidSpread] : [])]; - const newAttrs = [ - ...nonArgsAttrs.slice(0, insertionIndex), - ...pieces, - ...nonArgsAttrs.slice(insertionIndex), - ]; - - const willHaveChildren = canInjectChildren ? true : (body.children?.length ?? 0) > 0; - const shouldSelfClose = opening.selfClosing && !willHaveChildren; - - const finalOpening = t.jsxOpeningElement(opening.name, newAttrs, shouldSelfClose); - const finalClosing = shouldSelfClose - ? null - : (body.closingElement ?? t.jsxClosingElement(opening.name)); - const finalChildren = canInjectChildren ? toJsxChildren(mergedChildren) : body.children; - - let newBody = t.jsxElement(finalOpening, finalClosing, finalChildren, shouldSelfClose); - // After handling top-level {...args}, also inline any nested args.* usages and - // transform any nested {...args} spreads deeper in the tree. - const inlined = inlineArgsInJsx(newBody, merged); - const transformed = transformArgsSpreadsInJsx(inlined.node, merged); - newBody = transformed.node as t.JSXElement; - - const newFn = t.arrowFunctionExpression([], newBody, fn.async); + if (t.isArrowFunctionExpression(fn) && (t.isJSXElement(fn.body) || t.isJSXFragment(fn.body))) { + const spreadRes = transformArgsSpreadsInJsx(fn.body, merged); + const inlineRes = inlineArgsInJsx(spreadRes.node, merged); + if (spreadRes.changed || inlineRes.changed) { + const newFn = t.arrowFunctionExpression([], inlineRes.node, fn.async); return t.variableDeclaration('const', [ - t.variableDeclarator(t.identifier(storyId.node.name), newFn), + t.variableDeclarator(t.identifier(storyName), newFn), ]); } + } - // No {...args} at top level; try to remove any deeper {...args} spreads in the JSX tree - const deepSpread = transformArgsSpreadsInJsx(body, merged); - if (deepSpread.changed) { - // After transforming spreads, also inline any remaining args.* references across the tree - 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), - ]); - } + const stmts = t.isFunctionDeclaration(fn) + ? fn.body.body + : t.isArrowFunctionExpression(fn) && t.isBlockStatement(fn.body) + ? fn.body.body + : t.isFunctionExpression(fn) && t.isBlockStatement(fn.body) + ? fn.body.body + : undefined; + + if (stmts) { + let changed = false; + const newBody = stmts.map((stmt) => { + if ( + t.isReturnStatement(stmt) && + stmt.argument && + (t.isJSXElement(stmt.argument) || t.isJSXFragment(stmt.argument)) + ) { + const spreadRes = transformArgsSpreadsInJsx(stmt.argument, merged); + const inlineRes = inlineArgsInJsx(spreadRes.node, merged); + if (spreadRes.changed || inlineRes.changed) { + changed = true; + return t.returnStatement(inlineRes.node); + } + } + return stmt; + }); - // Still no spreads transformed; inline any usages of args.* in the entire JSX tree - const { node: transformedBody, changed } = inlineArgsInJsx(body, merged); if (changed) { - const newFn = t.arrowFunctionExpression([], transformedBody, fn.async); - return t.variableDeclaration('const', [ - t.variableDeclarator(t.identifier(storyId.node.name), newFn), - ]); + return t.isFunctionDeclaration(fn) + ? t.functionDeclaration(fn.id, [], t.blockStatement(newBody), fn.generator, fn.async) + : t.variableDeclaration('const', [ + t.variableDeclarator( + t.identifier(storyName), + t.arrowFunctionExpression([], t.blockStatement(newBody), fn.async) + ), + ]); } } - // 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), - ]); + return t.isFunctionDeclaration(fn) + ? fn + : t.variableDeclaration('const', [t.variableDeclarator(t.identifier(storyName), fn)]); } - // Build spread for invalid-only props, if any + // No function: synthesize `` + invariant(componentName, 'Could not generate snippet without component name.'); const invalidSpread = buildInvalidSpread(invalidEntries); - const name = t.jsxIdentifier(componentName); - - const openingElAttrs: Array = [ - ...injectedAttrs, - ...(invalidSpread ? [invalidSpread] : []), - ]; + const openingElAttrs = invalidSpread ? [...injectedAttrs, invalidSpread] : injectedAttrs; const arrow = t.arrowFunctionExpression( [], @@ -241,9 +221,18 @@ 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)]); +} + +/** Build a spread `{...{k: v}}` for props that aren't valid JSX attributes. */ +function buildInvalidSpread(entries: ReadonlyArray<[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')) + ); + return t.jsxSpreadAttribute(t.objectExpression(objectProps)); } const keyOf = (p: t.ObjectProperty): string | null => @@ -251,53 +240,41 @@ const keyOf = (p: t.ObjectProperty): string | null => const isValidJsxAttrName = (n: string) => /^[A-Za-z_][A-Za-z0-9_:-]*$/.test(n); -const argsRecordFromObjectPath = ( - objPath?: NodePath | null -): Record => { - if (!objPath) { - return {}; - } - - const props = objPath.get('properties') as NodePath< - t.ObjectMethod | t.ObjectProperty | t.SpreadElement - >[]; - - return Object.fromEntries( - props - .filter((p): p is NodePath => p.isObjectProperty()) - .map((p) => [keyOf(p.node), (p.get('value') as NodePath).node]) - .filter(([k]) => !!k) as Array<[string, t.Node]> - ); -}; +const argsRecordFromObjectPath = (objPath?: NodePath | null) => + objPath + ? Object.fromEntries( + objPath + .get('properties') + .filter((p) => p.isObjectProperty()) + .map((p) => [keyOf(p.node), p.get('value').node]) + .filter((e) => Boolean(e[0])) + ) + : {}; + +const argsRecordFromObjectNode = (obj?: t.ObjectExpression | null) => + obj + ? Object.fromEntries( + obj.properties + .filter((p): p is t.ObjectProperty => t.isObjectProperty(p)) + .map((p) => [keyOf(p), p.value]) + .filter((e) => Boolean(e[0])) + ) + : {}; -const argsRecordFromObjectNode = (objNode?: t.ObjectExpression | null): Record => { - if (!objNode) { +const metaArgsRecord = (meta?: t.ObjectExpression | null) => { + if (!meta) { return {}; } - return Object.fromEntries( - objNode.properties - .filter((prop) => t.isObjectProperty(prop)) - .flatMap((prop) => { - const key = keyOf(prop); - return key ? [[key, prop.value]] : []; - }) + const argsProp = meta.properties.find( + (p): p is t.ObjectProperty => t.isObjectProperty(p) && keyOf(p) === 'args' ); + return argsProp && t.isObjectExpression(argsProp.value) + ? argsRecordFromObjectNode(argsProp.value) + : {}; }; -const metaArgsRecord = (metaObj?: t.ObjectExpression | null): Record => { - if (!metaObj) { - return {}; - } - const argsProp = metaObj.properties - .filter((p) => t.isObjectProperty(p)) - .find((p) => keyOf(p) === 'args'); - - return t.isObjectExpression(argsProp?.value) ? argsRecordFromObjectNode(argsProp.value) : {}; -}; - -const toAttr = (key: string, value: t.Node): t.JSXAttribute | null => { +const toAttr = (key: string, value: t.Node) => { if (t.isBooleanLiteral(value)) { - // Keep falsy boolean attributes by rendering an explicit expression container return value.value ? t.jsxAttribute(t.jsxIdentifier(key), null) : t.jsxAttribute(t.jsxIdentifier(key), t.jsxExpressionContainer(value)); @@ -310,32 +287,22 @@ const toAttr = (key: string, value: t.Node): t.JSXAttribute | null => { if (t.isExpression(value)) { return t.jsxAttribute(t.jsxIdentifier(key), t.jsxExpressionContainer(value)); } - return null; // non-expression nodes are not valid as attribute values -}; - -const toJsxChildren = ( - node: t.Node | null | undefined -): Array => { - if (!node) { - return []; - } - - if (t.isStringLiteral(node)) { - return [t.jsxText(node.value)]; - } - - if (t.isJSXElement(node) || t.isJSXFragment(node)) { - return [node]; - } - - if (t.isExpression(node)) { - return [t.jsxExpressionContainer(node)]; - } - return []; // ignore non-expressions + return null; }; -// Detects {args.key} member usage -function getArgsMemberKey(expr: t.Node): string | null { +const toJsxChildren = (node: t.Node | null | undefined) => + !node + ? [] + : t.isStringLiteral(node) + ? [t.jsxText(node.value)] + : t.isJSXElement(node) || t.isJSXFragment(node) + ? [node] + : t.isExpression(node) + ? [t.jsxExpressionContainer(node)] + : []; + +/** Return `key` if expression is `args.key` (incl. optional chaining), else `null`. */ +function getArgsMemberKey(expr: t.Node) { if (t.isMemberExpression(expr) && t.isIdentifier(expr.object) && expr.object.name === 'args') { if (t.isIdentifier(expr.property) && !expr.computed) { return expr.property.name; @@ -345,9 +312,6 @@ function getArgsMemberKey(expr: t.Node): string | null { return expr.property.value; } } - // Optional chaining: args?.key - // In Babel types, this can still be a MemberExpression with optional: true or OptionalMemberExpression - // Handle both just in case if ( t.isOptionalMemberExpression?.(expr) && t.isIdentifier(expr.object) && @@ -366,14 +330,7 @@ function getArgsMemberKey(expr: t.Node): string | null { return null; } -function inlineAttrValueFromArg( - attrName: string, - argValue: t.Node -): t.JSXAttribute | null | undefined { - // Reuse toAttr, but keep the original attribute name - return toAttr(attrName, argValue); -} - +/** Inline `args.foo` -> actual literal/expression in attributes/children (recursively). */ function inlineArgsInJsx( node: t.JSXElement | t.JSXFragment, merged: Record @@ -382,83 +339,78 @@ function inlineArgsInJsx( if (t.isJSXElement(node)) { const opening = node.openingElement; - // Process attributes - const newAttrs: Array = []; - for (const a of opening.attributes) { - if (t.isJSXAttribute(a)) { - const attrName = t.isJSXIdentifier(a.name) ? a.name.name : null; - if (attrName && a.value && t.isJSXExpressionContainer(a.value)) { - const key = getArgsMemberKey(a.value.expression); - if (key && Object.prototype.hasOwnProperty.call(merged, key)) { - const repl = inlineAttrValueFromArg(attrName, merged[key]!); - changed = true; - if (repl) { - newAttrs.push(repl); - } - continue; - } - } - newAttrs.push(a); - } else { - // Keep spreads as-is (they might not be args) - newAttrs.push(a); + + const newAttrs = opening.attributes.flatMap((a) => { + if (!t.isJSXAttribute(a)) { + return [a]; + } + const name = t.isJSXIdentifier(a.name) ? a.name.name : null; + + if (!(name && a.value && t.isJSXExpressionContainer(a.value))) { + return [a]; + } + + const key = getArgsMemberKey(a.value.expression); + + if (!(key && key in merged)) { + return [a]; } - } - // Process children - const newChildren: (t.JSXText | t.JSXExpressionContainer | t.JSXElement | t.JSXFragment)[] = []; - for (const c of node.children) { + const repl = toAttr(name, merged[key]); + changed = true; + return repl ? [repl] : []; + }); + + const newChildren = node.children.flatMap< + t.JSXText | t.JSXExpressionContainer | t.JSXSpreadChild | t.JSXElement | t.JSXFragment + >((c) => { if (t.isJSXElement(c) || t.isJSXFragment(c)) { const res = inlineArgsInJsx(c, merged); - changed = changed || res.changed; - newChildren.push(res.node as any); - } else if (t.isJSXExpressionContainer(c)) { + changed ||= res.changed; + return [res.node]; + } + if (t.isJSXExpressionContainer(c)) { const key = getArgsMemberKey(c.expression); - if (key === 'children' && Object.prototype.hasOwnProperty.call(merged, 'children')) { - const injected = toJsxChildren(merged['children']); - newChildren.push(...injected); + if (key === 'children' && merged.children) { changed = true; - } else { - newChildren.push(c); + return toJsxChildren(merged.children); } - } else { - newChildren.push(c as any); } - } + return [c]; + }); - const shouldSelfClose = opening.selfClosing && newChildren.length === 0; - const newOpening = t.jsxOpeningElement(opening.name, newAttrs, shouldSelfClose); - const newClosing = shouldSelfClose - ? null - : (node.closingElement ?? t.jsxClosingElement(opening.name)); - const newEl = t.jsxElement(newOpening, newClosing, newChildren, shouldSelfClose); - return { node: newEl, changed }; + const selfClosing = opening.selfClosing && newChildren.length === 0; + return { + node: t.jsxElement( + t.jsxOpeningElement(opening.name, newAttrs, selfClosing), + selfClosing ? null : (node.closingElement ?? t.jsxClosingElement(opening.name)), + newChildren, + selfClosing + ), + changed, + }; } - // JSXFragment - const fragChildren: (t.JSXText | t.JSXExpressionContainer | t.JSXElement | t.JSXFragment)[] = []; - for (const c of node.children) { + const fragChildren = node.children.flatMap((c): (typeof c)[] => { if (t.isJSXElement(c) || t.isJSXFragment(c)) { const res = inlineArgsInJsx(c, merged); - changed = changed || res.changed; - fragChildren.push(res.node as any); - } else if (t.isJSXExpressionContainer(c)) { + changed ||= res.changed; + return [res.node]; + } + if (t.isJSXExpressionContainer(c)) { const key = getArgsMemberKey(c.expression); - if (key === 'children' && Object.prototype.hasOwnProperty.call(merged, 'children')) { - const injected = toJsxChildren(merged['children']); - fragChildren.push(...injected); + if (key === 'children' && 'children' in merged) { changed = true; - } else { - fragChildren.push(c); + return toJsxChildren(merged.children); } - } else { - fragChildren.push(c as any); } - } - const newFrag = t.jsxFragment(node.openingFragment, node.closingFragment, fragChildren); - return { node: newFrag, changed }; + return [c]; + }); + + return { node: t.jsxFragment(node.openingFragment, node.closingFragment, fragChildren), changed }; } +/** Expand `{...args}` into concrete attributes/children (recursively). */ function transformArgsSpreadsInJsx( node: t.JSXElement | t.JSXFragment, merged: Record @@ -466,137 +418,114 @@ function transformArgsSpreadsInJsx( let changed = false; const makeInjectedPieces = ( - existingAttrNames: Set + existing: ReadonlySet ): Array => { - // Normalize incoming set to a set of plain string names for reliable membership checks - const existingNames = new Set( - Array.from(existingAttrNames).map((n) => - typeof n === 'string' ? n : t.isJSXIdentifier(n) ? n.name : '' - ) - ); - - 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 entries = Object.entries(merged).filter(([k, v]) => v != null && k !== 'children'); + const validEntries = entries.filter(([k]) => isValidJsxAttrName(k)); + const invalidEntries = entries.filter(([k]) => !isValidJsxAttrName(k)); const injectedAttrs = validEntries .map(([k, v]) => toAttr(k, v)) - .filter((a): a is t.JSXAttribute => Boolean(a)); - - const filteredInjected = injectedAttrs.filter( - (a) => t.isJSXIdentifier(a.name) && !existingNames.has(a.name.name) - ); + .filter((a): a is t.JSXAttribute => Boolean(a)) + .filter((a) => t.isJSXIdentifier(a.name) && !existing.has(a.name.name)); - const invalidProps = invalidEntries.filter(([k]) => !existingNames.has(k)); - const invalidSpread = buildInvalidSpread(invalidProps); - - return [...filteredInjected, ...(invalidSpread ? [invalidSpread] : [])]; + const invalidSpread = buildInvalidSpread(invalidEntries.filter(([k]) => !existing.has(k))); + return invalidSpread ? [...injectedAttrs, invalidSpread] : injectedAttrs; }; if (t.isJSXElement(node)) { const opening = node.openingElement; const attrs = opening.attributes; - // Collect non-args attrs, track first insertion index, and whether we saw any args spreads - const nonArgsAttrs: (t.JSXAttribute | t.JSXSpreadAttribute)[] = []; - let insertionIndex = 0; - let sawArgsSpread = false; - - for (let i = 0; i < attrs.length; i++) { - const a = attrs[i]!; - const isArgsSpread = - t.isJSXSpreadAttribute(a) && t.isIdentifier(a.argument) && a.argument.name === 'args'; - if (isArgsSpread) { - if (!sawArgsSpread) { - insertionIndex = nonArgsAttrs.length; - } - sawArgsSpread = true; - continue; // drop all {...args} - } - nonArgsAttrs.push(a as any); - } - - let newAttrs = nonArgsAttrs; - if (sawArgsSpread) { - const existingAttrNames = new Set( - nonArgsAttrs - .filter((a) => t.isJSXAttribute(a) && t.isJSXIdentifier(a.name)) - .map((a) => (a as t.JSXAttribute).name.name) - ); - - const pieces = makeInjectedPieces(existingAttrNames); - newAttrs = [ - ...nonArgsAttrs.slice(0, insertionIndex), - ...pieces, - ...nonArgsAttrs.slice(insertionIndex), - ]; - changed = true; - } - - // Recurse into children - const newChildren: (t.JSXText | t.JSXExpressionContainer | t.JSXElement | t.JSXFragment)[] = []; - for (const c of node.children) { + const isArgsSpread = (a: t.JSXAttribute | t.JSXSpreadAttribute) => + t.isJSXSpreadAttribute(a) && t.isIdentifier(a.argument) && a.argument.name === 'args'; + + const sawArgsSpread = attrs.some(isArgsSpread); + const firstIdx = attrs.findIndex(isArgsSpread); + const nonArgsAttrs = attrs.filter((a) => !isArgsSpread(a)); + const insertionIndex = sawArgsSpread + ? attrs.slice(0, firstIdx).filter((a) => !isArgsSpread(a)).length + : 0; + + const newAttrs = sawArgsSpread + ? (() => { + const existing = new Set( + nonArgsAttrs + .filter((a): a is t.JSXAttribute => t.isJSXAttribute(a)) + .flatMap((a) => (t.isJSXIdentifier(a.name) ? [a.name.name] : [])) + ); + const pieces = makeInjectedPieces(existing); + changed = true; + return [ + ...nonArgsAttrs.slice(0, insertionIndex), + ...pieces, + ...nonArgsAttrs.slice(insertionIndex), + ]; + })() + : nonArgsAttrs; + + const newChildren = node.children.flatMap((c): (typeof c)[] => { if (t.isJSXElement(c) || t.isJSXFragment(c)) { const res = transformArgsSpreadsInJsx(c, merged); - changed = changed || res.changed; - newChildren.push(res.node as any); - } else { - newChildren.push(c as any); + changed ||= res.changed; + return [res.node]; } - } + return [c]; + }); - const shouldSelfClose = opening.selfClosing && newChildren.length === 0; - const newOpening = t.jsxOpeningElement(opening.name, newAttrs, shouldSelfClose); - const newClosing = shouldSelfClose - ? null - : (node.closingElement ?? t.jsxClosingElement(opening.name)); - const newEl = t.jsxElement(newOpening, newClosing, newChildren, shouldSelfClose); - return { node: newEl, changed }; + const children = + sawArgsSpread && newChildren.length === 0 && merged.children + ? ((changed = true), toJsxChildren(merged.children)) + : newChildren; + + const selfClosing = children.length === 0; + return { + node: t.jsxElement( + t.jsxOpeningElement(opening.name, newAttrs, selfClosing), + selfClosing ? null : (node.closingElement ?? t.jsxClosingElement(opening.name)), + children, + selfClosing + ), + changed, + }; } - // JSXFragment - const fragChildren: (t.JSXText | t.JSXExpressionContainer | t.JSXElement | t.JSXFragment)[] = []; - for (const c of node.children) { + const fragChildren = node.children.flatMap((c): (typeof c)[] => { if (t.isJSXElement(c) || t.isJSXFragment(c)) { const res = transformArgsSpreadsInJsx(c, merged); - changed = changed || res.changed; - fragChildren.push(res.node as any); - } else { - fragChildren.push(c as any); + changed ||= res.changed; + return [res.node]; } - } - const newFrag = t.jsxFragment(node.openingFragment, node.closingFragment, fragChildren); - return { node: newFrag, changed }; + return [c]; + }); + + return { node: t.jsxFragment(node.openingFragment, node.closingFragment, fragChildren), changed }; } -// Resolve the initializer path for an identifier used in a `.bind(...)` call +/** Resolve the initializer for an identifier used as `Template.bind(...)`. */ function resolveBindIdentifierInit( - storyExportPath: NodePath, + storyPath: NodePath, identifier: NodePath -): NodePath | null { - const programPath = storyExportPath.findParent((p) => p.isProgram()); +) { + const programPath = storyPath.findParent((p) => p.isProgram()) as NodePath | null; if (!programPath) { return null; } - const declarators = (programPath.get('body') as NodePath[]) // statements - .flatMap((stmt) => { - if ((stmt as NodePath).isVariableDeclaration()) { - return (stmt as NodePath).get( - 'declarations' - ) as NodePath[]; - } - if ((stmt as NodePath).isExportNamedDeclaration()) { - const decl = (stmt as NodePath).get( - 'declaration' - ) as NodePath; - if (decl && decl.isVariableDeclaration()) { - return decl.get('declarations') as NodePath[]; - } + const declarators = programPath.get('body').flatMap((stmt) => { + if (stmt.isVariableDeclaration()) { + return stmt.get('declarations'); + } + if (stmt.isExportNamedDeclaration()) { + const decl = stmt.get('declaration'); + + if (decl && decl.isVariableDeclaration()) { + return decl.get('declarations'); } - return [] as NodePath[]; - }); + } + return []; + }); const match = declarators.find((d) => { const id = d.get('id'); @@ -606,6 +535,27 @@ function resolveBindIdentifierInit( if (!match) { return null; } - const init = match.get('init') as NodePath | null; + const init = match.get('init'); return init && init.isExpression() ? init : null; } + +export function pathForNode( + program: NodePath, + target: T | undefined +): NodePath | undefined { + if (!target) { + return undefined; + } + let found: NodePath | undefined; + + program.traverse({ + enter(p) { + if (p.node && p.node === target) { + found = p as NodePath; + p.stop(); // bail as soon as we have it + } + }, + }); + + return found; +} diff --git a/code/renderers/react/src/componentManifest/generator.test.ts b/code/renderers/react/src/componentManifest/generator.test.ts index d50652724469..6196a2637956 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,223 @@ 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('fall back to index title when no component name', async () => { + const code = dedent` + import type { Meta } from '@storybook/react'; + import { Button } from './Button'; + + export default { + args: { onClick: fn() }, + }; + + export const Primary = () =>