From 67d5780983935c4748d341ebcd476b7233f10140 Mon Sep 17 00:00:00 2001 From: Kasper Peulen Date: Thu, 9 Oct 2025 15:52:20 +0200 Subject: [PATCH 01/22] 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/22] 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/22] 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/22] 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/22] 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/22] 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/22] 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/22] 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/22] 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/22] 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/22] 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/22] 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/22] 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/22] 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/22] 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/22] 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/22] 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/22] 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/22] 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 bd91ab6226e50d61b2807200a6b522fcde10d910 Mon Sep 17 00:00:00 2001 From: Kasper Peulen Date: Thu, 16 Oct 2025 14:36:00 +0200 Subject: [PATCH 20/22] 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 1e84547783971c8d578a77b010a9162486b3144e Mon Sep 17 00:00:00 2001 From: Kasper Peulen Date: Thu, 16 Oct 2025 20:16:21 +0200 Subject: [PATCH 21/22] Remove redundant features fetch --- code/core/src/core-server/build-static.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/code/core/src/core-server/build-static.ts b/code/core/src/core-server/build-static.ts index 4b4058550ddc..f33f483788a9 100644 --- a/code/core/src/core-server/build-static.ts +++ b/code/core/src/core-server/build-static.ts @@ -165,8 +165,6 @@ export async function buildStaticStandalone(options: BuildStaticStandaloneOption ) ); - const features = await presets.apply('features'); - if (features?.experimental_componentsManifest) { const componentManifestGenerator: ComponentManifestGenerator = await presets.apply( 'componentManifestGenerator' From a6f66733e62a6cb63277b00bcceea8a92f2300d4 Mon Sep 17 00:00:00 2001 From: Kasper Peulen Date: Thu, 16 Oct 2025 20:17:37 +0200 Subject: [PATCH 22/22] Apply feedback --- code/core/src/core-server/dev-server.ts | 29 ++++++++++++------------- 1 file changed, 14 insertions(+), 15 deletions(-) diff --git a/code/core/src/core-server/dev-server.ts b/code/core/src/core-server/dev-server.ts index 56fba2aaff5b..89232044fe26 100644 --- a/code/core/src/core-server/dev-server.ts +++ b/code/core/src/core-server/dev-server.ts @@ -136,10 +136,10 @@ export async function storybookDevServer(options: Options) { throw indexError; } - app.use('/manifests/components.json', async (req, res) => { - try { - const features = await options.presets.apply('features'); - if (!features?.experimental_componentsManifest) { + const features = await options.presets.apply('features'); + if (features?.experimental_componentsManifest) { + app.use('/manifests/components.json', async (req, res) => { + try { const componentManifestGenerator: ComponentManifestGenerator = await options.presets.apply( 'componentManifestGenerator' ); @@ -150,18 +150,17 @@ export async function storybookDevServer(options: Options) { res.end(JSON.stringify(manifest)); return; } + res.statusCode = 400; + res.end('No component manifest generator configured.'); + return; + } catch (e) { + logger.error(e instanceof Error ? e : String(e)); + res.statusCode = 500; + res.end(e instanceof Error ? e.toString() : String(e)); + return; } - res.statusCode = 400; - res.end('No component manifest generator configured.'); - return; - } catch (e) { - logger.error(e instanceof Error ? e : String(e)); - res.statusCode = 500; - res.end(e instanceof Error ? e.toString() : String(e)); - return; - } - }); - + }); + } // Now the preview has successfully started, we can count this as a 'dev' event. doTelemetry(app, core, initializedStoryIndexGenerator, options);