From 67d5780983935c4748d341ebcd476b7233f10140 Mon Sep 17 00:00:00 2001 From: Kasper Peulen Date: Thu, 9 Oct 2025 15:52:20 +0200 Subject: [PATCH 01/23] generate code snippets from csf file --- code/core/src/csf-tools/CsfFile.ts | 2 +- .../csf-tools/generateCodeSnippet.test.tsx | 158 ++++++++++++++ .../core/src/csf-tools/generateCodeSnippet.ts | 205 ++++++++++++++++++ 3 files changed, 364 insertions(+), 1 deletion(-) create mode 100644 code/core/src/csf-tools/generateCodeSnippet.test.tsx create mode 100644 code/core/src/csf-tools/generateCodeSnippet.ts diff --git a/code/core/src/csf-tools/CsfFile.ts b/code/core/src/csf-tools/CsfFile.ts index 213bcf2fd53e..20a90feda3fe 100644 --- a/code/core/src/csf-tools/CsfFile.ts +++ b/code/core/src/csf-tools/CsfFile.ts @@ -295,7 +295,7 @@ export class CsfFile { _metaStatement: t.Statement | undefined; - _metaNode: t.Expression | undefined; + _metaNode: t.ObjectExpression | undefined; _metaPath: NodePath | undefined; diff --git a/code/core/src/csf-tools/generateCodeSnippet.test.tsx b/code/core/src/csf-tools/generateCodeSnippet.test.tsx new file mode 100644 index 000000000000..29d112ec9017 --- /dev/null +++ b/code/core/src/csf-tools/generateCodeSnippet.test.tsx @@ -0,0 +1,158 @@ +import { expect, test } from 'vitest'; + +import { dedent } from 'ts-dedent'; + +import { recast } from '../babel'; +import { loadCsf } from './CsfFile'; +import { getAllCodeSnippets } from './generateCodeSnippet'; + +function generateExample(code: string) { + const csf = loadCsf(code, { makeTitle: (userTitle?: string) => userTitle ?? 'title' }).parse(); + return recast.print(getAllCodeSnippets(csf)).code; +} + +test('CSF3', async () => { + const input = dedent` + // Button.stories.tsx + import type { Meta, StoryObj, StoryFn } from '@storybook/react'; + import { Button } from '@design-system/button'; + + const meta: Meta = { + component: Button, + args: { + children: 'Click me' + } + }; + export default meta; + + type Story = StoryObj; + + export const Default: Story = {}; + + export const WithEmoji: Story = { + args: { + children: '🚀Launch' + } + }; + + export const Disabled: Story = { + args: { + disabled: true, + } + }; + + export const LinkButton: Story = { + args: { + children: This is a link, + } + }; + + export const ObjectArgs: Story = { + args: { + string: 'string', + number: 1, + object: { an: 'object'}, + complexObjet: {...{a: 1}, an: 'object'}, + array: [1,2,3] + } + }; + + export const CSF1: StoryFn = () => + + export const CSF2: StoryFn = (args) => + + export const CustomRender: Story = { + render: () => + }; + `; + + expect(generateExample(input)).toMatchInlineSnapshot(` + "const Default = () => ; + const WithEmoji = () => ; + const Disabled = () => ; + const LinkButton = () => ; + + const ObjectArgs = () => ; + + const CSF1 = () => ; + const CSF2 = (args) => ; + const CustomRender = () => ;" + `); +}); + + +test('CSF4', async () => { + const input = dedent` + // Button.stories.tsx + import preview from './preview'; + import { Button } from '@design-system/button'; + + const meta = preview.meta({ + component: Button, + args: { + children: 'Click me' + } + }); + + export const Default = meta.story({}); + + export const WithEmoji = meta.story({ + args: { + children: '🚀Launch' + } + }); + + export const Disabled = meta.story({ + args: { + disabled: true, + } + }); + + export const LinkButton = meta.story({ + args: { + children: This is a link, + } + }); + + export const ObjectArgs = meta.story({ + args: { + string: 'string', + number: 1, + object: { an: 'object'}, + complexObjet: {...{a: 1}, an: 'object'}, + array: [1,2,3] + } + }); + + export const CSF1 = meta.story(() => ) + + export const CSF2 = meta.story((args) => ) + + export const CustomRender = meta.story({ + render: () => + }); + `; + + expect(generateExample(input)).toMatchInlineSnapshot(` + "const Default = () => ; + const WithEmoji = () => ; + const Disabled = () => ; + const LinkButton = () => ; + + const ObjectArgs = () => ; + + const CSF1 = () => ; + const CSF2 = (args) => ; + const CustomRender = () => ;" + `); +}); diff --git a/code/core/src/csf-tools/generateCodeSnippet.ts b/code/core/src/csf-tools/generateCodeSnippet.ts new file mode 100644 index 000000000000..94505586626f --- /dev/null +++ b/code/core/src/csf-tools/generateCodeSnippet.ts @@ -0,0 +1,205 @@ +import { type NodePath, types as t } from 'storybook/internal/babel'; + +import invariant from 'tiny-invariant'; + +import { type CsfFile } from './CsfFile'; + +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'); + + const storyId = declarator.get('id'); + invariant(storyId.isIdentifier(), 'Expected named const story export'); + + let story: NodePath | null = init; + + if (init.isCallExpression()) { + const args = init.get('arguments'); + if (args.length === 0) { + story = null; + } + const storyArgument = args[0]; + invariant(storyArgument.isExpression()); + story = storyArgument; + } + + // If the story is already a function, keep it as-is. + if (story?.isArrowFunctionExpression() || story?.isFunctionExpression()) { + const expr = story.node; // This is already a t.Expression + return t.variableDeclaration('const', [ + t.variableDeclarator(t.identifier(storyId.node.name), expr), + ]); + } + + // Otherwise it must be an object story + const storyObjPath = story; + invariant( + storyObjPath === null || storyObjPath.isObjectExpression(), + 'Expected story init to be object or function' + ); + + // Prefer an explicit render() when it is a function (arrow/function) + const renderPath = storyObjPath + ?.get('properties') + .filter((p) => p.isObjectProperty()) + .filter((p) => keyOf(p.node) === 'render') + .map((p) => p.get('value')) + .find((value) => value.isExpression()); + + if (renderPath) { + const expr = renderPath.node; // t.Expression + return t.variableDeclaration('const', [ + t.variableDeclarator(t.identifier(storyId.node.name), expr), + ]); + } + + // Collect args: meta.args and story.args as Record + const metaArgs = metaArgsRecord(metaObj ?? null); + const storyArgsPath = storyObjPath + ?.get('properties') + .filter((p) => p.isObjectProperty()) + .filter((p) => keyOf(p.node) === 'args') + .map((p) => p.get('value')) + .find((value) => value.isObjectExpression()); + + const storyArgs = argsRecordFromObjectPath(storyArgsPath); + + // Merge (story overrides meta) + const merged: Record = { ...metaArgs, ...storyArgs }; + + // Split children from attrs + const childrenNode = merged['children']; + const attrs = Object.entries(merged) + .filter(([k, v]) => k !== 'children' && isValidJsxAttrName(k) && v != null) + .map(([k, v]) => toAttr(k, v)) + .filter((a): a is t.JSXAttribute => Boolean(a)); + + const name = t.jsxIdentifier(componentName); + + const arrow = t.arrowFunctionExpression( + [], + t.jsxElement( + t.jsxOpeningElement(name, attrs, false), + t.jsxClosingElement(name), + toJsxChildren(childrenNode), + false + ) + ); + + return t.variableDeclaration('const', [ + t.variableDeclarator(t.identifier(storyId.node.name), arrow), + ]); +} + +export function getAllCodeSnippets(csf: CsfFile) { + const component = csf._meta?.component ?? 'Unknown'; + + const snippets = Object.values(csf._storyPaths) + .map((path: NodePath) => + getCodeSnippet(path, csf._metaNode ?? null, component) + ) + .filter(Boolean); + + return t.program(snippets); +} + +const keyOf = (p: t.ObjectProperty): string | null => + t.isIdentifier(p.key) ? p.key.name : t.isStringLiteral(p.key) ? p.key.value : 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 argsRecordFromObjectNode = (objNode?: t.ObjectExpression | null): Record => { + if (!objNode) { + return {}; + } + return Object.fromEntries( + objNode.properties + .filter((prop) => t.isObjectProperty(prop)) + .flatMap((prop) => { + const key = keyOf(prop); + return key ? [[key, prop.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 => { + if (t.isBooleanLiteral(value)) { + return value.value ? t.jsxAttribute(t.jsxIdentifier(key), null) : null; + } + + if (t.isStringLiteral(value)) { + return t.jsxAttribute(t.jsxIdentifier(key), t.stringLiteral(value.value)); + } + + if (t.isNullLiteral(value)) { + return null; + } + + if (t.isIdentifier(value) && value.name === 'undefined') { + return 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 +}; From 5efe1839ea4e38ce551091530f766bd15154132f Mon Sep 17 00:00:00 2001 From: Kasper Peulen Date: Fri, 10 Oct 2025 16:25:45 +0200 Subject: [PATCH 02/23] Lot more cases --- .../csf-tools/generateCodeSnippet.test.tsx | 294 ++++++++++++------ .../core/src/csf-tools/generateCodeSnippet.ts | 149 ++++++++- 2 files changed, 330 insertions(+), 113 deletions(-) diff --git a/code/core/src/csf-tools/generateCodeSnippet.test.tsx b/code/core/src/csf-tools/generateCodeSnippet.test.tsx index 29d112ec9017..9ba2e8bcca2e 100644 --- a/code/core/src/csf-tools/generateCodeSnippet.test.tsx +++ b/code/core/src/csf-tools/generateCodeSnippet.test.tsx @@ -11,12 +11,11 @@ function generateExample(code: string) { return recast.print(getAllCodeSnippets(csf)).code; } -test('CSF3', async () => { - const input = dedent` - // Button.stories.tsx - import type { Meta, StoryObj, StoryFn } from '@storybook/react'; +function withCSF3(body: string) { + return dedent` + import type { Meta } from '@storybook/react'; import { Button } from '@design-system/button'; - + const meta: Meta = { component: Button, args: { @@ -24,29 +23,86 @@ test('CSF3', async () => { } }; export default meta; + + ${body} + `; +} + +function withCSF4(body: string) { + return dedent` + import preview from './preview'; + import { Button } from '@design-system/button'; - type Story = StoryObj; + const meta = preview.meta({ + component: Button, + args: { + children: 'Click me' + } + }); + ${body} + `; +} + +test('Default', () => { + const input = withCSF3(` export const Default: Story = {}; - + `); + expect(generateExample(input)).toMatchInlineSnapshot( + `"const Default = () => ;"` + ); +}); + +test('Default- CSF4', () => { + const input = withCSF4(` + export const Default = meta.story({}); + `); + expect(generateExample(input)).toMatchInlineSnapshot( + `"const Default = () => ;"` + ); +}); + +test('Replace children', () => { + const input = withCSF3(dedent` export const WithEmoji: Story = { args: { children: '🚀Launch' } }; - + `); + expect(generateExample(input)).toMatchInlineSnapshot( + `"const WithEmoji = () => ;"` + ); +}); + +test('Boolean', () => { + const input = withCSF3(dedent` export const Disabled: Story = { args: { - disabled: true, + disabled: true } }; - + `); + expect(generateExample(input)).toMatchInlineSnapshot( + `"const Disabled = () => ;"` + ); +}); + +test('JSX Children', () => { + const input = withCSF3(dedent` export const LinkButton: Story = { args: { children: This is a link, } }; - + `); + expect(generateExample(input)).toMatchInlineSnapshot( + `"const LinkButton = () => ;"` + ); +}); + +test('Object', () => { + const input = withCSF3(dedent` export const ObjectArgs: Story = { args: { string: 'string', @@ -56,103 +112,149 @@ test('CSF3', async () => { array: [1,2,3] } }; - - export const CSF1: StoryFn = () => - - export const CSF2: StoryFn = (args) => - - export const CustomRender: Story = { - render: () => - }; - `; - + `); expect(generateExample(input)).toMatchInlineSnapshot(` - "const Default = () => ; - const WithEmoji = () => ; - const Disabled = () => ; - const LinkButton = () => ; - - const ObjectArgs = () => ; + array={[1,2,3]}>Click me;" + `); +}); - const CSF1 = () => ; - const CSF2 = (args) => ; - const CustomRender = () => ;" +test('CSF1', () => { + const input = withCSF3(dedent` + export const CSF1: StoryFn = () => ; `); + expect(generateExample(input)).toMatchInlineSnapshot( + `"const CSF1 = () => ;"` + ); }); +test('CSF2', () => { + const input = withCSF3(dedent` + export const CSF2: StoryFn = (args) => ; + `); + expect(generateExample(input)).toMatchInlineSnapshot( + `"const CSF2 = () => ;"` + ); +}); -test('CSF4', async () => { - const input = dedent` - // Button.stories.tsx - import preview from './preview'; - import { Button } from '@design-system/button'; - - const meta = preview.meta({ - component: Button, - args: { - children: 'Click me' - } - }); - - export const Default = meta.story({}); - - export const WithEmoji = meta.story({ - args: { - children: '🚀Launch' - } - }); - - export const Disabled = meta.story({ - args: { - disabled: true, - } - }); - - export const LinkButton = meta.story({ - args: { - children: This is a link, - } - }); - - export const ObjectArgs = meta.story({ - args: { - string: 'string', - number: 1, - object: { an: 'object'}, - complexObjet: {...{a: 1}, an: 'object'}, - array: [1,2,3] - } - }); - - export const CSF1 = meta.story(() => ) - - export const CSF2 = meta.story((args) => ) - - export const CustomRender = meta.story({ - render: () => - }); - `; +test('Custom Render', () => { + const input = withCSF3(dedent` + export const CustomRender: Story = { render: () => } + `); + expect(generateExample(input)).toMatchInlineSnapshot( + `"const CustomRender = () => ;"` + ); +}); - expect(generateExample(input)).toMatchInlineSnapshot(` - "const Default = () => ; - const WithEmoji = () => ; - const Disabled = () => ; - const LinkButton = () => ; +test('CustomRenderWithOverideArgs only', async () => { + const input = withCSF3( + `export const CustomRenderWithOverideArgs = { + render: (args) => , + args: { foo: 'bar', override: 'value' } + };` + ); + expect(generateExample(input)).toMatchInlineSnapshot( + `"const CustomRenderWithOverideArgs = () => ;"` + ); +}); - const ObjectArgs = () => ; +test('CustomRenderWithNoArgs only', async () => { + const input = withCSF3( + `export const CustomRenderWithNoArgs = { + render: (args) => + };` + ); + expect(generateExample(input)).toMatchInlineSnapshot( + `"const CustomRenderWithNoArgs = () => ;"` + ); +}); - const CSF1 = () => ; - const CSF2 = (args) => ; - const CustomRender = () => ;" +test('CustomRenderWithDuplicateOnly only', async () => { + const input = withCSF3( + `export const CustomRenderWithDuplicateOnly = { + render: (args) => , + args: { override: 'value' } + };` + ); + expect(generateExample(input)).toMatchInlineSnapshot( + `"const CustomRenderWithDuplicateOnly = () => ;"` + ); +}); + +test('CustomRenderWithMultipleSpreads only', async () => { + const input = withCSF3( + `export const CustomRenderWithMultipleSpreads = { + render: (args) => , + args: { qux: 'q' } + };` + ); + expect(generateExample(input)).toMatchInlineSnapshot( + `"const CustomRenderWithMultipleSpreads = () => ;"` + ); +}); + +test('CustomRenderBlockBody only', async () => { + const input = withCSF3( + `export const CustomRenderBlockBody = { + render: (args) => { return }, + args: { foo: 'bar' } + };` + ); + expect(generateExample(input)).toMatchInlineSnapshot( + `"const CustomRenderBlockBody = (args) => { return };"` + ); +}); + +test('ObjectFalsyArgs only', async () => { + const input = withCSF3( + `export const ObjectFalsyArgs = { + args: { disabled: false, count: 0, empty: '' } + };` + ); + expect(generateExample(input)).toMatchInlineSnapshot( + `"const ObjectFalsyArgs = () => ;"` + ); +}); + +test('ObjectUndefinedNull only', async () => { + const input = withCSF3( + `export const ObjectUndefinedNull = { + args: { thing: undefined, nada: null } + };` + ); + expect(generateExample(input)).toMatchInlineSnapshot( + `"const ObjectUndefinedNull = () => ;"` + ); +}); + +test('ObjectDataAttr only', async () => { + const input = withCSF3( + `export const ObjectDataAttr = { + args: { 'data-test-id': 'x' } + };` + ); + expect(generateExample(input)).toMatchInlineSnapshot( + `"const ObjectDataAttr = () => ;"` + ); +}); + +test('ObjectInvalidAttr only', async () => { + const input = withCSF3( + `export const ObjectInvalidAttr = { + args: { '1x': 'a', 'bad key': 'b', '@foo': 'c', '-dash': 'd' } + };` + ); + expect(generateExample(input)).toMatchInlineSnapshot(` + "const ObjectInvalidAttr = () => ;" `); }); diff --git a/code/core/src/csf-tools/generateCodeSnippet.ts b/code/core/src/csf-tools/generateCodeSnippet.ts index 94505586626f..1cbd48d89bff 100644 --- a/code/core/src/csf-tools/generateCodeSnippet.ts +++ b/code/core/src/csf-tools/generateCodeSnippet.ts @@ -31,16 +31,11 @@ export function getCodeSnippet( story = storyArgument; } - // If the story is already a function, keep it as-is. - if (story?.isArrowFunctionExpression() || story?.isFunctionExpression()) { - const expr = story.node; // This is already a t.Expression - return t.variableDeclaration('const', [ - t.variableDeclarator(t.identifier(storyId.node.name), expr), - ]); - } + // 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; + const storyObjPath = + story?.isArrowFunctionExpression() || story?.isFunctionExpression() ? null : story; invariant( storyObjPath === null || storyObjPath.isObjectExpression(), 'Expected story init to be object or function' @@ -54,12 +49,7 @@ export function getCodeSnippet( .map((p) => p.get('value')) .find((value) => value.isExpression()); - if (renderPath) { - const expr = renderPath.node; // t.Expression - return t.variableDeclaration('const', [ - t.variableDeclarator(t.identifier(storyId.node.name), expr), - ]); - } + const storyFn = renderPath ?? story; // Collect args: meta.args and story.args as Record const metaArgs = metaArgsRecord(metaObj ?? null); @@ -75,19 +65,144 @@ export function getCodeSnippet( // Merge (story overrides meta) const merged: Record = { ...metaArgs, ...storyArgs }; + if (storyFn?.isArrowFunctionExpression() || storyFn?.isFunctionExpression()) { + const fn = storyFn.node; + + // Collect args from meta only (no story-level args in CSF2 function form) + const metaArgs = metaArgsRecord(metaObj ?? null); + + // Split merged args (excluding children) into valid JSX attributes and invalid-key entries + 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 is t.JSXAttribute => Boolean(a)); + + // Only handle arrow function with direct JSX expression body for now + if (t.isArrowFunctionExpression(fn) && t.isJSXElement(fn.body)) { + const body = fn.body as t.JSXElement; + 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)); + let invalidSpread: t.JSXSpreadAttribute | null = null; + if (invalidProps.length > 0) { + const objectProps = invalidProps.map(([k, v]) => + t.objectProperty( + t.stringLiteral(k), + t.isExpression(v) ? v : (t.identifier('undefined') as t.Expression) + ) + ); + invalidSpread = t.jsxSpreadAttribute(t.objectExpression(objectProps)); + } + + // Handle children injection from meta if the element currently has no children + const metaChildren = + metaArgs && Object.prototype.hasOwnProperty.call(metaArgs, 'children') + ? (metaArgs as Record)['children'] + : undefined; + const canInjectChildren = + !!metaChildren && (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(metaChildren) : body.children; + + const newBody = t.jsxElement(finalOpening, finalClosing, finalChildren, shouldSelfClose); + const newFn = t.arrowFunctionExpression([], newBody, fn.async); + return t.variableDeclaration('const', [ + t.variableDeclarator(t.identifier(storyId.node.name), 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), + ]); + } + // Split children from attrs const childrenNode = merged['children']; - const attrs = Object.entries(merged) - .filter(([k, v]) => k !== 'children' && isValidJsxAttrName(k) && v != null) + const entries2 = Object.entries(merged).filter(([k]) => k !== 'children'); + const validEntries2 = entries2.filter(([k, v]) => isValidJsxAttrName(k) && v != null); + const invalidEntries2 = entries2.filter(([k, v]) => !isValidJsxAttrName(k) && v != null); + + const attrs = validEntries2 .map(([k, v]) => toAttr(k, v)) .filter((a): a is t.JSXAttribute => Boolean(a)); + // Build spread for invalid-only props, if any + let invalidSpread2: t.JSXSpreadAttribute | null = null; + if (invalidEntries2.length > 0) { + const objectProps = invalidEntries2.map(([k, v]) => + t.objectProperty( + t.stringLiteral(k), + t.isExpression(v) ? v : (t.identifier('undefined') as t.Expression) + ) + ); + invalidSpread2 = t.jsxSpreadAttribute(t.objectExpression(objectProps)); + } + const name = t.jsxIdentifier(componentName); + const openingElAttrs: Array = [ + ...attrs, + ...(invalidSpread2 ? [invalidSpread2] : []), + ]; + const arrow = t.arrowFunctionExpression( [], t.jsxElement( - t.jsxOpeningElement(name, attrs, false), + t.jsxOpeningElement(name, openingElAttrs, false), t.jsxClosingElement(name), toJsxChildren(childrenNode), false From 446abe58c631c9175a71ef7777218b7742eacc66 Mon Sep 17 00:00:00 2001 From: Kasper Peulen Date: Fri, 10 Oct 2025 16:36:19 +0200 Subject: [PATCH 03/23] Cleanup code --- .../core/src/csf-tools/generateCodeSnippet.ts | 71 +++++++------------ 1 file changed, 25 insertions(+), 46 deletions(-) diff --git a/code/core/src/csf-tools/generateCodeSnippet.ts b/code/core/src/csf-tools/generateCodeSnippet.ts index 1cbd48d89bff..7bf12d65e847 100644 --- a/code/core/src/csf-tools/generateCodeSnippet.ts +++ b/code/core/src/csf-tools/generateCodeSnippet.ts @@ -4,6 +4,17 @@ import invariant from 'tiny-invariant'; import { type CsfFile } from './CsfFile'; +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( storyExportPath: NodePath, metaObj: t.ObjectExpression | null | undefined, @@ -65,24 +76,20 @@ export function getCodeSnippet( // Merge (story overrides meta) const merged: Record = { ...metaArgs, ...storyArgs }; - if (storyFn?.isArrowFunctionExpression() || storyFn?.isFunctionExpression()) { - const fn = storyFn.node; - - // Collect args from meta only (no story-level args in CSF2 function form) - const metaArgs = metaArgsRecord(metaObj ?? null); + 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); - // Split merged args (excluding children) into valid JSX attributes and invalid-key entries - 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 is t.JSXAttribute => Boolean(a)); - const injectedAttrs = validEntries - .map(([k, v]) => toAttr(k, v)) - .filter((a): a is t.JSXAttribute => Boolean(a)); + if (storyFn?.isArrowFunctionExpression() || storyFn?.isFunctionExpression()) { + 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 as t.JSXElement; + const body = fn.body; const opening = body.openingElement; const attrs = opening.attributes; const firstSpreadIndex = attrs.findIndex( @@ -119,16 +126,7 @@ export function getCodeSnippet( // 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)); - let invalidSpread: t.JSXSpreadAttribute | null = null; - if (invalidProps.length > 0) { - const objectProps = invalidProps.map(([k, v]) => - t.objectProperty( - t.stringLiteral(k), - t.isExpression(v) ? v : (t.identifier('undefined') as t.Expression) - ) - ); - invalidSpread = t.jsxSpreadAttribute(t.objectExpression(objectProps)); - } + const invalidSpread: t.JSXSpreadAttribute | null = buildInvalidSpread(invalidProps); // Handle children injection from meta if the element currently has no children const metaChildren = @@ -170,33 +168,14 @@ export function getCodeSnippet( ]); } - // Split children from attrs - const childrenNode = merged['children']; - const entries2 = Object.entries(merged).filter(([k]) => k !== 'children'); - const validEntries2 = entries2.filter(([k, v]) => isValidJsxAttrName(k) && v != null); - const invalidEntries2 = entries2.filter(([k, v]) => !isValidJsxAttrName(k) && v != null); - - const attrs = validEntries2 - .map(([k, v]) => toAttr(k, v)) - .filter((a): a is t.JSXAttribute => Boolean(a)); - // Build spread for invalid-only props, if any - let invalidSpread2: t.JSXSpreadAttribute | null = null; - if (invalidEntries2.length > 0) { - const objectProps = invalidEntries2.map(([k, v]) => - t.objectProperty( - t.stringLiteral(k), - t.isExpression(v) ? v : (t.identifier('undefined') as t.Expression) - ) - ); - invalidSpread2 = t.jsxSpreadAttribute(t.objectExpression(objectProps)); - } + const invalidSpread = buildInvalidSpread(invalidEntries); const name = t.jsxIdentifier(componentName); const openingElAttrs: Array = [ - ...attrs, - ...(invalidSpread2 ? [invalidSpread2] : []), + ...injectedAttrs, + ...(invalidSpread ? [invalidSpread] : []), ]; const arrow = t.arrowFunctionExpression( @@ -204,7 +183,7 @@ export function getCodeSnippet( t.jsxElement( t.jsxOpeningElement(name, openingElAttrs, false), t.jsxClosingElement(name), - toJsxChildren(childrenNode), + toJsxChildren(merged.children), false ) ); From ef96effc80febe51b00803fb849683a995d03ffc Mon Sep 17 00:00:00 2001 From: Kasper Peulen Date: Fri, 10 Oct 2025 16:43:42 +0200 Subject: [PATCH 04/23] Inline args in JSX --- .../csf-tools/generateCodeSnippet.test.tsx | 47 ++++++ .../core/src/csf-tools/generateCodeSnippet.ts | 138 +++++++++++++++++- 2 files changed, 184 insertions(+), 1 deletion(-) diff --git a/code/core/src/csf-tools/generateCodeSnippet.test.tsx b/code/core/src/csf-tools/generateCodeSnippet.test.tsx index 9ba2e8bcca2e..5b11ce70546e 100644 --- a/code/core/src/csf-tools/generateCodeSnippet.test.tsx +++ b/code/core/src/csf-tools/generateCodeSnippet.test.tsx @@ -258,3 +258,50 @@ test('ObjectInvalidAttr only', async () => { }}>Click me;" `); }); + +test('Inline nested args in child element (string)', () => { + const input = withCSF3(dedent` + export const NestedInline: Story = { + render: (args) => , + args: { foo: 'bar' } + }; + `); + expect(generateExample(input)).toMatchInlineSnapshot( + `"const NestedInline = () => ;"` + ); +}); + +test('Inline nested args in child element (boolean)', () => { + const input = withCSF3(dedent` + export const NestedBoolean: Story = { + render: (args) => , + args: { active: true } + }; + `); + expect(generateExample(input)).toMatchInlineSnapshot( + `"const NestedBoolean = () => ;"` + ); +}); + +test('Remove nested attr when arg is null/undefined', () => { + const input = withCSF3(dedent` + export const NestedRemove: Story = { + render: (args) => , + args: { gone: null } + }; + `); + expect(generateExample(input)).toMatchInlineSnapshot( + `"const NestedRemove = () => ;"` + ); +}); + +test('Inline args.children when used as child expression', () => { + const input = withCSF3(dedent` + export const ChildrenExpr: Story = { + render: (args) => + }; + `); + expect(generateExample(input)).toMatchInlineSnapshot( + `"const ChildrenExpr = () => ;"` + ); +}); diff --git a/code/core/src/csf-tools/generateCodeSnippet.ts b/code/core/src/csf-tools/generateCodeSnippet.ts index 7bf12d65e847..ca96c61f7b69 100644 --- a/code/core/src/csf-tools/generateCodeSnippet.ts +++ b/code/core/src/csf-tools/generateCodeSnippet.ts @@ -5,7 +5,9 @@ import invariant from 'tiny-invariant'; import { type CsfFile } from './CsfFile'; function buildInvalidSpread(entries: Array<[string, t.Node]>): t.JSXSpreadAttribute | null { - if (entries.length === 0) return null; + if (entries.length === 0) { + return null; + } const objectProps = entries.map(([k, v]) => t.objectProperty( t.stringLiteral(k), @@ -159,6 +161,15 @@ export function getCodeSnippet( t.variableDeclarator(t.identifier(storyId.node.name), newFn), ]); } + + // No {...args} at top level; still try to 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), + ]); + } } // Fallback: keep the function as-is @@ -297,3 +308,128 @@ const toJsxChildren = ( } return []; // ignore non-expressions }; + +// Detects {args.key} member usage +function getArgsMemberKey(expr: t.Node): string | null { + if (t.isMemberExpression(expr) && t.isIdentifier(expr.object) && expr.object.name === 'args') { + if (t.isIdentifier(expr.property) && !expr.computed) { + return expr.property.name; + } + + if (t.isStringLiteral(expr.property) && expr.computed) { + 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) && + expr.object.name === 'args' + ) { + const prop = expr.property; + + if (t.isIdentifier(prop) && !expr.computed) { + return prop.name; + } + + if (t.isStringLiteral(prop) && expr.computed) { + return prop.value; + } + } + 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); +} + +function inlineArgsInJsx( + node: t.JSXElement | t.JSXFragment, + merged: Record +): { node: t.JSXElement | t.JSXFragment; changed: boolean } { + let changed = false; + + 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); + } + } + + // Process children + const newChildren: (t.JSXText | t.JSXExpressionContainer | t.JSXElement | t.JSXFragment)[] = []; + for (const c of node.children) { + 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)) { + const key = getArgsMemberKey(c.expression); + if (key === 'children' && Object.prototype.hasOwnProperty.call(merged, 'children')) { + const injected = toJsxChildren(merged['children']); + newChildren.push(...injected); + changed = true; + } else { + newChildren.push(c); + } + } else { + newChildren.push(c as any); + } + } + + 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 }; + } + + // JSXFragment + const fragChildren: (t.JSXText | t.JSXExpressionContainer | t.JSXElement | t.JSXFragment)[] = []; + for (const c of node.children) { + 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)) { + const key = getArgsMemberKey(c.expression); + if (key === 'children' && Object.prototype.hasOwnProperty.call(merged, 'children')) { + const injected = toJsxChildren(merged['children']); + fragChildren.push(...injected); + changed = true; + } else { + fragChildren.push(c); + } + } else { + fragChildren.push(c as any); + } + } + const newFrag = t.jsxFragment(node.openingFragment, node.closingFragment, fragChildren); + return { node: newFrag, changed }; +} From 2d86537a4b6f2904c53fea53d898bd2ac8c97145 Mon Sep 17 00:00:00 2001 From: Kasper Peulen Date: Fri, 10 Oct 2025 16:49:23 +0200 Subject: [PATCH 05/23] More test examples --- .../csf-tools/generateCodeSnippet.test.tsx | 111 ++++++++++++++++++ 1 file changed, 111 insertions(+) diff --git a/code/core/src/csf-tools/generateCodeSnippet.test.tsx b/code/core/src/csf-tools/generateCodeSnippet.test.tsx index 5b11ce70546e..0bccc7d58941 100644 --- a/code/core/src/csf-tools/generateCodeSnippet.test.tsx +++ b/code/core/src/csf-tools/generateCodeSnippet.test.tsx @@ -305,3 +305,114 @@ test('Inline args.children when used as child expression', () => { `"const ChildrenExpr = () => ;"` ); }); + +// Deeper tree examples + +test('Deeply nested prop replacement (string)', () => { + const input = withCSF3(dedent` + export const DeepNestedProp: Story = { + render: (args) => ( + + ), + args: { foo: 'bar' } + }; + `); + expect(generateExample(input)).toMatchInlineSnapshot( + ` + "const DeepNestedProp = () => ;" + ` + ); +}); + +test('Deeply nested prop replacement (boolean)', () => { + const input = withCSF3(dedent` + export const DeepNestedBoolean: Story = { + render: (args) => ( + + ), + args: { active: true } + }; + `); + expect(generateExample(input)).toMatchInlineSnapshot( + ` + "const DeepNestedBoolean = () => ;" + ` + ); +}); + +test('Deeply nested children expression', () => { + const input = withCSF3(dedent` + export const DeepNestedChildren: Story = { + render: (args) => ( + + ) + }; + `); + expect(generateExample(input)).toMatchInlineSnapshot( + ` + "const DeepNestedChildren = () => ;" + ` + ); +}); + +test('Deeply nested multiple replacements', () => { + const input = withCSF3(dedent` + export const DeepNestedMultiple: Story = { + render: (args) => ( + + ), + args: { a: 'x', b: 'y' } + }; + `); + expect(generateExample(input)).toMatchInlineSnapshot( + ` + "const DeepNestedMultiple = () => ;" + ` + ); +}); From 3738a60552a5a42a16776a521a332e4622904bf1 Mon Sep 17 00:00:00 2001 From: Kasper Peulen Date: Fri, 10 Oct 2025 17:21:59 +0200 Subject: [PATCH 06/23] Add extra tests --- .../csf-tools/generateCodeSnippet.test.tsx | 50 +++++++ .../core/src/csf-tools/generateCodeSnippet.ts | 124 +++++++++++++++++- 2 files changed, 172 insertions(+), 2 deletions(-) diff --git a/code/core/src/csf-tools/generateCodeSnippet.test.tsx b/code/core/src/csf-tools/generateCodeSnippet.test.tsx index 0bccc7d58941..d64affd56ee8 100644 --- a/code/core/src/csf-tools/generateCodeSnippet.test.tsx +++ b/code/core/src/csf-tools/generateCodeSnippet.test.tsx @@ -416,3 +416,53 @@ test('Deeply nested multiple replacements', () => { ` ); }); + +test('Deeply nested multiple replacements and using args spread', () => { + const input = withCSF3(dedent` + export const DeepNestedMultiple: Story = { + render: (args) => ( + + ), + args: { a: 'x', b: 'y' } + }; + `); + expect(generateExample(input)).toMatchInlineSnapshot( + ` + "const DeepNestedMultiple = () => ;" + ` + ); +}); + +test('top level args injection and spreading in different places', async () => { + const input = withCSF3(dedent` + export const MultipleSpreads: Story = { + args: { disabled: false, count: 0, empty: '' }, + render: (args) => ( +
+
+ ), + }; + `); + expect(generateExample(input)).toMatchInlineSnapshot(` + "const MultipleSpreads = () =>
+
;" + `); +}); diff --git a/code/core/src/csf-tools/generateCodeSnippet.ts b/code/core/src/csf-tools/generateCodeSnippet.ts index ca96c61f7b69..48c5e62bf575 100644 --- a/code/core/src/csf-tools/generateCodeSnippet.ts +++ b/code/core/src/csf-tools/generateCodeSnippet.ts @@ -155,14 +155,31 @@ export function getCodeSnippet( : (body.closingElement ?? t.jsxClosingElement(opening.name)); const finalChildren = canInjectChildren ? toJsxChildren(metaChildren) : body.children; - const newBody = t.jsxElement(finalOpening, finalClosing, finalChildren, shouldSelfClose); + 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); return t.variableDeclaration('const', [ t.variableDeclarator(t.identifier(storyId.node.name), newFn), ]); } - // No {...args} at top level; still try to inline any usages of args.* in the entire JSX tree + // 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), + ]); + } + + // 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); @@ -433,3 +450,106 @@ function inlineArgsInJsx( const newFrag = t.jsxFragment(node.openingFragment, node.closingFragment, fragChildren); return { node: newFrag, changed }; } + +function transformArgsSpreadsInJsx( + node: t.JSXElement | t.JSXFragment, + merged: Record +): { node: t.JSXElement | t.JSXFragment; changed: boolean } { + let changed = false; + + const makeInjectedPieces = ( + existingAttrNames: Set + ): Array => { + 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 is t.JSXAttribute => Boolean(a)); + + const filteredInjected = injectedAttrs.filter( + (a) => t.isJSXIdentifier(a.name) && !existingAttrNames.has(a.name.name) + ); + + const invalidProps = invalidEntries.filter(([k]) => !existingAttrNames.has(k)); + const invalidSpread = buildInvalidSpread(invalidProps); + + return [...filteredInjected, ...(invalidSpread ? [invalidSpread] : [])]; + }; + + 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) { + 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); + } + } + + 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 }; + } + + // JSXFragment + const fragChildren: (t.JSXText | t.JSXExpressionContainer | t.JSXElement | t.JSXFragment)[] = []; + for (const c of node.children) { + 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); + } + } + const newFrag = t.jsxFragment(node.openingFragment, node.closingFragment, fragChildren); + return { node: newFrag, changed }; +} From 24425742b5ccb4ced68b2863641eeb92b2815c90 Mon Sep 17 00:00:00 2001 From: Kasper Peulen Date: Fri, 10 Oct 2025 17:26:07 +0200 Subject: [PATCH 07/23] Fix type error --- code/core/src/csf-tools/generateCodeSnippet.ts | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/code/core/src/csf-tools/generateCodeSnippet.ts b/code/core/src/csf-tools/generateCodeSnippet.ts index 48c5e62bf575..8b6a53f2ad9b 100644 --- a/code/core/src/csf-tools/generateCodeSnippet.ts +++ b/code/core/src/csf-tools/generateCodeSnippet.ts @@ -458,8 +458,15 @@ function transformArgsSpreadsInJsx( let changed = false; const makeInjectedPieces = ( - existingAttrNames: Set + existingAttrNames: Set ): 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); @@ -469,10 +476,10 @@ function transformArgsSpreadsInJsx( .filter((a): a is t.JSXAttribute => Boolean(a)); const filteredInjected = injectedAttrs.filter( - (a) => t.isJSXIdentifier(a.name) && !existingAttrNames.has(a.name.name) + (a) => t.isJSXIdentifier(a.name) && !existingNames.has(a.name.name) ); - const invalidProps = invalidEntries.filter(([k]) => !existingAttrNames.has(k)); + const invalidProps = invalidEntries.filter(([k]) => !existingNames.has(k)); const invalidSpread = buildInvalidSpread(invalidProps); return [...filteredInjected, ...(invalidSpread ? [invalidSpread] : [])]; From 18aaaf47654aeede68e67b00d1ca3510687fb356 Mon Sep 17 00:00:00 2001 From: Kasper Peulen Date: Fri, 10 Oct 2025 17:35:01 +0200 Subject: [PATCH 08/23] Template.bind expressions --- .../csf-tools/generateCodeSnippet.test.tsx | 10 +++ .../core/src/csf-tools/generateCodeSnippet.ts | 74 +++++++++++++++++-- 2 files changed, 78 insertions(+), 6 deletions(-) diff --git a/code/core/src/csf-tools/generateCodeSnippet.test.tsx b/code/core/src/csf-tools/generateCodeSnippet.test.tsx index d64affd56ee8..af377c608c48 100644 --- a/code/core/src/csf-tools/generateCodeSnippet.test.tsx +++ b/code/core/src/csf-tools/generateCodeSnippet.test.tsx @@ -141,6 +141,16 @@ test('CSF2', () => { ); }); +test('CSF2 - Template.bind', () => { + const input = withCSF3(dedent` + const Template = (args) => + export const CSF2: StoryFn = Template.bind({}); + `); + expect(generateExample(input)).toMatchInlineSnapshot( + `"const CSF2 = () => ;"` + ); +}); + test('Custom Render', () => { const input = withCSF3(dedent` export const CustomRender: Story = { render: () => } diff --git a/code/core/src/csf-tools/generateCodeSnippet.ts b/code/core/src/csf-tools/generateCodeSnippet.ts index 8b6a53f2ad9b..7845207bab61 100644 --- a/code/core/src/csf-tools/generateCodeSnippet.ts +++ b/code/core/src/csf-tools/generateCodeSnippet.ts @@ -35,13 +35,34 @@ export function getCodeSnippet( let story: NodePath | null = init; if (init.isCallExpression()) { - const args = init.get('arguments'); - if (args.length === 0) { - story = null; + const callee = init.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 as any).node) && + ((prop as any).node as t.StringLiteral).value === 'bind'); + if (obj.isIdentifier() && isBind) { + const resolved = resolveBindIdentifierInit(storyExportPath, obj); + if (resolved) { + story = resolved; + } + } + } + + // Fallback: treat call expression as story factory and use first argument + if (story === init) { + const args = init.get('arguments'); + if (args.length === 0) { + story = null; + } else { + const storyArgument = args[0]; + invariant(storyArgument.isExpression()); + story = storyArgument; + } } - const storyArgument = args[0]; - invariant(storyArgument.isExpression()); - story = storyArgument; } // If the story is already a function, try to inline args like in render() when using `{...args}` @@ -560,3 +581,44 @@ function transformArgsSpreadsInJsx( const newFrag = t.jsxFragment(node.openingFragment, node.closingFragment, fragChildren); return { node: newFrag, changed }; } + +// Resolve the initializer path for an identifier used in a `.bind(...)` call +function resolveBindIdentifierInit( + storyExportPath: NodePath, + identifier: NodePath +): NodePath | null { + const programPath = storyExportPath.findParent((p) => p.isProgram()); + + 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[]; + } + } + return [] as NodePath[]; + }); + + const match = declarators.find((d) => { + const id = d.get('id'); + return id.isIdentifier() && id.node.name === identifier.node.name; + }); + + if (!match) { + return null; + } + const init = match.get('init') as NodePath | null; + return init && init.isExpression() ? init : null; +} From 50ea146c207a0db5ba1b555301314f5b292d533c Mon Sep 17 00:00:00 2001 From: Kasper Peulen Date: Wed, 15 Oct 2025 15:41:04 +0200 Subject: [PATCH 09/23] Add componentManifestGenerator preset and implement for react --- code/core/src/core-server/build-static.ts | 16 +++++- code/core/src/core-server/dev-server.ts | 17 ++++++ code/core/src/types/modules/core-common.ts | 14 +++++ code/core/src/types/modules/indexer.ts | 1 + .../generateCodeSnippet.test.tsx | 23 +++++--- .../generateCodeSnippet.ts | 14 ----- code/renderers/react/src/preset.ts | 53 +++++++++++++++++++ 7 files changed, 117 insertions(+), 21 deletions(-) rename code/{core/src/csf-tools => renderers/react/src/component-manifest}/generateCodeSnippet.test.tsx (94%) rename code/{core/src/csf-tools => renderers/react/src/component-manifest}/generateCodeSnippet.ts (98%) diff --git a/code/core/src/core-server/build-static.ts b/code/core/src/core-server/build-static.ts index 96679b114bd4..8f55ec416387 100644 --- a/code/core/src/core-server/build-static.ts +++ b/code/core/src/core-server/build-static.ts @@ -1,4 +1,4 @@ -import { cp, mkdir } from 'node:fs/promises'; +import { cp, mkdir, writeFile } from 'node:fs/promises'; import { rm } from 'node:fs/promises'; import { join, relative, resolve } from 'node:path'; @@ -18,6 +18,7 @@ import { global } from '@storybook/global'; import picocolors from 'picocolors'; import { resolvePackageDir } from '../shared/utils/module'; +import { type ComponentManifestGenerator } from '../types'; import { StoryIndexGenerator } from './utils/StoryIndexGenerator'; import { buildOrThrow } from './utils/build-or-throw'; import { copyAllStaticFilesRelativeToMain } from './utils/copy-all-static-files'; @@ -163,6 +164,19 @@ export async function buildStaticStandalone(options: BuildStaticStandaloneOption initializedStoryIndexGenerator as Promise ) ); + + const features = await presets.apply('features'); + + if (features?.componentManifestGenerator) { + const componentManifestGenerator: ComponentManifestGenerator = await presets.apply( + 'componentManifestGenerator' + ); + const indexGenerator = await initializedStoryIndexGenerator; + if (componentManifestGenerator && indexGenerator) { + const manifests = await componentManifestGenerator(indexGenerator); + await writeFile(join(options.outputDir, 'components.json'), JSON.stringify(manifests)); + } + } } if (!core?.disableProjectJson) { diff --git a/code/core/src/core-server/dev-server.ts b/code/core/src/core-server/dev-server.ts index 375917558ad9..c0acb7981fa2 100644 --- a/code/core/src/core-server/dev-server.ts +++ b/code/core/src/core-server/dev-server.ts @@ -8,6 +8,7 @@ import polka from 'polka'; import invariant from 'tiny-invariant'; import { telemetry } from '../telemetry'; +import { type ComponentManifestGenerator } from '../types'; import type { StoryIndexGenerator } from './utils/StoryIndexGenerator'; import { doTelemetry } from './utils/doTelemetry'; import { getManagerBuilder, getPreviewBuilder } from './utils/get-builders'; @@ -135,6 +136,22 @@ export async function storybookDevServer(options: Options) { throw indexError; } + app.use('/components.json', async (req, res) => { + const componentManifestGenerator: ComponentManifestGenerator = await options.presets.apply( + 'componentManifestGenerator' + ); + const indexGenerator = await initializedStoryIndexGenerator; + const features = await options.presets.apply('features'); + if (features?.componentManifestGenerator && componentManifestGenerator && indexGenerator) { + const manifest = await componentManifestGenerator(indexGenerator); + res.setHeader('Content-Type', 'application/json'); + res.end(JSON.stringify(manifest)); + } else { + res.statusCode = 500; + res.end('No component manifest generator configured.'); + } + }); + // Now the preview has successfully started, we can count this as a 'dev' event. doTelemetry(app, core, initializedStoryIndexGenerator, options); diff --git a/code/core/src/types/modules/core-common.ts b/code/core/src/types/modules/core-common.ts index d31bf15209b9..2c68b5b1ee69 100644 --- a/code/core/src/types/modules/core-common.ts +++ b/code/core/src/types/modules/core-common.ts @@ -6,6 +6,7 @@ import type { Server as NetServer } from 'net'; import type { Options as TelejsonOptions } from 'telejson'; import type { PackageJson as PackageJsonFromTypeFest } from 'type-fest'; +import { type StoryIndexGenerator } from '../../core-server'; import type { Indexer, StoriesEntry } from './indexer'; /** ⚠️ This file contains internal WIP types they MUST NOT be exported outside this package for now! */ @@ -343,6 +344,16 @@ export type TagsOptions = Record>; * The interface for Storybook configuration used internally in presets The difference is that these * values are the raw values, AKA, not wrapped with `PresetValue<>` */ + +export interface ComponentManifest { + id: string; + examples: { name: string; snippet: string }[]; +} + +export type ComponentManifestGenerator = ( + storyIndexGenerator: StoryIndexGenerator +) => Promise>; + export interface StorybookConfigRaw { /** * Sets the addons you want to use with Storybook. @@ -356,6 +367,7 @@ export interface StorybookConfigRaw { */ addons?: Preset[]; core?: CoreConfig; + componentManifestGenerator?: ComponentManifestGenerator; staticDirs?: (DirectoryMapping | string)[]; logLevel?: string; features?: { @@ -453,6 +465,8 @@ export interface StorybookConfigRaw { developmentModeForBuild?: boolean; /** Only show input controls in Angular */ angularFilterNonInputControls?: boolean; + + componentManifestGenerator?: boolean; }; build?: TestBuildConfig; diff --git a/code/core/src/types/modules/indexer.ts b/code/core/src/types/modules/indexer.ts index 428ee186cd1b..4c091ca706a5 100644 --- a/code/core/src/types/modules/indexer.ts +++ b/code/core/src/types/modules/indexer.ts @@ -70,6 +70,7 @@ export interface BaseIndexEntry { title: ComponentTitle; tags?: Tag[]; importPath: Path; + componentPath?: Path; } export type StoryIndexEntry = BaseIndexEntry & { type: 'story'; diff --git a/code/core/src/csf-tools/generateCodeSnippet.test.tsx b/code/renderers/react/src/component-manifest/generateCodeSnippet.test.tsx similarity index 94% rename from code/core/src/csf-tools/generateCodeSnippet.test.tsx rename to code/renderers/react/src/component-manifest/generateCodeSnippet.test.tsx index af377c608c48..8dc42fa79c64 100644 --- a/code/core/src/csf-tools/generateCodeSnippet.test.tsx +++ b/code/renderers/react/src/component-manifest/generateCodeSnippet.test.tsx @@ -1,14 +1,25 @@ 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'; + import { dedent } from 'ts-dedent'; -import { recast } from '../babel'; -import { loadCsf } from './CsfFile'; -import { getAllCodeSnippets } from './generateCodeSnippet'; +import { getCodeSnippet } from './generateCodeSnippet'; function generateExample(code: string) { const csf = loadCsf(code, { makeTitle: (userTitle?: string) => userTitle ?? 'title' }).parse(); - return recast.print(getAllCodeSnippets(csf)).code; + const component = csf._meta?.component ?? 'Unknown'; + + const snippets = Object.values(csf._storyPaths) + .map((path: NodePath) => + getCodeSnippet(path, csf._metaNode ?? null, component) + ) + .filter(Boolean); + + return recast.print(t.program(snippets)).code; } function withCSF3(body: string) { @@ -108,7 +119,7 @@ test('Object', () => { string: 'string', number: 1, object: { an: 'object'}, - complexObjet: {...{a: 1}, an: 'object'}, + complexObject: {...{a: 1}, an: 'object'}, array: [1,2,3] } }; @@ -118,7 +129,7 @@ test('Object', () => { string="string" number={1} object={{ an: 'object'}} - complexObjet={{...{a: 1}, an: 'object'}} + complexObject={{...{a: 1}, an: 'object'}} array={[1,2,3]}>Click me;" `); }); diff --git a/code/core/src/csf-tools/generateCodeSnippet.ts b/code/renderers/react/src/component-manifest/generateCodeSnippet.ts similarity index 98% rename from code/core/src/csf-tools/generateCodeSnippet.ts rename to code/renderers/react/src/component-manifest/generateCodeSnippet.ts index 7845207bab61..33eeca02bd85 100644 --- a/code/core/src/csf-tools/generateCodeSnippet.ts +++ b/code/renderers/react/src/component-manifest/generateCodeSnippet.ts @@ -2,8 +2,6 @@ import { type NodePath, types as t } from 'storybook/internal/babel'; import invariant from 'tiny-invariant'; -import { type CsfFile } from './CsfFile'; - function buildInvalidSpread(entries: Array<[string, t.Node]>): t.JSXSpreadAttribute | null { if (entries.length === 0) { return null; @@ -242,18 +240,6 @@ export function getCodeSnippet( ]); } -export function getAllCodeSnippets(csf: CsfFile) { - const component = csf._meta?.component ?? 'Unknown'; - - const snippets = Object.values(csf._storyPaths) - .map((path: NodePath) => - getCodeSnippet(path, csf._metaNode ?? null, component) - ) - .filter(Boolean); - - return t.program(snippets); -} - const keyOf = (p: t.ObjectProperty): string | null => t.isIdentifier(p.key) ? p.key.name : t.isStringLiteral(p.key) ? p.key.value : null; diff --git a/code/renderers/react/src/preset.ts b/code/renderers/react/src/preset.ts index bc157cfd14a8..45543de029b8 100644 --- a/code/renderers/react/src/preset.ts +++ b/code/renderers/react/src/preset.ts @@ -1,13 +1,66 @@ +import { readFile } from 'node:fs/promises'; import { fileURLToPath } from 'node:url'; +import { recast } from 'storybook/internal/babel'; +import { loadCsf } from 'storybook/internal/csf-tools'; import type { PresetProperty } from 'storybook/internal/types'; +import { type ComponentManifestGenerator } from 'storybook/internal/types'; + +import path from 'pathe'; import { resolvePackageDir } from '../../../core/src/shared/utils/module'; +import { getCodeSnippet } from './component-manifest/generateCodeSnippet'; export const addons: PresetProperty<'addons'> = [ import.meta.resolve('@storybook/react-dom-shim/preset'), ]; +export const componentManifestGenerator = async () => { + return (async (storyIndexGenerator) => { + const index = await storyIndexGenerator.getIndex(); + const groupByComponentId = groupBy( + Object.values(index.entries).filter( + (entry) => entry.type === 'story' && entry.subtype === 'story' && entry.componentPath + ), + (it) => it.id.split('--')[0] + ); + const singleEntryPerComponent = Object.values(groupByComponentId).flatMap((group) => + group && group?.length > 0 ? [group[0]] : [] + ); + const components = await Promise.all( + singleEntryPerComponent.map(async (entry) => { + const code = await readFile(path.join(process.cwd(), entry.importPath), 'utf-8'); + const csf = loadCsf(code, { makeTitle: (title) => title }).parse(); + const component = csf._meta?.component ?? 'Unknown'; + return { + id: entry.id.split('--')[0], + examples: Object.entries(csf._storyPaths) + .map(([name, path]) => ({ + name, + snippet: recast.print(getCodeSnippet(path, csf._metaNode ?? null, component)).code, + })) + .filter(Boolean), + }; + }) + ); + + return Object.fromEntries(components.map((component) => [component.id, component])); + }) satisfies ComponentManifestGenerator; +}; + +// Object.groupBy polyfill +const groupBy = ( + items: T[], + keySelector: (item: T, index: number) => K +) => { + return items.reduce>>((acc = {}, item, index) => { + const key = keySelector(item, index); + acc[key] ??= []; + acc[key].push(item); + return acc; + }, {}); +}; + export const previewAnnotations: PresetProperty<'previewAnnotations'> = async ( input = [], options From 4ed385675591f20b5723050b5dc0f549bf9df420 Mon Sep 17 00:00:00 2001 From: Kasper Peulen Date: Wed, 15 Oct 2025 16:48:16 +0200 Subject: [PATCH 10/23] Fix types --- .../modules/preview-web/render/mount-utils.ts | 10 +++------- 1 file changed, 3 insertions(+), 7 deletions(-) diff --git a/code/core/src/preview-api/modules/preview-web/render/mount-utils.ts b/code/core/src/preview-api/modules/preview-web/render/mount-utils.ts index b6e85edd3b6c..4683ca743ac3 100644 --- a/code/core/src/preview-api/modules/preview-web/render/mount-utils.ts +++ b/code/core/src/preview-api/modules/preview-web/render/mount-utils.ts @@ -1,14 +1,10 @@ // Inspired by Vitest fixture implementation: // https://github.com/vitest-dev/vitest/blob/200a4349a2f85686bc7005dce686d9d1b48b84d2/packages/runner/src/fixture.ts -import type { PlayFunction } from 'storybook/internal/csf'; -import { type Renderer } from 'storybook/internal/types'; - -export function mountDestructured( - playFunction?: PlayFunction -): boolean { +export function mountDestructured(playFunction?: (...args: any[]) => any): boolean { return playFunction != null && getUsedProps(playFunction).includes('mount'); } -export function getUsedProps(fn: Function) { + +export function getUsedProps(fn: (...args: any[]) => any) { const match = fn.toString().match(/[^(]*\(([^)]*)/); if (!match) { From a449847c532326004e37f29f8b40e40ef218ed94 Mon Sep 17 00:00:00 2001 From: Kasper Peulen Date: Thu, 16 Oct 2025 12:01:28 +0200 Subject: [PATCH 11/23] Update code/core/src/core-server/dev-server.ts Co-authored-by: Jeppe Reinhold --- code/core/src/core-server/dev-server.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/code/core/src/core-server/dev-server.ts b/code/core/src/core-server/dev-server.ts index c0acb7981fa2..c35ce2e61648 100644 --- a/code/core/src/core-server/dev-server.ts +++ b/code/core/src/core-server/dev-server.ts @@ -136,7 +136,7 @@ export async function storybookDevServer(options: Options) { throw indexError; } - app.use('/components.json', async (req, res) => { + app.use('/mainfests/components.json', async (req, res) => { const componentManifestGenerator: ComponentManifestGenerator = await options.presets.apply( 'componentManifestGenerator' ); From 4ce972a265b09e884baa72ac4d06b367c0ffcc20 Mon Sep 17 00:00:00 2001 From: Kasper Peulen Date: Thu, 16 Oct 2025 12:01:39 +0200 Subject: [PATCH 12/23] Update code/core/src/core-server/dev-server.ts Co-authored-by: Jeppe Reinhold --- code/core/src/core-server/dev-server.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/code/core/src/core-server/dev-server.ts b/code/core/src/core-server/dev-server.ts index c35ce2e61648..08c0ac83b2a9 100644 --- a/code/core/src/core-server/dev-server.ts +++ b/code/core/src/core-server/dev-server.ts @@ -142,7 +142,7 @@ export async function storybookDevServer(options: Options) { ); const indexGenerator = await initializedStoryIndexGenerator; const features = await options.presets.apply('features'); - if (features?.componentManifestGenerator && componentManifestGenerator && indexGenerator) { + if (features?.experimental_componentsManifest && componentManifestGenerator && indexGenerator) { const manifest = await componentManifestGenerator(indexGenerator); res.setHeader('Content-Type', 'application/json'); res.end(JSON.stringify(manifest)); From 1599fac109aeb80cd42bd2d4872c729d6c8f370c Mon Sep 17 00:00:00 2001 From: Kasper Peulen Date: Thu, 16 Oct 2025 12:01:51 +0200 Subject: [PATCH 13/23] Update code/core/src/core-server/build-static.ts Co-authored-by: Jeppe Reinhold --- code/core/src/core-server/build-static.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/code/core/src/core-server/build-static.ts b/code/core/src/core-server/build-static.ts index 8f55ec416387..f85be753ee8f 100644 --- a/code/core/src/core-server/build-static.ts +++ b/code/core/src/core-server/build-static.ts @@ -167,7 +167,7 @@ export async function buildStaticStandalone(options: BuildStaticStandaloneOption const features = await presets.apply('features'); - if (features?.componentManifestGenerator) { + if (features?.experimental_componentsManifest) { const componentManifestGenerator: ComponentManifestGenerator = await presets.apply( 'componentManifestGenerator' ); From 1bcd00ba69a9c740a391b7efb5ae8ac3d60f32b2 Mon Sep 17 00:00:00 2001 From: Kasper Peulen Date: Thu, 16 Oct 2025 12:01:57 +0200 Subject: [PATCH 14/23] Update code/core/src/core-server/build-static.ts Co-authored-by: Jeppe Reinhold --- code/core/src/core-server/build-static.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/code/core/src/core-server/build-static.ts b/code/core/src/core-server/build-static.ts index f85be753ee8f..c7d5e60ff18f 100644 --- a/code/core/src/core-server/build-static.ts +++ b/code/core/src/core-server/build-static.ts @@ -174,7 +174,7 @@ export async function buildStaticStandalone(options: BuildStaticStandaloneOption const indexGenerator = await initializedStoryIndexGenerator; if (componentManifestGenerator && indexGenerator) { const manifests = await componentManifestGenerator(indexGenerator); - await writeFile(join(options.outputDir, 'components.json'), JSON.stringify(manifests)); + await writeFile(join(options.outputDir, 'mainfests', 'components.json'), JSON.stringify(manifests)); } } } From bf9a49a3af09749eb3977995c61ef14206c16e46 Mon Sep 17 00:00:00 2001 From: Kasper Peulen Date: Thu, 16 Oct 2025 12:19:49 +0200 Subject: [PATCH 15/23] Improve dev server logic --- code/core/src/core-server/dev-server.ts | 32 ++++++++++++++++--------- 1 file changed, 21 insertions(+), 11 deletions(-) diff --git a/code/core/src/core-server/dev-server.ts b/code/core/src/core-server/dev-server.ts index c0acb7981fa2..65dd7c316803 100644 --- a/code/core/src/core-server/dev-server.ts +++ b/code/core/src/core-server/dev-server.ts @@ -137,18 +137,28 @@ export async function storybookDevServer(options: Options) { } app.use('/components.json', async (req, res) => { - const componentManifestGenerator: ComponentManifestGenerator = await options.presets.apply( - 'componentManifestGenerator' - ); - const indexGenerator = await initializedStoryIndexGenerator; - const features = await options.presets.apply('features'); - if (features?.componentManifestGenerator && componentManifestGenerator && indexGenerator) { - const manifest = await componentManifestGenerator(indexGenerator); - res.setHeader('Content-Type', 'application/json'); - res.end(JSON.stringify(manifest)); - } else { - res.statusCode = 500; + try { + const features = await options.presets.apply('features'); + if (!features?.componentManifestGenerator) { + const componentManifestGenerator: ComponentManifestGenerator = await options.presets.apply( + 'componentManifestGenerator' + ); + const indexGenerator = await initializedStoryIndexGenerator; + if (componentManifestGenerator && indexGenerator) { + const manifest = await componentManifestGenerator(indexGenerator); + res.setHeader('Content-Type', 'application/json'); + res.end(JSON.stringify(manifest)); + return; + } + } + res.statusCode = 400; res.end('No component manifest generator configured.'); + return; + } catch (e) { + console.error(e); + res.statusCode = 500; + res.end(e instanceof Error ? e.toString() : String(e)); + return; } }); From c0df22c80481888522ec34d20460719eaab91f28 Mon Sep 17 00:00:00 2001 From: Kasper Peulen Date: Thu, 16 Oct 2025 12:40:36 +0200 Subject: [PATCH 16/23] Add type --- code/core/src/types/modules/core-common.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/code/core/src/types/modules/core-common.ts b/code/core/src/types/modules/core-common.ts index 2c68b5b1ee69..fb652a7bc5fd 100644 --- a/code/core/src/types/modules/core-common.ts +++ b/code/core/src/types/modules/core-common.ts @@ -466,7 +466,7 @@ export interface StorybookConfigRaw { /** Only show input controls in Angular */ angularFilterNonInputControls?: boolean; - componentManifestGenerator?: boolean; + experimental_componentsManifest?: boolean; }; build?: TestBuildConfig; From 25b197ea86a242abc07f353ec55020f8e8627249 Mon Sep 17 00:00:00 2001 From: Kasper Peulen Date: Thu, 16 Oct 2025 13:23:28 +0200 Subject: [PATCH 17/23] Fix lint --- code/core/src/core-server/build-static.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/code/core/src/core-server/build-static.ts b/code/core/src/core-server/build-static.ts index c7d5e60ff18f..76057371e794 100644 --- a/code/core/src/core-server/build-static.ts +++ b/code/core/src/core-server/build-static.ts @@ -174,7 +174,10 @@ export async function buildStaticStandalone(options: BuildStaticStandaloneOption const indexGenerator = await initializedStoryIndexGenerator; if (componentManifestGenerator && indexGenerator) { const manifests = await componentManifestGenerator(indexGenerator); - await writeFile(join(options.outputDir, 'mainfests', 'components.json'), JSON.stringify(manifests)); + await writeFile( + join(options.outputDir, 'mainfests', 'components.json'), + JSON.stringify(manifests) + ); } } } From 18c671e02b4e0bab1de618bf499bb3d3a5330855 Mon Sep 17 00:00:00 2001 From: Kasper Peulen Date: Thu, 16 Oct 2025 13:27:49 +0200 Subject: [PATCH 18/23] Use node logger --- code/core/src/core-server/dev-server.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/code/core/src/core-server/dev-server.ts b/code/core/src/core-server/dev-server.ts index a09a85688add..08607d7a5cee 100644 --- a/code/core/src/core-server/dev-server.ts +++ b/code/core/src/core-server/dev-server.ts @@ -155,7 +155,7 @@ export async function storybookDevServer(options: Options) { res.end('No component manifest generator configured.'); return; } catch (e) { - console.error(e); + logger.error(e); res.statusCode = 500; res.end(e instanceof Error ? e.toString() : String(e)); return; From 11ac91459fab20376b08a7b960c563994843b85b Mon Sep 17 00:00:00 2001 From: Kasper Peulen Date: Thu, 16 Oct 2025 13:30:26 +0200 Subject: [PATCH 19/23] Fix --- code/core/src/core-server/dev-server.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/code/core/src/core-server/dev-server.ts b/code/core/src/core-server/dev-server.ts index 08607d7a5cee..388adbe53167 100644 --- a/code/core/src/core-server/dev-server.ts +++ b/code/core/src/core-server/dev-server.ts @@ -155,7 +155,7 @@ export async function storybookDevServer(options: Options) { res.end('No component manifest generator configured.'); return; } catch (e) { - logger.error(e); + logger.error(e instanceof Error ? e : String(e)); res.statusCode = 500; res.end(e instanceof Error ? e.toString() : String(e)); return; From 98f121b1c35aab701e746fe9d736dbc62afd5b5e Mon Sep 17 00:00:00 2001 From: Kasper Peulen Date: Thu, 16 Oct 2025 14:32:47 +0200 Subject: [PATCH 20/23] Add component name and tests --- code/core/src/types/modules/core-common.ts | 1 + .../renderers/react/__mocks__/fs/promises.cjs | 3 + .../src/component-manifest/generator.test.ts | 244 ++++++++++++++++++ .../react/src/component-manifest/generator.ts | 59 +++++ code/renderers/react/src/preset.ts | 53 +--- 5 files changed, 308 insertions(+), 52 deletions(-) create mode 100644 code/renderers/react/__mocks__/fs/promises.cjs create mode 100644 code/renderers/react/src/component-manifest/generator.test.ts create mode 100644 code/renderers/react/src/component-manifest/generator.ts diff --git a/code/core/src/types/modules/core-common.ts b/code/core/src/types/modules/core-common.ts index fb652a7bc5fd..3fd51cc0b87a 100644 --- a/code/core/src/types/modules/core-common.ts +++ b/code/core/src/types/modules/core-common.ts @@ -347,6 +347,7 @@ export type TagsOptions = Record>; export interface ComponentManifest { id: string; + name?: string; examples: { name: string; snippet: string }[]; } diff --git a/code/renderers/react/__mocks__/fs/promises.cjs b/code/renderers/react/__mocks__/fs/promises.cjs new file mode 100644 index 000000000000..bccdebb932d9 --- /dev/null +++ b/code/renderers/react/__mocks__/fs/promises.cjs @@ -0,0 +1,3 @@ +const { fs } = require('memfs'); + +module.exports = fs.promises; diff --git a/code/renderers/react/src/component-manifest/generator.test.ts b/code/renderers/react/src/component-manifest/generator.test.ts new file mode 100644 index 000000000000..7a3bd9add51b --- /dev/null +++ b/code/renderers/react/src/component-manifest/generator.test.ts @@ -0,0 +1,244 @@ +import { beforeEach, expect, test, vi } from 'vitest'; + +import { type StoryIndexGenerator } from 'storybook/internal/core-server'; + +import { vol } from 'memfs'; +import { dedent } from 'ts-dedent'; + +import { componentManifestGenerator } from './generator'; + +vi.mock('node:fs/promises'); + +// Use the provided indexJson from this file +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', + }, + 'example-button--secondary': { + type: 'story', + subtype: 'story', + id: 'example-button--secondary', + name: 'Secondary', + title: 'Example/Button', + importPath: './src/stories/Button.stories.ts', + componentPath: './src/stories/Button.tsx', + tags: ['dev', 'test', 'vitest', 'autodocs'], + exportName: 'Secondary', + }, + 'example-button--large': { + type: 'story', + subtype: 'story', + id: 'example-button--large', + name: 'Large', + title: 'Example/Button', + importPath: './src/stories/Button.stories.ts', + componentPath: './src/stories/Button.tsx', + tags: ['dev', 'test', 'vitest', 'autodocs'], + exportName: 'Large', + }, + 'example-button--small': { + type: 'story', + subtype: 'story', + id: 'example-button--small', + name: 'Small', + title: 'Example/Button', + importPath: './src/stories/Button.stories.ts', + componentPath: './src/stories/Button.tsx', + tags: ['dev', 'test', 'vitest', 'autodocs'], + exportName: 'Small', + }, + 'example-header--docs': { + id: 'example-header--docs', + title: 'Example/Header', + name: 'Docs', + importPath: './src/stories/Header.stories.ts', + type: 'docs', + tags: ['dev', 'test', 'vitest', 'autodocs'], + storiesImports: [], + }, + 'example-header--logged-in': { + type: 'story', + subtype: 'story', + id: 'example-header--logged-in', + name: 'Logged In', + title: 'Example/Header', + importPath: './src/stories/Header.stories.ts', + componentPath: './src/stories/Header.tsx', + tags: ['dev', 'test', 'vitest', 'autodocs'], + exportName: 'LoggedIn', + }, + 'example-header--logged-out': { + type: 'story', + subtype: 'story', + id: 'example-header--logged-out', + name: 'Logged Out', + title: 'Example/Header', + importPath: './src/stories/Header.stories.ts', + componentPath: './src/stories/Header.tsx', + tags: ['dev', 'test', 'vitest', 'autodocs'], + exportName: 'LoggedOut', + }, + }, +}; + +beforeEach(() => { + vol.fromJSON({ + ['./src/stories/Button.stories.ts']: dedent` + import type { Meta, StoryObj } from '@storybook/react'; + import { fn } from 'storybook/test'; + import { Button } from './Button'; + + const meta = { + component: Button, + args: { onClick: fn() }, + } satisfies Meta; + export default meta; + type Story = StoryObj; + + export const Primary: Story = { args: { primary: true, label: 'Button' } }; + export const Secondary: Story = { args: { label: 'Button' } }; + export const Large: Story = { args: { size: 'large', label: 'Button' } }; + export const Small: Story = { args: { size: 'small', label: 'Button' } };`, + ['./src/stories/Button.tsx']: dedent` + import React from 'react'; + export interface ButtonProps { + primary?: boolean; + backgroundColor?: string; + size?: 'small' | 'medium' | 'large'; + label: string; + onClick?: () => void; + } + + /** Primary UI component for user interaction */ + export const Button = ({ + primary = false, + size = 'medium', + backgroundColor, + label, + ...props + }: ButtonProps) => { + const mode = primary ? 'storybook-button--primary' : 'storybook-button--secondary'; + return ( + + ); + };`, + ['./src/stories/Header.stories.ts']: dedent` + import type { Meta, StoryObj } from '@storybook/react'; + import { fn } from 'storybook/test'; + import { Header } from './Header'; + const meta = { + component: Header, + args: { + onLogin: fn(), + onLogout: fn(), + onCreateAccount: fn(), + } + } satisfies Meta; + export default meta; + type Story = StoryObj; + export const LoggedIn: Story = { args: { user: { name: 'Jane Doe' } } }; + export const LoggedOut: Story = {}; + `, + ['./src/stories/Header.tsx']: dedent` + import { Button } from './Button'; + + export interface HeaderProps { + user?: User; + onLogin?: () => void; + onLogout?: () => void; + onCreateAccount?: () => void; + } + + export const Header = ({ user, onLogin, onLogout, onCreateAccount }: HeaderProps) => ( +
+
+
+ {user ? ( + <> + + Welcome, {user.name}! + +
+
+
+ );`, + }); + return () => vol.reset(); +}); + +test('generates correct id, name and examples ', async () => { + const generator = await componentManifestGenerator(); + const manifest = await generator({ + getIndex: async () => indexJson, + } as unknown as StoryIndexGenerator); + + expect(manifest).toMatchInlineSnapshot(` + { + "example-button": { + "examples": [ + { + "name": "Primary", + "snippet": "const Primary = () => ;", + }, + { + "name": "Secondary", + "snippet": "const Secondary = () => ;", + }, + { + "name": "Large", + "snippet": "const Large = () => ;", + }, + { + "name": "Small", + "snippet": "const Small = () => ;", + }, + ], + "id": "example-button", + "name": "Button", + }, + "example-header": { + "examples": [ + { + "name": "LoggedIn", + "snippet": "const LoggedIn = () =>
;", + }, + { + "name": "LoggedOut", + "snippet": "const LoggedOut = () =>
;", + }, + ], + "id": "example-header", + "name": "Header", + }, + } + `); +}); diff --git a/code/renderers/react/src/component-manifest/generator.ts b/code/renderers/react/src/component-manifest/generator.ts new file mode 100644 index 000000000000..5313cc85d3dd --- /dev/null +++ b/code/renderers/react/src/component-manifest/generator.ts @@ -0,0 +1,59 @@ +import { readFile } from 'node:fs/promises'; + +import { recast } from 'storybook/internal/babel'; +import { loadCsf } from 'storybook/internal/csf-tools'; +import { type ComponentManifestGenerator } from 'storybook/internal/types'; + +import path from 'pathe'; + +import { getCodeSnippet } from './generateCodeSnippet'; + +export const componentManifestGenerator = async () => { + return (async (storyIndexGenerator) => { + const index = await storyIndexGenerator.getIndex(); + const groupByComponentId = groupBy( + Object.values(index.entries).filter( + (entry) => entry.type === 'story' && entry.subtype === 'story' && entry.componentPath + ), + (it) => it.id.split('--')[0] + ); + const singleEntryPerComponent = Object.values(groupByComponentId).flatMap((group) => + group && group?.length > 0 ? [group[0]] : [] + ); + const components = await Promise.all( + singleEntryPerComponent.map(async (entry) => { + const code = await readFile(path.join(process.cwd(), entry.importPath), 'utf-8'); + const csf = loadCsf(code, { makeTitle: (title) => title ?? 'No title' }).parse(); + const componentName = csf._meta?.component; + return { + id: entry.id.split('--')[0], + name: componentName, + examples: !componentName + ? [] + : Object.entries(csf._storyPaths) + .map(([name, path]) => ({ + name, + snippet: recast.print(getCodeSnippet(path, csf._metaNode ?? null, componentName)) + .code, + })) + .filter(Boolean), + }; + }) + ); + + return Object.fromEntries(components.map((component) => [component.id, component])); + }) satisfies ComponentManifestGenerator; +}; + +// Object.groupBy polyfill +const groupBy = ( + items: T[], + keySelector: (item: T, index: number) => K +) => { + return items.reduce>>((acc = {}, item, index) => { + const key = keySelector(item, index); + acc[key] ??= []; + acc[key].push(item); + return acc; + }, {}); +}; diff --git a/code/renderers/react/src/preset.ts b/code/renderers/react/src/preset.ts index 45543de029b8..4586681cdae1 100644 --- a/code/renderers/react/src/preset.ts +++ b/code/renderers/react/src/preset.ts @@ -1,65 +1,14 @@ -import { readFile } from 'node:fs/promises'; import { fileURLToPath } from 'node:url'; -import { recast } from 'storybook/internal/babel'; -import { loadCsf } from 'storybook/internal/csf-tools'; import type { PresetProperty } from 'storybook/internal/types'; -import { type ComponentManifestGenerator } from 'storybook/internal/types'; - -import path from 'pathe'; import { resolvePackageDir } from '../../../core/src/shared/utils/module'; -import { getCodeSnippet } from './component-manifest/generateCodeSnippet'; export const addons: PresetProperty<'addons'> = [ import.meta.resolve('@storybook/react-dom-shim/preset'), ]; -export const componentManifestGenerator = async () => { - return (async (storyIndexGenerator) => { - const index = await storyIndexGenerator.getIndex(); - const groupByComponentId = groupBy( - Object.values(index.entries).filter( - (entry) => entry.type === 'story' && entry.subtype === 'story' && entry.componentPath - ), - (it) => it.id.split('--')[0] - ); - const singleEntryPerComponent = Object.values(groupByComponentId).flatMap((group) => - group && group?.length > 0 ? [group[0]] : [] - ); - const components = await Promise.all( - singleEntryPerComponent.map(async (entry) => { - const code = await readFile(path.join(process.cwd(), entry.importPath), 'utf-8'); - const csf = loadCsf(code, { makeTitle: (title) => title }).parse(); - const component = csf._meta?.component ?? 'Unknown'; - return { - id: entry.id.split('--')[0], - examples: Object.entries(csf._storyPaths) - .map(([name, path]) => ({ - name, - snippet: recast.print(getCodeSnippet(path, csf._metaNode ?? null, component)).code, - })) - .filter(Boolean), - }; - }) - ); - - return Object.fromEntries(components.map((component) => [component.id, component])); - }) satisfies ComponentManifestGenerator; -}; - -// Object.groupBy polyfill -const groupBy = ( - items: T[], - keySelector: (item: T, index: number) => K -) => { - return items.reduce>>((acc = {}, item, index) => { - const key = keySelector(item, index); - acc[key] ??= []; - acc[key].push(item); - return acc; - }, {}); -}; +export { componentManifestGenerator } from './component-manifest/generator'; export const previewAnnotations: PresetProperty<'previewAnnotations'> = async ( input = [], From bd91ab6226e50d61b2807200a6b522fcde10d910 Mon Sep 17 00:00:00 2001 From: Kasper Peulen Date: Thu, 16 Oct 2025 14:36:00 +0200 Subject: [PATCH 21/23] Fix typos --- code/core/src/core-server/build-static.ts | 3 ++- code/core/src/core-server/dev-server.ts | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/code/core/src/core-server/build-static.ts b/code/core/src/core-server/build-static.ts index 76057371e794..4b4058550ddc 100644 --- a/code/core/src/core-server/build-static.ts +++ b/code/core/src/core-server/build-static.ts @@ -174,8 +174,9 @@ export async function buildStaticStandalone(options: BuildStaticStandaloneOption const indexGenerator = await initializedStoryIndexGenerator; if (componentManifestGenerator && indexGenerator) { const manifests = await componentManifestGenerator(indexGenerator); + await mkdir(join(options.outputDir, 'manifests'), { recursive: true }); await writeFile( - join(options.outputDir, 'mainfests', 'components.json'), + join(options.outputDir, 'manifests', 'components.json'), JSON.stringify(manifests) ); } diff --git a/code/core/src/core-server/dev-server.ts b/code/core/src/core-server/dev-server.ts index 388adbe53167..56fba2aaff5b 100644 --- a/code/core/src/core-server/dev-server.ts +++ b/code/core/src/core-server/dev-server.ts @@ -136,7 +136,7 @@ export async function storybookDevServer(options: Options) { throw indexError; } - app.use('/mainfests/components.json', async (req, res) => { + app.use('/manifests/components.json', async (req, res) => { try { const features = await options.presets.apply('features'); if (!features?.experimental_componentsManifest) { From b865df95fef55cb2fae47eef1f3105342b4431ce Mon Sep 17 00:00:00 2001 From: Kasper Peulen Date: Thu, 16 Oct 2025 15:23:56 +0200 Subject: [PATCH 22/23] Add component description to manifest --- .../docgen-handlers/actualNameHandler.ts | 56 ++++++++++ .../src/component-manifest/docgen-resolver.ts | 75 +++++++++++++ .../src/component-manifest/generator.test.ts | 6 +- .../react/src/component-manifest/generator.ts | 18 ++- .../src/component-manifest/react-docgen.ts | 103 ++++++++++++++++++ 5 files changed, 255 insertions(+), 3 deletions(-) create mode 100644 code/renderers/react/src/component-manifest/docgen-handlers/actualNameHandler.ts create mode 100644 code/renderers/react/src/component-manifest/docgen-resolver.ts create mode 100644 code/renderers/react/src/component-manifest/react-docgen.ts diff --git a/code/renderers/react/src/component-manifest/docgen-handlers/actualNameHandler.ts b/code/renderers/react/src/component-manifest/docgen-handlers/actualNameHandler.ts new file mode 100644 index 000000000000..6b91fa7fcb1d --- /dev/null +++ b/code/renderers/react/src/component-manifest/docgen-handlers/actualNameHandler.ts @@ -0,0 +1,56 @@ +/** + * This is heavily based on the react-docgen `displayNameHandler` + * (https://github.com/reactjs/react-docgen/blob/26c90c0dd105bf83499a83826f2a6ff7a724620d/src/handlers/displayNameHandler.ts) + * but instead defines an `actualName` property on the generated docs that is taken first from the + * component's actual name. This addresses an issue where the name that the generated docs are + * stored under is incorrectly named with the `displayName` and not the component's actual name. + * + * This is inspired by `actualNameHandler` from + * https://github.com/storybookjs/babel-plugin-react-docgen, but is modified directly from + * displayNameHandler, using the same approach as babel-plugin-react-docgen. + */ +import type { Handler, NodePath, babelTypes as t } from 'react-docgen'; +import { utils } from 'react-docgen'; + +const { getNameOrValue, isReactForwardRefCall } = utils; + +const actualNameHandler: Handler = function actualNameHandler(documentation, componentDefinition) { + documentation.set('definedInFile', componentDefinition.hub.file.opts.filename); + + if ( + (componentDefinition.isClassDeclaration() || componentDefinition.isFunctionDeclaration()) && + componentDefinition.has('id') + ) { + documentation.set( + 'actualName', + getNameOrValue(componentDefinition.get('id') as NodePath) + ); + } else if ( + componentDefinition.isArrowFunctionExpression() || + componentDefinition.isFunctionExpression() || + isReactForwardRefCall(componentDefinition) + ) { + let currentPath: NodePath = componentDefinition; + + while (currentPath.parentPath) { + if (currentPath.parentPath.isVariableDeclarator()) { + documentation.set('actualName', getNameOrValue(currentPath.parentPath.get('id'))); + return; + } + if (currentPath.parentPath.isAssignmentExpression()) { + const leftPath = currentPath.parentPath.get('left'); + + if (leftPath.isIdentifier() || leftPath.isLiteral()) { + documentation.set('actualName', getNameOrValue(leftPath)); + return; + } + } + + currentPath = currentPath.parentPath; + } + // Could not find an actual name + documentation.set('actualName', ''); + } +}; + +export default actualNameHandler; diff --git a/code/renderers/react/src/component-manifest/docgen-resolver.ts b/code/renderers/react/src/component-manifest/docgen-resolver.ts new file mode 100644 index 000000000000..f4ae37407c09 --- /dev/null +++ b/code/renderers/react/src/component-manifest/docgen-resolver.ts @@ -0,0 +1,75 @@ +import { extname } from 'node:path'; + +import resolve from 'resolve'; + +export class ReactDocgenResolveError extends Error { + // the magic string that react-docgen uses to check if a module is ignored + readonly code = 'MODULE_NOT_FOUND'; + + constructor(filename: string) { + super(`'${filename}' was ignored by react-docgen.`); + } +} + +/* The below code was copied from: + * https://github.com/reactjs/react-docgen/blob/df2daa8b6f0af693ecc3c4dc49f2246f60552bcb/packages/react-docgen/src/importer/makeFsImporter.ts#L14-L63 + * because it wasn't exported from the react-docgen package. + * watch out: when updating this code, also update the code in code/presets/react-webpack/src/loaders/docgen-resolver.ts + */ + +// These extensions are sorted by priority +// resolve() will check for files in the order these extensions are sorted +export const RESOLVE_EXTENSIONS = [ + '.js', + '.cts', // These were originally not in the code, I added them + '.mts', // These were originally not in the code, I added them + '.ctsx', // These were originally not in the code, I added them + '.mtsx', // These were originally not in the code, I added them + '.ts', + '.tsx', + '.mjs', + '.cjs', + '.mts', + '.cts', + '.jsx', +]; + +export function defaultLookupModule(filename: string, basedir: string): string { + const resolveOptions = { + basedir, + extensions: RESOLVE_EXTENSIONS, + // we do not need to check core modules as we cannot import them anyway + includeCoreModules: false, + }; + + try { + return resolve.sync(filename, resolveOptions); + } catch (error) { + const ext = extname(filename); + let newFilename: string; + + // if we try to import a JavaScript file it might be that we are actually pointing to + // a TypeScript file. This can happen in ES modules as TypeScript requires to import other + // TypeScript files with .js extensions + // https://www.typescriptlang.org/docs/handbook/esm-node.html#type-in-packagejson-and-new-extensions + switch (ext) { + case '.js': + case '.mjs': + case '.cjs': + newFilename = `${filename.slice(0, -2)}ts`; + break; + + case '.jsx': + newFilename = `${filename.slice(0, -3)}tsx`; + break; + default: + throw error; + } + + return resolve.sync(newFilename, { + ...resolveOptions, + // we already know that there is an extension at this point, so no need to check other extensions + extensions: [], + }); + } +} diff --git a/code/renderers/react/src/component-manifest/generator.test.ts b/code/renderers/react/src/component-manifest/generator.test.ts index 7a3bd9add51b..4227ae8c548e 100644 --- a/code/renderers/react/src/component-manifest/generator.test.ts +++ b/code/renderers/react/src/component-manifest/generator.test.ts @@ -143,6 +143,8 @@ beforeEach(() => { import type { Meta, StoryObj } from '@storybook/react'; import { fn } from 'storybook/test'; import { Header } from './Header'; + + /** Meta description */ const meta = { component: Header, args: { @@ -191,7 +193,7 @@ beforeEach(() => { return () => vol.reset(); }); -test('generates correct id, name and examples ', async () => { +test('componentManifestGenerator generates correct id, name, description and examples ', async () => { const generator = await componentManifestGenerator(); const manifest = await generator({ getIndex: async () => indexJson, @@ -200,6 +202,7 @@ test('generates correct id, name and examples ', async () => { expect(manifest).toMatchInlineSnapshot(` { "example-button": { + "description": "Primary UI component for user interaction", "examples": [ { "name": "Primary", @@ -222,6 +225,7 @@ test('generates correct id, name and examples ', async () => { "name": "Button", }, "example-header": { + "description": "Meta description", "examples": [ { "name": "LoggedIn", diff --git a/code/renderers/react/src/component-manifest/generator.ts b/code/renderers/react/src/component-manifest/generator.ts index 5313cc85d3dd..3f0936a51e9f 100644 --- a/code/renderers/react/src/component-manifest/generator.ts +++ b/code/renderers/react/src/component-manifest/generator.ts @@ -2,11 +2,13 @@ import { readFile } from 'node:fs/promises'; import { recast } from 'storybook/internal/babel'; import { loadCsf } from 'storybook/internal/csf-tools'; +import { extractDescription } from 'storybook/internal/csf-tools'; import { type ComponentManifestGenerator } from 'storybook/internal/types'; import path from 'pathe'; import { getCodeSnippet } from './generateCodeSnippet'; +import { getMatchingDocgen, parseWithReactDocgen } from './react-docgen'; export const componentManifestGenerator = async () => { return (async (storyIndexGenerator) => { @@ -22,12 +24,24 @@ export const componentManifestGenerator = async () => { ); const components = await Promise.all( singleEntryPerComponent.map(async (entry) => { - const code = await readFile(path.join(process.cwd(), entry.importPath), 'utf-8'); - const csf = loadCsf(code, { makeTitle: (title) => title ?? 'No title' }).parse(); + const storyFile = await readFile(path.join(process.cwd(), entry.importPath), 'utf-8'); + const componentFile = await readFile( + path.join(process.cwd(), entry.componentPath!), + 'utf-8' + ); + const csf = loadCsf(storyFile, { makeTitle: (title) => title ?? 'No title' }).parse(); const componentName = csf._meta?.component; + const docgens = await parseWithReactDocgen({ + code: componentFile, + filename: path.join(process.cwd(), entry.importPath), + }); + const docgen = getMatchingDocgen(docgens, componentName); + + const metaDescription = extractDescription(csf._metaStatement); return { id: entry.id.split('--')[0], name: componentName, + description: metaDescription || docgen?.description, examples: !componentName ? [] : Object.entries(csf._storyPaths) diff --git a/code/renderers/react/src/component-manifest/react-docgen.ts b/code/renderers/react/src/component-manifest/react-docgen.ts new file mode 100644 index 000000000000..66a0333dc47f --- /dev/null +++ b/code/renderers/react/src/component-manifest/react-docgen.ts @@ -0,0 +1,103 @@ +import { existsSync } from 'node:fs'; +import { sep } from 'node:path'; + +import { getProjectRoot } from 'storybook/internal/common'; +import { logger } from 'storybook/internal/node-logger'; + +import * as find from 'empathic/find'; +import type { Documentation } from 'react-docgen'; +import { + builtinHandlers as docgenHandlers, + builtinResolvers as docgenResolver, + makeFsImporter, + parse, +} from 'react-docgen'; +import * as TsconfigPaths from 'tsconfig-paths'; + +import actualNameHandler from './docgen-handlers/actualNameHandler'; +import { + RESOLVE_EXTENSIONS, + ReactDocgenResolveError, + defaultLookupModule, +} from './docgen-resolver'; + +type DocObj = Documentation & { actualName: string; definedInFile: string }; + +// TODO: None of these are able to be overridden, so `default` is aspirational here. +const defaultHandlers = Object.values(docgenHandlers).map((handler) => handler); +const defaultResolver = new docgenResolver.FindExportedDefinitionsResolver(); +const handlers = [...defaultHandlers, actualNameHandler]; + +export function getMatchingDocgen(docgens: DocObj[], componentName: string | undefined) { + if (docgens.length === 1) { + return docgens[0]; + } + if (!componentName) { + return; + } + const docgen = docgens.find( + (docgen) => docgen.displayName === componentName || docgen.actualName === componentName + ); + if (docgen) { + return docgen; + } +} + +export async function parseWithReactDocgen({ code, filename }: { code: string; filename: string }) { + const tsconfigPath = find.up('tsconfig.json', { cwd: process.cwd(), last: getProjectRoot() }); + const tsconfig = TsconfigPaths.loadConfig(tsconfigPath); + + let matchPath: TsconfigPaths.MatchPath | undefined; + + if (tsconfig.resultType === 'success') { + logger.info('Using tsconfig paths for react-docgen'); + matchPath = TsconfigPaths.createMatchPath(tsconfig.absoluteBaseUrl, tsconfig.paths, [ + 'browser', + 'module', + 'main', + ]); + } + + try { + return parse(code, { + resolver: defaultResolver, + handlers, + importer: getReactDocgenImporter(matchPath), + filename, + }) as DocObj[]; + } catch (e) { + return []; + } +} + +export function getReactDocgenImporter(matchPath: TsconfigPaths.MatchPath | undefined) { + return makeFsImporter((filename, basedir) => { + const mappedFilenameByPaths = (() => { + if (matchPath) { + const match = matchPath(filename); + return match || filename; + } else { + return filename; + } + })(); + + const result = defaultLookupModule(mappedFilenameByPaths, basedir); + + if (result.includes(`${sep}react-native${sep}index.js`)) { + const replaced = result.replace( + `${sep}react-native${sep}index.js`, + `${sep}react-native-web${sep}dist${sep}index.js` + ); + if (existsSync(replaced)) { + if (RESOLVE_EXTENSIONS.find((ext) => result.endsWith(ext))) { + return replaced; + } + } + } + if (RESOLVE_EXTENSIONS.find((ext) => result.endsWith(ext))) { + return result; + } + + throw new ReactDocgenResolveError(filename); + }); +} From 51cdfc7ecf29278186cbfb78c759f51deffd951c Mon Sep 17 00:00:00 2001 From: Kasper Peulen Date: Thu, 16 Oct 2025 16:43:20 +0200 Subject: [PATCH 23/23] Extract jsdoc tags --- code/renderers/react/package.json | 1 + .../src/component-manifest/generator.test.ts | 21 ++++++++++-- .../react/src/component-manifest/generator.ts | 22 +++++------- .../src/component-manifest/jsdoc-tags.test.ts | 34 +++++++++++++++++++ .../src/component-manifest/jsdoc-tags.ts | 24 +++++++++++++ .../react/src/component-manifest/utils.ts | 13 +++++++ code/yarn.lock | 1 + 7 files changed, 100 insertions(+), 16 deletions(-) create mode 100644 code/renderers/react/src/component-manifest/jsdoc-tags.test.ts create mode 100644 code/renderers/react/src/component-manifest/jsdoc-tags.ts create mode 100644 code/renderers/react/src/component-manifest/utils.ts diff --git a/code/renderers/react/package.json b/code/renderers/react/package.json index c71b26976c3e..a8a08e8f2453 100644 --- a/code/renderers/react/package.json +++ b/code/renderers/react/package.json @@ -66,6 +66,7 @@ "acorn-jsx": "^5.3.1", "acorn-walk": "^7.2.0", "babel-plugin-react-docgen": "^4.2.1", + "comment-parser": "^1.4.1", "es-toolkit": "^1.36.0", "escodegen": "^2.1.0", "expect-type": "^0.15.0", diff --git a/code/renderers/react/src/component-manifest/generator.test.ts b/code/renderers/react/src/component-manifest/generator.test.ts index 4227ae8c548e..18c94d11866c 100644 --- a/code/renderers/react/src/component-manifest/generator.test.ts +++ b/code/renderers/react/src/component-manifest/generator.test.ts @@ -144,7 +144,11 @@ beforeEach(() => { import { fn } from 'storybook/test'; import { Header } from './Header'; - /** Meta description */ + /** + * Description from meta and very long. + * @summary Component summary + * @import import { Header } from '@design-system/components/Header'; + */ const meta = { component: Header, args: { @@ -222,10 +226,13 @@ test('componentManifestGenerator generates correct id, name, description and exa }, ], "id": "example-button", + "import": undefined, + "jsdocTag": {}, "name": "Button", + "summary": undefined, }, "example-header": { - "description": "Meta description", + "description": "Description from meta and very long. ", "examples": [ { "name": "LoggedIn", @@ -241,7 +248,17 @@ test('componentManifestGenerator generates correct id, name, description and exa }, ], "id": "example-header", + "import": "import { Header } from '@design-system/components/Header';", + "jsdocTag": { + "import": [ + "import { Header } from '@design-system/components/Header';", + ], + "summary": [ + "Component summary", + ], + }, "name": "Header", + "summary": "Component summary", }, } `); diff --git a/code/renderers/react/src/component-manifest/generator.ts b/code/renderers/react/src/component-manifest/generator.ts index 3f0936a51e9f..a821666fcdb9 100644 --- a/code/renderers/react/src/component-manifest/generator.ts +++ b/code/renderers/react/src/component-manifest/generator.ts @@ -8,7 +8,9 @@ import { type ComponentManifestGenerator } from 'storybook/internal/types'; import path from 'pathe'; import { getCodeSnippet } from './generateCodeSnippet'; +import { extractJSDocTags, removeTags } from './jsdoc-tags'; import { getMatchingDocgen, parseWithReactDocgen } from './react-docgen'; +import { groupBy } from './utils'; export const componentManifestGenerator = async () => { return (async (storyIndexGenerator) => { @@ -38,10 +40,15 @@ export const componentManifestGenerator = async () => { const docgen = getMatchingDocgen(docgens, componentName); const metaDescription = extractDescription(csf._metaStatement); + const description = metaDescription || docgen?.description; + const tags = description ? extractJSDocTags(description) : {}; return { id: entry.id.split('--')[0], name: componentName, - description: metaDescription || docgen?.description, + description: description ? removeTags(description) : undefined, + jsdocTag: tags, + summary: tags.summary ? tags.summary[0] : undefined, + import: tags.import ? tags.import[0] : undefined, examples: !componentName ? [] : Object.entries(csf._storyPaths) @@ -58,16 +65,3 @@ export const componentManifestGenerator = async () => { return Object.fromEntries(components.map((component) => [component.id, component])); }) satisfies ComponentManifestGenerator; }; - -// Object.groupBy polyfill -const groupBy = ( - items: T[], - keySelector: (item: T, index: number) => K -) => { - return items.reduce>>((acc = {}, item, index) => { - const key = keySelector(item, index); - acc[key] ??= []; - acc[key].push(item); - return acc; - }, {}); -}; diff --git a/code/renderers/react/src/component-manifest/jsdoc-tags.test.ts b/code/renderers/react/src/component-manifest/jsdoc-tags.test.ts new file mode 100644 index 000000000000..b0c00661fb07 --- /dev/null +++ b/code/renderers/react/src/component-manifest/jsdoc-tags.test.ts @@ -0,0 +1,34 @@ +import { expect, it } from 'vitest'; + +import { dedent } from 'ts-dedent'; + +import { extractJSDocTags } from './jsdoc-tags'; + +it('should extract @summary tag', () => { + const code = dedent`@summary This is the summary`; + const tags = extractJSDocTags(code); + expect(tags).toMatchInlineSnapshot(` + { + "summary": [ + "This is the summary", + ], + } + `); +}); + +it('should extract @param tag with type', () => { + const code = dedent` + @param {Object} employee - The employee who is responsible for the project. + @param {string} employee.name - The name of the employee. + @param {string} employee.department - The employee's department.`; + const tags = extractJSDocTags(code); + expect(tags).toMatchInlineSnapshot(` + { + "param": [ + "{Object} employee - The employee who is responsible for the project.", + "{string} employee.name - The name of the employee.", + "{string} employee.department - The employee's department.", + ], + } + `); +}); diff --git a/code/renderers/react/src/component-manifest/jsdoc-tags.ts b/code/renderers/react/src/component-manifest/jsdoc-tags.ts new file mode 100644 index 000000000000..5db83721329c --- /dev/null +++ b/code/renderers/react/src/component-manifest/jsdoc-tags.ts @@ -0,0 +1,24 @@ +import { parse } from 'comment-parser'; + +import { groupBy } from './utils'; + +export function extractJSDocTags(description: string) { + const lines = description.split('\n'); + const jsDoc = ['/**', ...lines.map((line) => ` * ${line}`), ' */'].join('\n'); + + const parsed = parse(jsDoc); + + return Object.fromEntries( + Object.entries(groupBy(parsed[0].tags, (it) => it.tag)).map(([key, tags]) => [ + key, + tags?.map((tag) => (tag.type ? `{${tag.type}} ` : '') + `${tag.name} ${tag.description}`), + ]) + ); +} + +export function removeTags(description: string) { + return description + .split('\n') + .filter((line) => !line.trim().startsWith('@')) + .join('\n'); +} diff --git a/code/renderers/react/src/component-manifest/utils.ts b/code/renderers/react/src/component-manifest/utils.ts new file mode 100644 index 000000000000..bd19e7ce6fcb --- /dev/null +++ b/code/renderers/react/src/component-manifest/utils.ts @@ -0,0 +1,13 @@ +// Object.groupBy polyfill +export const groupBy = ( + items: T[], + keySelector: (item: T, index: number) => K +) => { + return items.reduce>>((acc = {}, item, index) => { + const key = keySelector(item, index); + acc[key] ??= []; + acc[key].push(item); + return acc; + }, {}); +}; + diff --git a/code/yarn.lock b/code/yarn.lock index fbc0b812a177..cc935e924e02 100644 --- a/code/yarn.lock +++ b/code/yarn.lock @@ -6841,6 +6841,7 @@ __metadata: acorn-jsx: "npm:^5.3.1" acorn-walk: "npm:^7.2.0" babel-plugin-react-docgen: "npm:^4.2.1" + comment-parser: "npm:^1.4.1" es-toolkit: "npm:^1.36.0" escodegen: "npm:^2.1.0" expect-type: "npm:^0.15.0"