diff --git a/code/.storybook/main.ts b/code/.storybook/main.ts index 5b1be3191417..279e8a138b51 100644 --- a/code/.storybook/main.ts +++ b/code/.storybook/main.ts @@ -137,6 +137,7 @@ const config = defineMain({ features: { developmentModeForBuild: true, experimentalTestSyntax: true, + experimentalComponentsManifest: true, }, staticDirs: [{ from: './bench/bundle-analyzer', to: '/bundle-analyzer' }], viteFinal: async (viteConfig, { configType }) => { diff --git a/code/core/src/core-server/build-static.ts b/code/core/src/core-server/build-static.ts index 96679b114bd4..aebfec748860 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'; @@ -12,6 +12,7 @@ import { import { logger } from 'storybook/internal/node-logger'; import { getPrecedingUpgrade, telemetry } from 'storybook/internal/telemetry'; import type { BuilderOptions, CLIOptions, LoadOptions, Options } from 'storybook/internal/types'; +import { type ComponentManifestGenerator } from 'storybook/internal/types'; import { global } from '@storybook/global'; @@ -163,6 +164,28 @@ export async function buildStaticStandalone(options: BuildStaticStandaloneOption initializedStoryIndexGenerator as Promise ) ); + + if (features?.experimentalComponentsManifest) { + const componentManifestGenerator: ComponentManifestGenerator = await presets.apply( + 'experimental_componentManifestGenerator' + ); + const indexGenerator = await initializedStoryIndexGenerator; + if (componentManifestGenerator && indexGenerator) { + try { + const manifests = await componentManifestGenerator( + indexGenerator as unknown as import('storybook/internal/core-server').StoryIndexGenerator + ); + await mkdir(join(options.outputDir, 'manifests'), { recursive: true }); + await writeFile( + join(options.outputDir, 'manifests', 'components.json'), + JSON.stringify(manifests) + ); + } catch (e) { + logger.error('Failed to generate manifests/components.json'); + logger.error(e instanceof Error ? e : String(e)); + } + } + } } 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..fee70fe4687e 100644 --- a/code/core/src/core-server/dev-server.ts +++ b/code/core/src/core-server/dev-server.ts @@ -2,13 +2,14 @@ import { logConfig } from 'storybook/internal/common'; import { logger } from 'storybook/internal/node-logger'; import { MissingBuilderError } from 'storybook/internal/server-errors'; import type { Options } from 'storybook/internal/types'; +import { type ComponentManifestGenerator } from 'storybook/internal/types'; import compression from '@polka/compression'; import polka from 'polka'; import invariant from 'tiny-invariant'; import { telemetry } from '../telemetry'; -import type { StoryIndexGenerator } from './utils/StoryIndexGenerator'; +import { type StoryIndexGenerator } from './utils/StoryIndexGenerator'; import { doTelemetry } from './utils/doTelemetry'; import { getManagerBuilder, getPreviewBuilder } from './utils/get-builders'; import { getCachingMiddleware } from './utils/get-caching-middleware'; @@ -135,8 +136,35 @@ export async function storybookDevServer(options: Options) { throw indexError; } + const features = await options.presets.apply('features'); + if (features?.experimentalComponentsManifest) { + app.use('/manifests/components.json', async (req, res) => { + try { + const componentManifestGenerator: ComponentManifestGenerator = await options.presets.apply( + 'experimental_componentManifestGenerator' + ); + const indexGenerator = await initializedStoryIndexGenerator; + if (componentManifestGenerator && indexGenerator) { + const manifest = await componentManifestGenerator( + indexGenerator as unknown as import('storybook/internal/core-server').StoryIndexGenerator + ); + 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); + doTelemetry(app, core, initializedStoryIndexGenerator as Promise, options); async function cancelTelemetry() { const payload = { eventType: 'dev' }; diff --git a/code/core/src/csf-tools/CsfFile.ts b/code/core/src/csf-tools/CsfFile.ts index ae93c62afa2d..33b9c0c013c5 100644 --- a/code/core/src/csf-tools/CsfFile.ts +++ b/code/core/src/csf-tools/CsfFile.ts @@ -287,6 +287,8 @@ export class CsfFile { _rawComponentPath?: string; + _componentImportSpecifier?: t.ImportSpecifier | t.ImportDefaultSpecifier; + _meta?: StaticMeta; _stories: Record = {}; @@ -299,7 +301,7 @@ export class CsfFile { _metaStatement: t.Statement | undefined; - _metaNode: t.Expression | undefined; + _metaNode: t.ObjectExpression | undefined; _metaPath: NodePath | undefined; @@ -369,9 +371,18 @@ export class CsfFile { stmt.specifiers.find((spec) => spec.local.name === id) ) as t.ImportDeclaration; if (importStmt) { + // Example: `import { ComponentImport } from './path-to-component'` + // const meta = { component: ComponentImport }; + // Sets: + // - _rawComponentPath = './path-to-component' + // - _componentImportSpecifier = ComponentImport const { source } = importStmt; - if (t.isStringLiteral(source)) { + const specifier = importStmt.specifiers.find((spec) => spec.local.name === id); + if (t.isStringLiteral(source) && specifier) { this._rawComponentPath = source.value; + if (t.isImportSpecifier(specifier) || t.isImportDefaultSpecifier(specifier)) { + this._componentImportSpecifier = specifier; + } } } } 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..fcd90b5a9ab7 100644 --- a/code/core/src/types/modules/core-common.ts +++ b/code/core/src/types/modules/core-common.ts @@ -1,5 +1,6 @@ // should be node:http, but that caused the ui/manager to fail to build, might be able to switch this back once ui/manager is in the core import type { FileSystemCache } from 'storybook/internal/common'; +import { type StoryIndexGenerator } from 'storybook/internal/core-server'; import type { Server as HttpServer, IncomingMessage, ServerResponse } from 'http'; import type { Server as NetServer } from 'net'; @@ -339,10 +340,25 @@ export interface TagOptions { 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; + name: string; + description?: string; + import?: string; + summary?: string; + examples: { name: string; snippet: string }[]; + jsDocTags: Record; +} + +export interface ComponentsManifest { + v: number; + components: Record; +} + +export type ComponentManifestGenerator = ( + storyIndexGenerator: StoryIndexGenerator +) => Promise; + export interface StorybookConfigRaw { /** * Sets the addons you want to use with Storybook. @@ -356,6 +372,7 @@ export interface StorybookConfigRaw { */ addons?: Preset[]; core?: CoreConfig; + componentManifestGenerator?: ComponentManifestGenerator; staticDirs?: (DirectoryMapping | string)[]; logLevel?: string; features?: { @@ -453,6 +470,8 @@ export interface StorybookConfigRaw { developmentModeForBuild?: boolean; /** Only show input controls in Angular */ angularFilterNonInputControls?: boolean; + + experimentalComponentsManifest?: boolean; }; build?: TestBuildConfig; diff --git a/code/frameworks/react-vite/src/plugins/react-docgen.ts b/code/frameworks/react-vite/src/plugins/react-docgen.ts index 75a6ff8eb27f..bbac579c379f 100644 --- a/code/frameworks/react-vite/src/plugins/react-docgen.ts +++ b/code/frameworks/react-vite/src/plugins/react-docgen.ts @@ -50,7 +50,6 @@ export async function reactDocgen({ let matchPath: TsconfigPaths.MatchPath | undefined; if (tsconfig.resultType === 'success') { - logger.info('Using tsconfig paths for react-docgen'); matchPath = TsconfigPaths.createMatchPath(tsconfig.absoluteBaseUrl, tsconfig.paths, [ 'browser', 'module', diff --git a/code/presets/react-webpack/src/loaders/react-docgen-loader.ts b/code/presets/react-webpack/src/loaders/react-docgen-loader.ts index bff38248b7c2..3d1bc7e954e8 100644 --- a/code/presets/react-webpack/src/loaders/react-docgen-loader.ts +++ b/code/presets/react-webpack/src/loaders/react-docgen-loader.ts @@ -85,7 +85,6 @@ export default async function reactDocgenLoader( const tsconfig = TsconfigPaths.loadConfig(tsconfigPath); if (tsconfig.resultType === 'success') { - logger.info('Using tsconfig paths for react-docgen'); matchPath = TsconfigPaths.createMatchPath(tsconfig.absoluteBaseUrl, tsconfig.paths, [ 'browser', 'module', diff --git a/code/renderers/react/package.json b/code/renderers/react/package.json index 465323e7f141..b4de8c0a822a 100644 --- a/code/renderers/react/package.json +++ b/code/renderers/react/package.json @@ -54,7 +54,8 @@ }, "dependencies": { "@storybook/global": "^5.0.0", - "@storybook/react-dom-shim": "workspace:*" + "@storybook/react-dom-shim": "workspace:*", + "react-docgen": "^8.0.2" }, "devDependencies": { "@types/babel-plugin-react-docgen": "^4.2.3", @@ -66,6 +67,7 @@ "acorn-jsx": "^5.3.1", "acorn-walk": "^7.2.0", "babel-plugin-react-docgen": "^4.2.1", + "comment-parser": "^1.4.1", "es-toolkit": "^1.36.0", "escodegen": "^2.1.0", "expect-type": "^0.15.0", diff --git a/code/renderers/react/src/componentManifest/generateCodeSnippet.test.tsx b/code/renderers/react/src/componentManifest/generateCodeSnippet.test.tsx new file mode 100644 index 000000000000..7912d2843e50 --- /dev/null +++ b/code/renderers/react/src/componentManifest/generateCodeSnippet.test.tsx @@ -0,0 +1,511 @@ +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 satisfies or as', () => { + const input = withCSF3(` + export const Default = {} satisfies Story; + export const Other = {} as Story; + `); + expect(generateExample(input)).toMatchInlineSnapshot( + ` + "const Default = () => ; + const Other = () => ;" + ` + ); +}); + +test('Edge case identifier we can not find', () => { + const input = withCSF3(` + export const Default = someImportOrWhatever; + `); + 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/componentManifest/generateCodeSnippet.ts b/code/renderers/react/src/componentManifest/generateCodeSnippet.ts new file mode 100644 index 000000000000..12fbd31a1a4c --- /dev/null +++ b/code/renderers/react/src/componentManifest/generateCodeSnippet.ts @@ -0,0 +1,611 @@ +import { type NodePath, types as t } from 'storybook/internal/babel'; + +function invariant(condition: any, message?: string | (() => string)): asserts condition { + if (condition) { + return; + } + throw new Error(typeof message === 'function' ? message() : message); +} + +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 == null || story.isArrowFunctionExpression() || story.isFunctionExpression() + ? null + : story.isTSSatisfiesExpression() + ? story.get('expression') + : story.isTSAsExpression() + ? story.get('expression') + : story; + + const storyProperties = storyObjPath?.isObjectExpression() + ? storyObjPath.get('properties').filter((p) => p.isObjectProperty()) + : []; + + // Prefer an explicit render() when it is a function (arrow/function) + const renderPath = storyProperties + .filter((p) => keyOf(p.node) === 'render') + .map((p) => p.get('value')) + .find((value) => value.isExpression()); + + const storyFn = renderPath ?? story; + + // Collect args: meta.args and story.args as Record + const metaArgs = metaArgsRecord(metaObj ?? null); + const storyArgsPath = storyProperties + .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 if the element currently has no children, using merged children (story overrides meta) + const mergedChildren = Object.prototype.hasOwnProperty.call(merged, 'children') + ? merged['children'] + : undefined; + const canInjectChildren = + !!mergedChildren && (body.children == null || body.children.length === 0); + + // Always transform when `{...args}` exists: remove spreads and empty params + const pieces = [...filteredInjected, ...(invalidSpread ? [invalidSpread] : [])]; + const newAttrs = [ + ...nonArgsAttrs.slice(0, insertionIndex), + ...pieces, + ...nonArgsAttrs.slice(insertionIndex), + ]; + + const willHaveChildren = canInjectChildren ? true : (body.children?.length ?? 0) > 0; + const shouldSelfClose = opening.selfClosing && !willHaveChildren; + + const finalOpening = t.jsxOpeningElement(opening.name, newAttrs, shouldSelfClose); + const finalClosing = shouldSelfClose + ? null + : (body.closingElement ?? t.jsxClosingElement(opening.name)); + const finalChildren = canInjectChildren ? toJsxChildren(mergedChildren) : body.children; + + let newBody = t.jsxElement(finalOpening, finalClosing, finalChildren, shouldSelfClose); + // After handling top-level {...args}, also inline any nested args.* usages and + // transform any nested {...args} spreads deeper in the tree. + const inlined = inlineArgsInJsx(newBody, merged); + const transformed = transformArgsSpreadsInJsx(inlined.node, merged); + newBody = transformed.node as t.JSXElement; + + const newFn = t.arrowFunctionExpression([], newBody, fn.async); + 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)) { + // Keep falsy boolean attributes by rendering an explicit expression container + return value.value + ? t.jsxAttribute(t.jsxIdentifier(key), null) + : t.jsxAttribute(t.jsxIdentifier(key), t.jsxExpressionContainer(value)); + } + + if (t.isStringLiteral(value)) { + return t.jsxAttribute(t.jsxIdentifier(key), t.stringLiteral(value.value)); + } + + 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/componentManifest/generator.test.ts b/code/renderers/react/src/componentManifest/generator.test.ts new file mode 100644 index 000000000000..e4b8f3294fbf --- /dev/null +++ b/code/renderers/react/src/componentManifest/generator.test.ts @@ -0,0 +1,417 @@ +import { beforeEach, expect, test, vi } from 'vitest'; + +import { type StoryIndexGenerator } from 'storybook/internal/core-server'; + +import { vol } from 'memfs'; +import { dedent } from 'ts-dedent'; +import * as TsconfigPaths from 'tsconfig-paths'; + +import { componentManifestGenerator } from './generator'; + +vi.mock('tsconfig-paths', { spy: true }); +vi.mock('node:fs/promises', async () => (await import('memfs')).fs.promises); +vi.mock('node:fs', async () => (await import('memfs')).fs); + +// Use the provided indexJson from this file +const indexJson = { + v: 5, + entries: { + 'example-button--primary': { + type: 'story', + subtype: 'story', + id: 'example-button--primary', + name: 'Primary', + title: 'Example/Button', + importPath: './src/stories/Button.stories.ts', + componentPath: './src/stories/Button.tsx', + tags: ['dev', 'test', 'vitest', 'autodocs'], + exportName: 'Primary', + }, + 'example-button--secondary': { + type: 'story', + subtype: 'story', + id: 'example-button--secondary', + name: 'Secondary', + title: 'Example/Button', + importPath: './src/stories/Button.stories.ts', + componentPath: './src/stories/Button.tsx', + tags: ['dev', 'test', 'vitest', 'autodocs'], + exportName: 'Secondary', + }, + 'example-button--large': { + type: 'story', + subtype: 'story', + id: 'example-button--large', + name: 'Large', + title: 'Example/Button', + importPath: './src/stories/Button.stories.ts', + componentPath: './src/stories/Button.tsx', + tags: ['dev', 'test', 'vitest', 'autodocs'], + exportName: 'Large', + }, + 'example-button--small': { + type: 'story', + subtype: 'story', + id: 'example-button--small', + name: 'Small', + title: 'Example/Button', + importPath: './src/stories/Button.stories.ts', + componentPath: './src/stories/Button.tsx', + tags: ['dev', 'test', 'vitest', 'autodocs'], + exportName: 'Small', + }, + 'example-header--docs': { + id: 'example-header--docs', + title: 'Example/Header', + name: 'Docs', + importPath: './src/stories/Header.stories.ts', + type: 'docs', + tags: ['dev', 'test', 'vitest', 'autodocs'], + storiesImports: [], + }, + 'example-header--logged-in': { + type: 'story', + subtype: 'story', + id: 'example-header--logged-in', + name: 'Logged In', + title: 'Example/Header', + importPath: './src/stories/Header.stories.ts', + componentPath: './src/stories/Header.tsx', + tags: ['dev', 'test', 'vitest', 'autodocs'], + exportName: 'LoggedIn', + }, + 'example-header--logged-out': { + type: 'story', + subtype: 'story', + id: 'example-header--logged-out', + name: 'Logged Out', + title: 'Example/Header', + importPath: './src/stories/Header.stories.ts', + componentPath: './src/stories/Header.tsx', + tags: ['dev', 'test', 'vitest', 'autodocs'], + exportName: 'LoggedOut', + }, + }, +}; + +beforeEach(() => { + vi.mocked(TsconfigPaths.loadConfig).mockImplementation(() => ({ + resultType: null!, + message: null!, + })); + vi.spyOn(process, 'cwd').mockReturnValue('/app'); + vol.fromJSON( + { + ['./src/stories/Button.stories.ts']: dedent` + import type { Meta, StoryObj } from '@storybook/react'; + import { fn } from 'storybook/test'; + import { Button } from './Button'; + + const meta = { + component: Button, + args: { onClick: fn() }, + } satisfies Meta; + export default meta; + type Story = StoryObj; + + export const Primary: Story = { args: { primary: true, label: 'Button' } }; + export const Secondary: Story = { args: { label: 'Button' } }; + export const Large: Story = { args: { size: 'large', label: 'Button' } }; + export const Small: Story = { args: { size: 'small', label: 'Button' } };`, + ['./src/stories/Button.tsx']: dedent` + import React from 'react'; + export interface ButtonProps { + /** Description of primary */ + primary?: boolean; + backgroundColor?: string; + size?: 'small' | 'medium' | 'large'; + label: string; + onClick?: () => void; + } + + /** Primary UI component for user interaction */ + export const Button = ({ + primary = false, + size = 'medium', + backgroundColor, + label, + ...props + }: ButtonProps) => { + const mode = primary ? 'storybook-button--primary' : 'storybook-button--secondary'; + return ( + + ); + };`, + ['./src/stories/Header.stories.ts']: dedent` + import type { Meta, StoryObj } from '@storybook/react'; + import { fn } from 'storybook/test'; + import Header from './Header'; + + /** + * Description from meta and very long. + * @summary Component summary + * @import import { Header } from '@design-system/components/Header'; + */ + const meta = { + component: Header, + args: { + onLogin: fn(), + onLogout: fn(), + onCreateAccount: fn(), + } + } satisfies Meta; + export default meta; + type Story = StoryObj; + export const LoggedIn: Story = { args: { user: { name: 'Jane Doe' } } }; + export const LoggedOut: Story = {}; + `, + ['./src/stories/Header.tsx']: dedent` + import { Button } from './Button'; + + export interface HeaderProps { + user?: User; + onLogin?: () => void; + onLogout?: () => void; + onCreateAccount?: () => void; + } + + export default ({ user, onLogin, onLogout, onCreateAccount }: HeaderProps) => ( +
+
+
+ {user ? ( + <> + + Welcome, {user.name}! + +
+
+
+ );`, + }, + '/app' + ); + return () => vol.reset(); +}); + +test('componentManifestGenerator generates correct id, name, description and examples ', async () => { + const generator = await componentManifestGenerator(); + const manifest = await generator({ + getIndex: async () => indexJson, + } as unknown as StoryIndexGenerator); + + expect(manifest).toMatchInlineSnapshot(` + { + "components": { + "example-button": { + "description": "Primary UI component for user interaction", + "examples": [ + { + "name": "Primary", + "snippet": "const Primary = () => ;", + }, + { + "name": "Secondary", + "snippet": "const Secondary = () => ;", + }, + { + "name": "Large", + "snippet": "const Large = () => ;", + }, + { + "name": "Small", + "snippet": "const Small = () => ;", + }, + ], + "id": "example-button", + "import": undefined, + "jsDocTags": {}, + "name": "Button", + "reactDocgen": { + "actualName": "Button", + "definedInFile": "/app/src/stories/Button.tsx", + "description": "Primary UI component for user interaction", + "displayName": "Button", + "exportName": "Button", + "methods": [], + "props": { + "backgroundColor": { + "description": "", + "required": false, + "tsType": { + "name": "string", + }, + }, + "label": { + "description": "", + "required": true, + "tsType": { + "name": "string", + }, + }, + "onClick": { + "description": "", + "required": false, + "tsType": { + "name": "signature", + "raw": "() => void", + "signature": { + "arguments": [], + "return": { + "name": "void", + }, + }, + "type": "function", + }, + }, + "primary": { + "defaultValue": { + "computed": false, + "value": "false", + }, + "description": "Description of primary", + "required": false, + "tsType": { + "name": "boolean", + }, + }, + "size": { + "defaultValue": { + "computed": false, + "value": "'medium'", + }, + "description": "", + "required": false, + "tsType": { + "elements": [ + { + "name": "literal", + "value": "'small'", + }, + { + "name": "literal", + "value": "'medium'", + }, + { + "name": "literal", + "value": "'large'", + }, + ], + "name": "union", + "raw": "'small' | 'medium' | 'large'", + }, + }, + }, + }, + "summary": undefined, + }, + "example-header": { + "description": "Description from meta and very long.", + "examples": [ + { + "name": "LoggedIn", + "snippet": "const LoggedIn = () =>
;", + }, + { + "name": "LoggedOut", + "snippet": "const LoggedOut = () =>
;", + }, + ], + "id": "example-header", + "import": "import { Header } from '@design-system/components/Header';", + "jsDocTags": { + "import": [ + "import { Header } from '@design-system/components/Header';", + ], + "summary": [ + "Component summary", + ], + }, + "name": "Header", + "reactDocgen": { + "actualName": "", + "definedInFile": "/app/src/stories/Header.tsx", + "description": "", + "exportName": "default", + "methods": [], + "props": { + "onCreateAccount": { + "description": "", + "required": false, + "tsType": { + "name": "signature", + "raw": "() => void", + "signature": { + "arguments": [], + "return": { + "name": "void", + }, + }, + "type": "function", + }, + }, + "onLogin": { + "description": "", + "required": false, + "tsType": { + "name": "signature", + "raw": "() => void", + "signature": { + "arguments": [], + "return": { + "name": "void", + }, + }, + "type": "function", + }, + }, + "onLogout": { + "description": "", + "required": false, + "tsType": { + "name": "signature", + "raw": "() => void", + "signature": { + "arguments": [], + "return": { + "name": "void", + }, + }, + "type": "function", + }, + }, + "user": { + "description": "", + "required": false, + "tsType": { + "name": "User", + }, + }, + }, + }, + "summary": "Component summary", + }, + }, + "v": 0, + } + `); +}); diff --git a/code/renderers/react/src/componentManifest/generator.ts b/code/renderers/react/src/componentManifest/generator.ts new file mode 100644 index 000000000000..9867acd67e52 --- /dev/null +++ b/code/renderers/react/src/componentManifest/generator.ts @@ -0,0 +1,100 @@ +import { readFile } from 'node:fs/promises'; + +import { recast } from 'storybook/internal/babel'; +import { loadCsf } from 'storybook/internal/csf-tools'; +import { extractDescription } from 'storybook/internal/csf-tools'; +import { type ComponentManifestGenerator } from 'storybook/internal/types'; +import { type ComponentManifest } from 'storybook/internal/types'; + +import path from 'pathe'; + +import { getCodeSnippet } from './generateCodeSnippet'; +import { extractJSDocTags, removeTags } from './jsdocTags'; +import { type DocObj, getMatchingDocgen, parseWithReactDocgen } from './reactDocgen'; +import { groupBy } from './utils'; + +interface ReactComponentManifest extends ComponentManifest { + reactDocgen?: DocObj; +} + +export const componentManifestGenerator = async () => { + return (async (storyIndexGenerator) => { + const index = await storyIndexGenerator.getIndex(); + const groupByComponentId = groupBy( + Object.values(index.entries) + .filter((entry) => entry.type === 'story') + .filter((entry) => 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.flatMap(async (entry) => { + const storyFile = await readFile(path.join(process.cwd(), entry.importPath), 'utf-8'); + const csf = loadCsf(storyFile, { makeTitle: (title) => title ?? 'No title' }).parse(); + const componentName = csf._meta?.component; + + if (!componentName) { + // TODO when there is not component name we could generate snippets based on the meta.render + return; + } + + const examples = Object.entries(csf._storyPaths) + .map(([name, path]) => ({ + name, + snippet: recast.print(getCodeSnippet(path, csf._metaNode ?? null, componentName)).code, + })) + .filter(Boolean); + + const id = entry.id.split('--')[0]; + + const componentFile = await readFile( + path.join(process.cwd(), entry.componentPath!), + 'utf-8' + ).catch(() => { + // TODO This happens too often. We should improve the componentPath resolution. + return null; + }); + + if (!componentFile || !entry.componentPath) { + return { id, name: componentName, examples, jsDocTags: {} }; + } + + const docgens = await parseWithReactDocgen({ + code: componentFile, + filename: path.join(process.cwd(), entry.componentPath), + }); + const docgen = getMatchingDocgen(docgens, csf); + + const metaDescription = extractDescription(csf._metaStatement); + const jsdocComment = metaDescription || docgen?.description; + const tags = jsdocComment ? extractJSDocTags(jsdocComment) : {}; + + const manifestDescription = jsdocComment + ? removeTags(tags.describe?.[0] || tags.desc?.[0] || jsdocComment).trim() + : undefined; + + return { + id, + name: componentName, + description: manifestDescription, + summary: tags.summary?.[0], + import: tags.import?.[0], + reactDocgen: docgen, + jsDocTags: tags, + examples, + } satisfies ReactComponentManifest; + }) + ); + + return { + v: 0, + components: Object.fromEntries( + components + .filter((component) => component != null) + .map((component) => [component.id, component]) + ), + }; + }) satisfies ComponentManifestGenerator; +}; diff --git a/code/renderers/react/src/componentManifest/jsdocTags.test.ts b/code/renderers/react/src/componentManifest/jsdocTags.test.ts new file mode 100644 index 000000000000..fef2d44b1c78 --- /dev/null +++ b/code/renderers/react/src/componentManifest/jsdocTags.test.ts @@ -0,0 +1,34 @@ +import { expect, it } from 'vitest'; + +import { dedent } from 'ts-dedent'; + +import { extractJSDocTags } from './jsdocTags'; + +it('should extract @summary tag', () => { + const code = dedent`@summary This is the summary`; + const tags = extractJSDocTags(code); + expect(tags).toMatchInlineSnapshot(` + { + "summary": [ + "This is the summary", + ], + } + `); +}); + +it('should extract @param tag with type', () => { + const code = dedent` + @param {Object} employee - The employee who is responsible for the project. + @param {string} employee.name - The name of the employee. + @param {string} employee.department - The employee's department.`; + const tags = extractJSDocTags(code); + expect(tags).toMatchInlineSnapshot(` + { + "param": [ + "{Object} employee - The employee who is responsible for the project.", + "{string} employee.name - The name of the employee.", + "{string} employee.department - The employee's department.", + ], + } + `); +}); diff --git a/code/renderers/react/src/componentManifest/jsdocTags.ts b/code/renderers/react/src/componentManifest/jsdocTags.ts new file mode 100644 index 000000000000..74e5f4486c3d --- /dev/null +++ b/code/renderers/react/src/componentManifest/jsdocTags.ts @@ -0,0 +1,25 @@ +import { parse } from 'comment-parser'; + +import { groupBy } from './utils'; + +export function extractJSDocTags(jsdocComment: string) { + const lines = jsdocComment.split('\n'); + const jsDoc = ['/**', ...lines.map((line) => ` * ${line}`), ' */'].join('\n'); + + const parsed = parse(jsDoc); + + return Object.fromEntries( + Object.entries(groupBy(parsed[0].tags, (it) => it.tag)).map(([key, tags]) => [ + key, + tags?.map((tag) => (tag.type ? `{${tag.type}} ` : '') + `${tag.name} ${tag.description}`) ?? + [], + ]) + ); +} + +export function removeTags(jsdocComment: string) { + return jsdocComment + .split('\n') + .filter((line) => !line.trim().startsWith('@')) + .join('\n'); +} diff --git a/code/renderers/react/src/componentManifest/reactDocgen.test.ts b/code/renderers/react/src/componentManifest/reactDocgen.test.ts new file mode 100644 index 000000000000..e17922bfde73 --- /dev/null +++ b/code/renderers/react/src/componentManifest/reactDocgen.test.ts @@ -0,0 +1,209 @@ +import { describe, expect, test } from 'vitest'; + +import { dedent } from 'ts-dedent'; + +import { parseWithReactDocgen } from './reactDocgen'; + +async function parse(code: string, name = 'Component.tsx') { + const filename = `/virtual/${name}`; + return parseWithReactDocgen({ code, filename }); +} + +describe('parseWithReactDocgen exportName coverage', () => { + test('inline default export function declaration', async () => { + const code = dedent /* tsx */ ` + import React from 'react'; + export default function Foo() { return <> } + `; + expect(await parse(code)).toMatchInlineSnapshot(` + [ + { + "actualName": "Foo", + "definedInFile": "/virtual/Component.tsx", + "description": "", + "displayName": "Foo", + "exportName": "default", + "methods": [], + }, + ] + `); + }); + + test('inline default export class declaration', async () => { + const code = dedent /* tsx */ ` + import React from 'react'; + export default class Foo extends React.Component { render(){ return null } } + `; + expect(await parse(code)).toMatchInlineSnapshot(` + [ + { + "actualName": "Foo", + "definedInFile": "/virtual/Component.tsx", + "description": "", + "displayName": "Foo", + "exportName": "default", + "methods": [], + }, + ] + `); + }); + + test('inline anonymous default export (arrow function)', async () => { + const code = dedent /* tsx */ ` + export default () =>
; + `; + expect(await parse(code)).toMatchInlineSnapshot(` + [ + { + "actualName": "", + "definedInFile": "/virtual/Component.tsx", + "description": "", + "exportName": "default", + "methods": [], + }, + ] + `); + }); + + test('separate default export identifier', async () => { + const code = dedent /* tsx */ ` + const Foo = () =>
; + export default Foo; + `; + expect(await parse(code)).toMatchInlineSnapshot(` + [ + { + "actualName": "Foo", + "definedInFile": "/virtual/Component.tsx", + "description": "", + "displayName": "Foo", + "exportName": "default", + "methods": [], + }, + ] + `); + }); + + test('named export: export const Foo = ...', async () => { + const code = dedent /* tsx */ ` + export const Foo = () =>
; + `; + expect(await parse(code)).toMatchInlineSnapshot(` + [ + { + "actualName": "Foo", + "definedInFile": "/virtual/Component.tsx", + "description": "", + "displayName": "Foo", + "exportName": "Foo", + "methods": [], + }, + ] + `); + }); + + test('named export: export function Foo() {}', async () => { + const code = dedent /* tsx */ ` + export function Foo() { return
} + `; + expect(await parse(code)).toMatchInlineSnapshot(` + [ + { + "actualName": "Foo", + "definedInFile": "/virtual/Component.tsx", + "description": "", + "displayName": "Foo", + "exportName": "Foo", + "methods": [], + }, + ] + `); + }); + + test('export list: export { Foo }', async () => { + const code = dedent /* tsx */ ` + const Foo = () =>
; + export { Foo }; + `; + expect(await parse(code)).toMatchInlineSnapshot(` + [ + { + "actualName": "Foo", + "definedInFile": "/virtual/Component.tsx", + "description": "", + "displayName": "Foo", + "exportName": "Foo", + "methods": [], + }, + ] + `); + }); + + test('aliased named export: export { Foo as Bar }', async () => { + const code = dedent /* tsx */ ` + const Foo = () =>
; + export { Foo as Bar }; + `; + expect(await parse(code)).toMatchInlineSnapshot(` + [ + { + "actualName": "Foo", + "definedInFile": "/virtual/Component.tsx", + "description": "", + "displayName": "Foo", + "exportName": "Bar", + "methods": [], + }, + ] + `); + }); + + test('aliased to default: export { Foo as default }', async () => { + const code = dedent /* tsx */ ` + const Foo = () =>
; + export { Foo as default }; + `; + expect(await parse(code)).toMatchInlineSnapshot(` + [ + { + "actualName": "Foo", + "definedInFile": "/virtual/Component.tsx", + "description": "", + "displayName": "Foo", + "exportName": "default", + "methods": [], + }, + ] + `); + }); + + test('multiple components with different export styles', async () => { + const code = dedent /* tsx */ ` + export function A(){ return null } + const B = () =>
; + export { B as Beta }; + const C = () =>
; + export default C; + `; + expect(await parse(code)).toMatchInlineSnapshot(` + [ + { + "actualName": "B", + "definedInFile": "/virtual/Component.tsx", + "description": "", + "displayName": "B", + "exportName": "Beta", + "methods": [], + }, + { + "actualName": "C", + "definedInFile": "/virtual/Component.tsx", + "description": "", + "displayName": "C", + "exportName": "default", + "methods": [], + }, + ] + `); + }); +}); diff --git a/code/renderers/react/src/componentManifest/reactDocgen.ts b/code/renderers/react/src/componentManifest/reactDocgen.ts new file mode 100644 index 000000000000..4770e958b1cb --- /dev/null +++ b/code/renderers/react/src/componentManifest/reactDocgen.ts @@ -0,0 +1,121 @@ +import { existsSync } from 'node:fs'; +import { sep } from 'node:path'; + +import { types as t } from 'storybook/internal/babel'; +import { getProjectRoot } from 'storybook/internal/common'; +import { type CsfFile } from 'storybook/internal/csf-tools'; + +import * as find from 'empathic/find'; +import { type Documentation, ERROR_CODES } from 'react-docgen'; +import { + builtinHandlers as docgenHandlers, + builtinResolvers as docgenResolver, + makeFsImporter, + parse, +} from 'react-docgen'; +import * as TsconfigPaths from 'tsconfig-paths'; + +import actualNameHandler from './reactDocgen/actualNameHandler'; +import { + RESOLVE_EXTENSIONS, + ReactDocgenResolveError, + defaultLookupModule, +} from './reactDocgen/docgenResolver'; +import exportNameHandler from './reactDocgen/exportNameHandler'; + +export type DocObj = Documentation & { + actualName: string; + definedInFile: string; + exportName?: string; +}; + +// TODO: None of these are able to be overridden, so `default` is aspirational here. +const defaultHandlers = Object.values(docgenHandlers).map((handler) => handler); +const defaultResolver = new docgenResolver.FindExportedDefinitionsResolver(); +const handlers = [...defaultHandlers, actualNameHandler, exportNameHandler]; + +export function getMatchingDocgen(docgens: DocObj[], csf: CsfFile) { + const componentName = csf._meta?.component; + if (docgens.length === 1) { + return docgens[0]; + } + const importSpecifier = csf._componentImportSpecifier; + + let importName: string; + if (t.isImportSpecifier(importSpecifier)) { + const imported = importSpecifier.imported; + importName = t.isIdentifier(imported) ? imported.name : imported.value; + } else if (t.isImportDefaultSpecifier(importSpecifier)) { + importName = 'default'; + } + + const docgen = docgens.find((docgen) => docgen.exportName === importName); + if (docgen || !componentName) { + return docgen; + } + return docgens.find( + (docgen) => docgen.displayName === componentName || docgen.actualName === componentName + ); +} + +export async function parseWithReactDocgen({ code, filename }: { code: string; filename: string }) { + const tsconfigPath = find.up('tsconfig.json', { cwd: process.cwd(), last: getProjectRoot() }); + const tsconfig = TsconfigPaths.loadConfig(tsconfigPath); + + let matchPath: TsconfigPaths.MatchPath | undefined; + + if (tsconfig.resultType === 'success') { + matchPath = TsconfigPaths.createMatchPath(tsconfig.absoluteBaseUrl, tsconfig.paths, [ + 'browser', + 'module', + 'main', + ]); + } + + try { + return parse(code, { + resolver: defaultResolver, + handlers, + importer: getReactDocgenImporter(matchPath), + filename, + }) as DocObj[]; + } catch (e) { + // Ignore the error when react-docgen cannot find a react component + if (!(e instanceof Error && 'code' in e && e.code === ERROR_CODES.MISSING_DEFINITION)) { + console.error(e); + } + return []; + } +} + +export function getReactDocgenImporter(matchPath: TsconfigPaths.MatchPath | undefined) { + return makeFsImporter((filename, basedir) => { + const mappedFilenameByPaths = (() => { + if (matchPath) { + const match = matchPath(filename); + return match || filename; + } else { + return filename; + } + })(); + + const result = defaultLookupModule(mappedFilenameByPaths, basedir); + + if (result.includes(`${sep}react-native${sep}index.js`)) { + const replaced = result.replace( + `${sep}react-native${sep}index.js`, + `${sep}react-native-web${sep}dist${sep}index.js` + ); + if (existsSync(replaced)) { + if (RESOLVE_EXTENSIONS.find((ext) => result.endsWith(ext))) { + return replaced; + } + } + } + if (RESOLVE_EXTENSIONS.find((ext) => result.endsWith(ext))) { + return result; + } + + throw new ReactDocgenResolveError(filename); + }); +} diff --git a/code/renderers/react/src/componentManifest/reactDocgen/actualNameHandler.ts b/code/renderers/react/src/componentManifest/reactDocgen/actualNameHandler.ts new file mode 100644 index 000000000000..6b91fa7fcb1d --- /dev/null +++ b/code/renderers/react/src/componentManifest/reactDocgen/actualNameHandler.ts @@ -0,0 +1,56 @@ +/** + * This is heavily based on the react-docgen `displayNameHandler` + * (https://github.com/reactjs/react-docgen/blob/26c90c0dd105bf83499a83826f2a6ff7a724620d/src/handlers/displayNameHandler.ts) + * but instead defines an `actualName` property on the generated docs that is taken first from the + * component's actual name. This addresses an issue where the name that the generated docs are + * stored under is incorrectly named with the `displayName` and not the component's actual name. + * + * This is inspired by `actualNameHandler` from + * https://github.com/storybookjs/babel-plugin-react-docgen, but is modified directly from + * displayNameHandler, using the same approach as babel-plugin-react-docgen. + */ +import type { Handler, NodePath, babelTypes as t } from 'react-docgen'; +import { utils } from 'react-docgen'; + +const { getNameOrValue, isReactForwardRefCall } = utils; + +const actualNameHandler: Handler = function actualNameHandler(documentation, componentDefinition) { + documentation.set('definedInFile', componentDefinition.hub.file.opts.filename); + + if ( + (componentDefinition.isClassDeclaration() || componentDefinition.isFunctionDeclaration()) && + componentDefinition.has('id') + ) { + documentation.set( + 'actualName', + getNameOrValue(componentDefinition.get('id') as NodePath) + ); + } else if ( + componentDefinition.isArrowFunctionExpression() || + componentDefinition.isFunctionExpression() || + isReactForwardRefCall(componentDefinition) + ) { + let currentPath: NodePath = componentDefinition; + + while (currentPath.parentPath) { + if (currentPath.parentPath.isVariableDeclarator()) { + documentation.set('actualName', getNameOrValue(currentPath.parentPath.get('id'))); + return; + } + if (currentPath.parentPath.isAssignmentExpression()) { + const leftPath = currentPath.parentPath.get('left'); + + if (leftPath.isIdentifier() || leftPath.isLiteral()) { + documentation.set('actualName', getNameOrValue(leftPath)); + return; + } + } + + currentPath = currentPath.parentPath; + } + // Could not find an actual name + documentation.set('actualName', ''); + } +}; + +export default actualNameHandler; diff --git a/code/renderers/react/src/componentManifest/reactDocgen/docgenResolver.ts b/code/renderers/react/src/componentManifest/reactDocgen/docgenResolver.ts new file mode 100644 index 000000000000..f4ae37407c09 --- /dev/null +++ b/code/renderers/react/src/componentManifest/reactDocgen/docgenResolver.ts @@ -0,0 +1,75 @@ +import { extname } from 'node:path'; + +import resolve from 'resolve'; + +export class ReactDocgenResolveError extends Error { + // the magic string that react-docgen uses to check if a module is ignored + readonly code = 'MODULE_NOT_FOUND'; + + constructor(filename: string) { + super(`'${filename}' was ignored by react-docgen.`); + } +} + +/* The below code was copied from: + * https://github.com/reactjs/react-docgen/blob/df2daa8b6f0af693ecc3c4dc49f2246f60552bcb/packages/react-docgen/src/importer/makeFsImporter.ts#L14-L63 + * because it wasn't exported from the react-docgen package. + * watch out: when updating this code, also update the code in code/presets/react-webpack/src/loaders/docgen-resolver.ts + */ + +// These extensions are sorted by priority +// resolve() will check for files in the order these extensions are sorted +export const RESOLVE_EXTENSIONS = [ + '.js', + '.cts', // These were originally not in the code, I added them + '.mts', // These were originally not in the code, I added them + '.ctsx', // These were originally not in the code, I added them + '.mtsx', // These were originally not in the code, I added them + '.ts', + '.tsx', + '.mjs', + '.cjs', + '.mts', + '.cts', + '.jsx', +]; + +export function defaultLookupModule(filename: string, basedir: string): string { + const resolveOptions = { + basedir, + extensions: RESOLVE_EXTENSIONS, + // we do not need to check core modules as we cannot import them anyway + includeCoreModules: false, + }; + + try { + return resolve.sync(filename, resolveOptions); + } catch (error) { + const ext = extname(filename); + let newFilename: string; + + // if we try to import a JavaScript file it might be that we are actually pointing to + // a TypeScript file. This can happen in ES modules as TypeScript requires to import other + // TypeScript files with .js extensions + // https://www.typescriptlang.org/docs/handbook/esm-node.html#type-in-packagejson-and-new-extensions + switch (ext) { + case '.js': + case '.mjs': + case '.cjs': + newFilename = `${filename.slice(0, -2)}ts`; + break; + + case '.jsx': + newFilename = `${filename.slice(0, -3)}tsx`; + break; + default: + throw error; + } + + return resolve.sync(newFilename, { + ...resolveOptions, + // we already know that there is an extension at this point, so no need to check other extensions + extensions: [], + }); + } +} diff --git a/code/renderers/react/src/componentManifest/reactDocgen/exportNameHandler.ts b/code/renderers/react/src/componentManifest/reactDocgen/exportNameHandler.ts new file mode 100644 index 000000000000..534ca33365ad --- /dev/null +++ b/code/renderers/react/src/componentManifest/reactDocgen/exportNameHandler.ts @@ -0,0 +1,180 @@ +/** + * ExportNameHandler + * + * Sets `exportName` on the documentation: + * + * - 'default' for default exports + * - The exported identifier for named exports (incl. aliases) + * - Undefined when not exported / undetermined + */ +import type { Handler, NodePath, babelTypes as t } from 'react-docgen'; +import { utils } from 'react-docgen'; + +const { isReactForwardRefCall } = utils; + +/** Extract a string name from Identifier or StringLiteral NodePath. */ +function nameFromId(path?: NodePath | null): string | undefined { + if (!path) { + return undefined; + } + + if (path.isIdentifier()) { + return path.node.name; + } + + if (path.isStringLiteral()) { + return path.node.value; + } + return undefined; +} + +/** True if node is directly/indirectly inline default-exported. */ +function isInlineDefaultExport(path: NodePath): boolean { + let p: NodePath | null = path; + while (p && p.parentPath) { + if (p.parentPath.isExportDefaultDeclaration()) { + return true; + } + p = p.parentPath as NodePath; + } + return false; +} + +/** Find the Program node that contains this path. */ +function findProgram(path: NodePath): NodePath | undefined { + const found = path.findParent((p) => p.isProgram()) as NodePath | null; + return found && found.isProgram() ? found : undefined; +} + +/** + * Determine the local identifier of the component in this file. Priority: + * + * 1. Provided fallback (documentation.actualName) + * 2. Class/Function declaration `id` + * 3. LHS of VariableDeclarator / AssignmentExpression for expressions + */ +function getLocalName( + componentDefinition: NodePath, + fallback?: string +): string | undefined { + if (fallback) { + return fallback; + } + + // Named class/function declarations + if ( + (componentDefinition.isClassDeclaration() || componentDefinition.isFunctionDeclaration()) && + componentDefinition.has('id') + ) { + const idPath = componentDefinition.get('id') as NodePath; + return nameFromId(idPath); + } + + // Expressions: arrow/function/forwardRef -> walk up to a declarator/assignment + if ( + componentDefinition.isArrowFunctionExpression() || + componentDefinition.isFunctionExpression() || + isReactForwardRefCall(componentDefinition) + ) { + let p: NodePath | null = componentDefinition; + while (p && p.parentPath) { + if (p.parentPath.isVariableDeclarator()) { + const id = p.parentPath.get('id'); + return nameFromId(id); + } + if (p.parentPath.isAssignmentExpression()) { + const left = p.parentPath.get('left'); + const lhs = nameFromId(left); + + if (lhs) { + return lhs; + } + } + p = p.parentPath as NodePath; + } + } + + return undefined; +} + +const exportNameHandler: Handler = (documentation, componentDefinition) => { + // 1) Inline default export (e.g., `export default function Foo(){}`) + if (isInlineDefaultExport(componentDefinition)) { + documentation.set('exportName', 'default'); + return; + } + + // 2) Resolve local name we’ll match against exports + const actual = documentation.get('actualName'); + const actualName = typeof actual === 'string' ? actual : undefined; + const localName = getLocalName(componentDefinition, actualName); + + const programPath = findProgram(componentDefinition); + if (!programPath) { + documentation.set('exportName', undefined); + return; + } + + const body = programPath.get('body'); + + // 3) Scan top-level export statements + for (const stmt of body) { + // A) `export const Foo = ...`, `export function Foo(){}`, `export class Foo {}` + if (stmt.isExportNamedDeclaration() && stmt.has('declaration')) { + const decl = stmt.get('declaration'); + + if (decl.isFunctionDeclaration() || decl.isClassDeclaration()) { + const name = nameFromId(decl.get('id') as NodePath); + if (name && name === localName) { + documentation.set('exportName', name); + return; + } + } + + if (decl.isVariableDeclaration()) { + const decls = decl.get('declarations'); + for (const d of decls) { + if (d.isVariableDeclarator()) { + const id = d.get('id'); + if (id.isIdentifier() && id.node.name === localName) { + documentation.set('exportName', localName); + return; + } + } + } + } + } + + // B) `export { Foo }`, `export { Foo as Bar }`, `export { Foo as default }` + if (stmt.isExportNamedDeclaration() && stmt.has('specifiers')) { + const specs = stmt.get('specifiers'); + for (const s of specs) { + if (s.isExportSpecifier()) { + const local = nameFromId(s.get('local')); + const exported = nameFromId(s.get('exported')); + if (local && local === localName) { + documentation.set( + 'exportName', + exported === 'default' ? 'default' : (exported ?? local) + ); + return; + } + } + } + } + + // C) `export default Foo` + if (stmt.isExportDefaultDeclaration()) { + const decl = stmt.get('declaration'); + if (decl.isIdentifier() && decl.node.name === localName) { + documentation.set('exportName', 'default'); + return; + } + } + } + + // 4) Not exported / unknown + documentation.set('exportName', undefined); +}; + +export default exportNameHandler; diff --git a/code/renderers/react/src/componentManifest/utils.ts b/code/renderers/react/src/componentManifest/utils.ts new file mode 100644 index 000000000000..69e40f0c5bd0 --- /dev/null +++ b/code/renderers/react/src/componentManifest/utils.ts @@ -0,0 +1,12 @@ +// Object.groupBy polyfill +export const groupBy = ( + items: T[], + keySelector: (item: T, index: number) => K +) => { + return items.reduce>>((acc = {}, item, index) => { + const key = keySelector(item, index); + acc[key] ??= []; + acc[key].push(item); + return acc; + }, {}); +}; diff --git a/code/renderers/react/src/preset.ts b/code/renderers/react/src/preset.ts index bc157cfd14a8..11018a9beb1d 100644 --- a/code/renderers/react/src/preset.ts +++ b/code/renderers/react/src/preset.ts @@ -8,6 +8,8 @@ export const addons: PresetProperty<'addons'> = [ import.meta.resolve('@storybook/react-dom-shim/preset'), ]; +export { componentManifestGenerator as experimental_componentManifestGenerator } from './componentManifest/generator'; + export const previewAnnotations: PresetProperty<'previewAnnotations'> = async ( input = [], options diff --git a/code/yarn.lock b/code/yarn.lock index 38ca6d66fcc7..24e7bc145b8b 100644 --- a/code/yarn.lock +++ b/code/yarn.lock @@ -6825,11 +6825,13 @@ __metadata: acorn-jsx: "npm:^5.3.1" acorn-walk: "npm:^7.2.0" babel-plugin-react-docgen: "npm:^4.2.1" + comment-parser: "npm:^1.4.1" es-toolkit: "npm:^1.36.0" escodegen: "npm:^2.1.0" expect-type: "npm:^0.15.0" html-tags: "npm:^3.1.0" prop-types: "npm:^15.7.2" + react-docgen: "npm:^8.0.2" react-element-to-jsx-string: "npm:@7rulnik/react-element-to-jsx-string@15.0.1" require-from-string: "npm:^2.0.2" semver: "npm:^7.3.7" @@ -22204,6 +22206,24 @@ __metadata: languageName: node linkType: hard +"react-docgen@npm:^8.0.2": + version: 8.0.2 + resolution: "react-docgen@npm:8.0.2" + dependencies: + "@babel/core": "npm:^7.28.0" + "@babel/traverse": "npm:^7.28.0" + "@babel/types": "npm:^7.28.2" + "@types/babel__core": "npm:^7.20.5" + "@types/babel__traverse": "npm:^7.20.7" + "@types/doctrine": "npm:^0.0.9" + "@types/resolve": "npm:^1.20.2" + doctrine: "npm:^3.0.0" + resolve: "npm:^1.22.1" + strip-indent: "npm:^4.0.0" + checksum: 10c0/25e2dd48957c52749cf44bdcf172f3b47d42d8bb8c51000bceb136ff018cbe0a78610d04f12d8bbb882df0d86884e8d05b1d7a1cc39586de356ef5bb9fceab71 + languageName: node + linkType: hard + "react-dom@npm:^18.2.0": version: 18.3.1 resolution: "react-dom@npm:18.3.1"