diff --git a/code/core/src/core-server/build-static.ts b/code/core/src/core-server/build-static.ts index 96679b114bd4..f33f483788a9 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,21 @@ export async function buildStaticStandalone(options: BuildStaticStandaloneOption initializedStoryIndexGenerator as Promise ) ); + + if (features?.experimental_componentsManifest) { + const componentManifestGenerator: ComponentManifestGenerator = await presets.apply( + 'componentManifestGenerator' + ); + 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, 'manifests', '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..89232044fe26 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,31 @@ export async function storybookDevServer(options: Options) { throw indexError; } + 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' + ); + 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) { + 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); 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/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) { diff --git a/code/core/src/types/modules/core-common.ts b/code/core/src/types/modules/core-common.ts index d31bf15209b9..fb652a7bc5fd 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; + + experimental_componentsManifest?: 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/renderers/react/src/component-manifest/generateCodeSnippet.test.tsx b/code/renderers/react/src/component-manifest/generateCodeSnippet.test.tsx new file mode 100644 index 000000000000..8dc42fa79c64 --- /dev/null +++ b/code/renderers/react/src/component-manifest/generateCodeSnippet.test.tsx @@ -0,0 +1,489 @@ +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 { getCodeSnippet } from './generateCodeSnippet'; + +function generateExample(code: string) { + const csf = loadCsf(code, { makeTitle: (userTitle?: string) => userTitle ?? 'title' }).parse(); + const component = csf._meta?.component ?? 'Unknown'; + + const snippets = Object.values(csf._storyPaths) + .map((path: NodePath) => + getCodeSnippet(path, csf._metaNode ?? null, component) + ) + .filter(Boolean); + + return recast.print(t.program(snippets)).code; +} + +function withCSF3(body: string) { + return dedent` + import type { Meta } from '@storybook/react'; + import { Button } from '@design-system/button'; + + const meta: Meta = { + component: Button, + args: { + children: 'Click me' + } + }; + export default meta; + + ${body} + `; +} + +function withCSF4(body: string) { + return dedent` + import preview from './preview'; + import { Button } from '@design-system/button'; + + 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 + } + }; + `); + 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', + number: 1, + object: { an: 'object'}, + complexObject: {...{a: 1}, an: 'object'}, + array: [1,2,3] + } + }; + `); + expect(generateExample(input)).toMatchInlineSnapshot(` + "const ObjectArgs = () => ;" + `); +}); + +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('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: () => } + `); + expect(generateExample(input)).toMatchInlineSnapshot( + `"const CustomRender = () => ;"` + ); +}); + +test('CustomRenderWithOverideArgs only', async () => { + const input = withCSF3( + `export const CustomRenderWithOverideArgs = { + render: (args) => , + args: { foo: 'bar', override: 'value' } + };` + ); + expect(generateExample(input)).toMatchInlineSnapshot( + `"const CustomRenderWithOverideArgs = () => ;"` + ); +}); + +test('CustomRenderWithNoArgs only', async () => { + const input = withCSF3( + `export const CustomRenderWithNoArgs = { + render: (args) => + };` + ); + expect(generateExample(input)).toMatchInlineSnapshot( + `"const CustomRenderWithNoArgs = () => ;"` + ); +}); + +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 = () => ;" + `); +}); + +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 = () => ;"` + ); +}); + +// 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 = () => ;" + ` + ); +}); + +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/renderers/react/src/component-manifest/generateCodeSnippet.ts b/code/renderers/react/src/component-manifest/generateCodeSnippet.ts new file mode 100644 index 000000000000..33eeca02bd85 --- /dev/null +++ b/code/renderers/react/src/component-manifest/generateCodeSnippet.ts @@ -0,0 +1,610 @@ +import { type NodePath, types as t } from 'storybook/internal/babel'; + +import invariant from 'tiny-invariant'; + +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, + 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 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; + } + } + } + + // 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?.isArrowFunctionExpression() || story?.isFunctionExpression() ? null : 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()); + + const storyFn = renderPath ?? story; + + // 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 }; + + 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)); + + 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; + const opening = body.openingElement; + const attrs = opening.attributes; + const firstSpreadIndex = attrs.findIndex( + (a) => t.isJSXSpreadAttribute(a) && t.isIdentifier(a.argument) && a.argument.name === 'args' + ); + if (firstSpreadIndex !== -1) { + // Build a list of non-args attributes and compute insertion index at the position of the first args spread + const nonArgsAttrs: (t.JSXAttribute | t.JSXSpreadAttribute)[] = []; + let insertionIndex = 0; + for (let i = 0; i < attrs.length; i++) { + const a = attrs[i]!; + const isArgsSpread = + t.isJSXSpreadAttribute(a) && t.isIdentifier(a.argument) && a.argument.name === 'args'; + if (isArgsSpread) { + if (i === firstSpreadIndex) { + insertionIndex = nonArgsAttrs.length; + } + continue; // drop all {...args} + } + nonArgsAttrs.push(a as any); + } + + // Determine names of explicitly set attributes (excluding any args spreads) + const existingAttrNames = new Set( + nonArgsAttrs + .filter((a) => t.isJSXAttribute(a) && t.isJSXIdentifier(a.name)) + .map((a) => (a as t.JSXAttribute).name.name) + ); + + // Filter out any injected attrs that would duplicate an existing explicit attribute + const filteredInjected = injectedAttrs.filter( + (a) => t.isJSXIdentifier(a.name) && !existingAttrNames.has(a.name.name) + ); + + // Build a spread containing only invalid-key props, if any, and also exclude keys already explicitly present + const invalidProps = invalidEntries.filter(([k]) => !existingAttrNames.has(k)); + const invalidSpread: t.JSXSpreadAttribute | null = buildInvalidSpread(invalidProps); + + // Handle children injection 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; + + 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; 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); + 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), + ]); + } + + // Build spread for invalid-only props, if any + const invalidSpread = buildInvalidSpread(invalidEntries); + + const name = t.jsxIdentifier(componentName); + + const openingElAttrs: Array = [ + ...injectedAttrs, + ...(invalidSpread ? [invalidSpread] : []), + ]; + + const arrow = t.arrowFunctionExpression( + [], + t.jsxElement( + t.jsxOpeningElement(name, openingElAttrs, false), + t.jsxClosingElement(name), + toJsxChildren(merged.children), + false + ) + ); + + return t.variableDeclaration('const', [ + t.variableDeclarator(t.identifier(storyId.node.name), arrow), + ]); +} + +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 +}; + +// 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 }; +} + +function transformArgsSpreadsInJsx( + node: t.JSXElement | t.JSXFragment, + merged: Record +): { node: t.JSXElement | t.JSXFragment; changed: boolean } { + let changed = false; + + const makeInjectedPieces = ( + 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); + + const injectedAttrs = validEntries + .map(([k, v]) => toAttr(k, v)) + .filter((a): a is t.JSXAttribute => Boolean(a)); + + const filteredInjected = injectedAttrs.filter( + (a) => t.isJSXIdentifier(a.name) && !existingNames.has(a.name.name) + ); + + const invalidProps = invalidEntries.filter(([k]) => !existingNames.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 }; +} + +// 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; +} 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