diff --git a/code/core/src/core-server/build-static.ts b/code/core/src/core-server/build-static.ts index 96679b114bd4..4b4058550ddc 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,23 @@ export async function buildStaticStandalone(options: BuildStaticStandaloneOption initializedStoryIndexGenerator as Promise ) ); + + const features = await presets.apply('features'); + + 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..56fba2aaff5b 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,32 @@ export async function storybookDevServer(options: Options) { throw indexError; } + app.use('/manifests/components.json', async (req, res) => { + try { + const features = await options.presets.apply('features'); + if (!features?.experimental_componentsManifest) { + const 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..3fd51cc0b87a 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,17 @@ 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; + 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 +368,7 @@ export interface StorybookConfigRaw { */ addons?: Preset[]; core?: CoreConfig; + componentManifestGenerator?: ComponentManifestGenerator; staticDirs?: (DirectoryMapping | string)[]; logLevel?: string; features?: { @@ -453,6 +466,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/__mocks__/fs/promises.cjs b/code/renderers/react/__mocks__/fs/promises.cjs new file mode 100644 index 000000000000..bccdebb932d9 --- /dev/null +++ b/code/renderers/react/__mocks__/fs/promises.cjs @@ -0,0 +1,3 @@ +const { fs } = require('memfs'); + +module.exports = fs.promises; diff --git a/code/renderers/react/package.json b/code/renderers/react/package.json index c71b26976c3e..a8a08e8f2453 100644 --- a/code/renderers/react/package.json +++ b/code/renderers/react/package.json @@ -66,6 +66,7 @@ "acorn-jsx": "^5.3.1", "acorn-walk": "^7.2.0", "babel-plugin-react-docgen": "^4.2.1", + "comment-parser": "^1.4.1", "es-toolkit": "^1.36.0", "escodegen": "^2.1.0", "expect-type": "^0.15.0", diff --git a/code/renderers/react/src/component-manifest/docgen-handlers/actualNameHandler.ts b/code/renderers/react/src/component-manifest/docgen-handlers/actualNameHandler.ts new file mode 100644 index 000000000000..6b91fa7fcb1d --- /dev/null +++ b/code/renderers/react/src/component-manifest/docgen-handlers/actualNameHandler.ts @@ -0,0 +1,56 @@ +/** + * This is heavily based on the react-docgen `displayNameHandler` + * (https://github.com/reactjs/react-docgen/blob/26c90c0dd105bf83499a83826f2a6ff7a724620d/src/handlers/displayNameHandler.ts) + * but instead defines an `actualName` property on the generated docs that is taken first from the + * component's actual name. This addresses an issue where the name that the generated docs are + * stored under is incorrectly named with the `displayName` and not the component's actual name. + * + * This is inspired by `actualNameHandler` from + * https://github.com/storybookjs/babel-plugin-react-docgen, but is modified directly from + * displayNameHandler, using the same approach as babel-plugin-react-docgen. + */ +import type { Handler, NodePath, babelTypes as t } from 'react-docgen'; +import { utils } from 'react-docgen'; + +const { getNameOrValue, isReactForwardRefCall } = utils; + +const actualNameHandler: Handler = function actualNameHandler(documentation, componentDefinition) { + documentation.set('definedInFile', componentDefinition.hub.file.opts.filename); + + if ( + (componentDefinition.isClassDeclaration() || componentDefinition.isFunctionDeclaration()) && + componentDefinition.has('id') + ) { + documentation.set( + 'actualName', + getNameOrValue(componentDefinition.get('id') as NodePath) + ); + } else if ( + componentDefinition.isArrowFunctionExpression() || + componentDefinition.isFunctionExpression() || + isReactForwardRefCall(componentDefinition) + ) { + let currentPath: NodePath = componentDefinition; + + while (currentPath.parentPath) { + if (currentPath.parentPath.isVariableDeclarator()) { + documentation.set('actualName', getNameOrValue(currentPath.parentPath.get('id'))); + return; + } + if (currentPath.parentPath.isAssignmentExpression()) { + const leftPath = currentPath.parentPath.get('left'); + + if (leftPath.isIdentifier() || leftPath.isLiteral()) { + documentation.set('actualName', getNameOrValue(leftPath)); + return; + } + } + + currentPath = currentPath.parentPath; + } + // Could not find an actual name + documentation.set('actualName', ''); + } +}; + +export default actualNameHandler; diff --git a/code/renderers/react/src/component-manifest/docgen-resolver.ts b/code/renderers/react/src/component-manifest/docgen-resolver.ts new file mode 100644 index 000000000000..f4ae37407c09 --- /dev/null +++ b/code/renderers/react/src/component-manifest/docgen-resolver.ts @@ -0,0 +1,75 @@ +import { extname } from 'node:path'; + +import resolve from 'resolve'; + +export class ReactDocgenResolveError extends Error { + // the magic string that react-docgen uses to check if a module is ignored + readonly code = 'MODULE_NOT_FOUND'; + + constructor(filename: string) { + super(`'${filename}' was ignored by react-docgen.`); + } +} + +/* The below code was copied from: + * https://github.com/reactjs/react-docgen/blob/df2daa8b6f0af693ecc3c4dc49f2246f60552bcb/packages/react-docgen/src/importer/makeFsImporter.ts#L14-L63 + * because it wasn't exported from the react-docgen package. + * watch out: when updating this code, also update the code in code/presets/react-webpack/src/loaders/docgen-resolver.ts + */ + +// These extensions are sorted by priority +// resolve() will check for files in the order these extensions are sorted +export const RESOLVE_EXTENSIONS = [ + '.js', + '.cts', // These were originally not in the code, I added them + '.mts', // These were originally not in the code, I added them + '.ctsx', // These were originally not in the code, I added them + '.mtsx', // These were originally not in the code, I added them + '.ts', + '.tsx', + '.mjs', + '.cjs', + '.mts', + '.cts', + '.jsx', +]; + +export function defaultLookupModule(filename: string, basedir: string): string { + const resolveOptions = { + basedir, + extensions: RESOLVE_EXTENSIONS, + // we do not need to check core modules as we cannot import them anyway + includeCoreModules: false, + }; + + try { + return resolve.sync(filename, resolveOptions); + } catch (error) { + const ext = extname(filename); + let newFilename: string; + + // if we try to import a JavaScript file it might be that we are actually pointing to + // a TypeScript file. This can happen in ES modules as TypeScript requires to import other + // TypeScript files with .js extensions + // https://www.typescriptlang.org/docs/handbook/esm-node.html#type-in-packagejson-and-new-extensions + switch (ext) { + case '.js': + case '.mjs': + case '.cjs': + newFilename = `${filename.slice(0, -2)}ts`; + break; + + case '.jsx': + newFilename = `${filename.slice(0, -3)}tsx`; + break; + default: + throw error; + } + + return resolve.sync(newFilename, { + ...resolveOptions, + // we already know that there is an extension at this point, so no need to check other extensions + extensions: [], + }); + } +} diff --git a/code/renderers/react/src/component-manifest/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/component-manifest/generator.test.ts b/code/renderers/react/src/component-manifest/generator.test.ts new file mode 100644 index 000000000000..18c94d11866c --- /dev/null +++ b/code/renderers/react/src/component-manifest/generator.test.ts @@ -0,0 +1,265 @@ +import { beforeEach, expect, test, vi } from 'vitest'; + +import { type StoryIndexGenerator } from 'storybook/internal/core-server'; + +import { vol } from 'memfs'; +import { dedent } from 'ts-dedent'; + +import { componentManifestGenerator } from './generator'; + +vi.mock('node:fs/promises'); + +// Use the provided indexJson from this file +const indexJson = { + v: 5, + entries: { + 'example-button--primary': { + type: 'story', + subtype: 'story', + id: 'example-button--primary', + name: 'Primary', + title: 'Example/Button', + importPath: './src/stories/Button.stories.ts', + componentPath: './src/stories/Button.tsx', + tags: ['dev', 'test', 'vitest', 'autodocs'], + exportName: 'Primary', + }, + 'example-button--secondary': { + type: 'story', + subtype: 'story', + id: 'example-button--secondary', + name: 'Secondary', + title: 'Example/Button', + importPath: './src/stories/Button.stories.ts', + componentPath: './src/stories/Button.tsx', + tags: ['dev', 'test', 'vitest', 'autodocs'], + exportName: 'Secondary', + }, + 'example-button--large': { + type: 'story', + subtype: 'story', + id: 'example-button--large', + name: 'Large', + title: 'Example/Button', + importPath: './src/stories/Button.stories.ts', + componentPath: './src/stories/Button.tsx', + tags: ['dev', 'test', 'vitest', 'autodocs'], + exportName: 'Large', + }, + 'example-button--small': { + type: 'story', + subtype: 'story', + id: 'example-button--small', + name: 'Small', + title: 'Example/Button', + importPath: './src/stories/Button.stories.ts', + componentPath: './src/stories/Button.tsx', + tags: ['dev', 'test', 'vitest', 'autodocs'], + exportName: 'Small', + }, + 'example-header--docs': { + id: 'example-header--docs', + title: 'Example/Header', + name: 'Docs', + importPath: './src/stories/Header.stories.ts', + type: 'docs', + tags: ['dev', 'test', 'vitest', 'autodocs'], + storiesImports: [], + }, + 'example-header--logged-in': { + type: 'story', + subtype: 'story', + id: 'example-header--logged-in', + name: 'Logged In', + title: 'Example/Header', + importPath: './src/stories/Header.stories.ts', + componentPath: './src/stories/Header.tsx', + tags: ['dev', 'test', 'vitest', 'autodocs'], + exportName: 'LoggedIn', + }, + 'example-header--logged-out': { + type: 'story', + subtype: 'story', + id: 'example-header--logged-out', + name: 'Logged Out', + title: 'Example/Header', + importPath: './src/stories/Header.stories.ts', + componentPath: './src/stories/Header.tsx', + tags: ['dev', 'test', 'vitest', 'autodocs'], + exportName: 'LoggedOut', + }, + }, +}; + +beforeEach(() => { + vol.fromJSON({ + ['./src/stories/Button.stories.ts']: dedent` + import type { Meta, StoryObj } from '@storybook/react'; + import { fn } from 'storybook/test'; + import { Button } from './Button'; + + const meta = { + component: Button, + args: { onClick: fn() }, + } satisfies Meta; + export default meta; + type Story = StoryObj; + + export const Primary: Story = { args: { primary: true, label: 'Button' } }; + export const Secondary: Story = { args: { label: 'Button' } }; + export const Large: Story = { args: { size: 'large', label: 'Button' } }; + export const Small: Story = { args: { size: 'small', label: 'Button' } };`, + ['./src/stories/Button.tsx']: dedent` + import React from 'react'; + export interface ButtonProps { + primary?: boolean; + backgroundColor?: string; + size?: 'small' | 'medium' | 'large'; + label: string; + onClick?: () => void; + } + + /** Primary UI component for user interaction */ + export const Button = ({ + primary = false, + size = 'medium', + backgroundColor, + label, + ...props + }: ButtonProps) => { + const mode = primary ? 'storybook-button--primary' : 'storybook-button--secondary'; + return ( + + ); + };`, + ['./src/stories/Header.stories.ts']: dedent` + import type { Meta, StoryObj } from '@storybook/react'; + import { fn } from 'storybook/test'; + import { Header } from './Header'; + + /** + * 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 const Header = ({ user, onLogin, onLogout, onCreateAccount }: HeaderProps) => ( +
+
+
+ {user ? ( + <> + + Welcome, {user.name}! + +
+
+
+ );`, + }); + 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(` + { + "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, + "jsdocTag": {}, + "name": "Button", + "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';", + "jsdocTag": { + "import": [ + "import { Header } from '@design-system/components/Header';", + ], + "summary": [ + "Component summary", + ], + }, + "name": "Header", + "summary": "Component summary", + }, + } + `); +}); diff --git a/code/renderers/react/src/component-manifest/generator.ts b/code/renderers/react/src/component-manifest/generator.ts new file mode 100644 index 000000000000..a821666fcdb9 --- /dev/null +++ b/code/renderers/react/src/component-manifest/generator.ts @@ -0,0 +1,67 @@ +import { readFile } from 'node:fs/promises'; + +import { recast } from 'storybook/internal/babel'; +import { loadCsf } from 'storybook/internal/csf-tools'; +import { extractDescription } from 'storybook/internal/csf-tools'; +import { type ComponentManifestGenerator } from 'storybook/internal/types'; + +import path from 'pathe'; + +import { getCodeSnippet } from './generateCodeSnippet'; +import { extractJSDocTags, removeTags } from './jsdoc-tags'; +import { getMatchingDocgen, parseWithReactDocgen } from './react-docgen'; +import { groupBy } from './utils'; + +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 storyFile = await readFile(path.join(process.cwd(), entry.importPath), 'utf-8'); + const componentFile = await readFile( + path.join(process.cwd(), entry.componentPath!), + 'utf-8' + ); + const csf = loadCsf(storyFile, { makeTitle: (title) => title ?? 'No title' }).parse(); + const componentName = csf._meta?.component; + const docgens = await parseWithReactDocgen({ + code: componentFile, + filename: path.join(process.cwd(), entry.importPath), + }); + const docgen = getMatchingDocgen(docgens, componentName); + + const metaDescription = extractDescription(csf._metaStatement); + const description = metaDescription || docgen?.description; + const tags = description ? extractJSDocTags(description) : {}; + return { + id: entry.id.split('--')[0], + name: componentName, + description: description ? removeTags(description) : undefined, + jsdocTag: tags, + summary: tags.summary ? tags.summary[0] : undefined, + import: tags.import ? tags.import[0] : undefined, + examples: !componentName + ? [] + : Object.entries(csf._storyPaths) + .map(([name, path]) => ({ + name, + snippet: recast.print(getCodeSnippet(path, csf._metaNode ?? null, componentName)) + .code, + })) + .filter(Boolean), + }; + }) + ); + + return Object.fromEntries(components.map((component) => [component.id, component])); + }) satisfies ComponentManifestGenerator; +}; diff --git a/code/renderers/react/src/component-manifest/jsdoc-tags.test.ts b/code/renderers/react/src/component-manifest/jsdoc-tags.test.ts new file mode 100644 index 000000000000..b0c00661fb07 --- /dev/null +++ b/code/renderers/react/src/component-manifest/jsdoc-tags.test.ts @@ -0,0 +1,34 @@ +import { expect, it } from 'vitest'; + +import { dedent } from 'ts-dedent'; + +import { extractJSDocTags } from './jsdoc-tags'; + +it('should extract @summary tag', () => { + const code = dedent`@summary This is the summary`; + const tags = extractJSDocTags(code); + expect(tags).toMatchInlineSnapshot(` + { + "summary": [ + "This is the summary", + ], + } + `); +}); + +it('should extract @param tag with type', () => { + const code = dedent` + @param {Object} employee - The employee who is responsible for the project. + @param {string} employee.name - The name of the employee. + @param {string} employee.department - The employee's department.`; + const tags = extractJSDocTags(code); + expect(tags).toMatchInlineSnapshot(` + { + "param": [ + "{Object} employee - The employee who is responsible for the project.", + "{string} employee.name - The name of the employee.", + "{string} employee.department - The employee's department.", + ], + } + `); +}); diff --git a/code/renderers/react/src/component-manifest/jsdoc-tags.ts b/code/renderers/react/src/component-manifest/jsdoc-tags.ts new file mode 100644 index 000000000000..5db83721329c --- /dev/null +++ b/code/renderers/react/src/component-manifest/jsdoc-tags.ts @@ -0,0 +1,24 @@ +import { parse } from 'comment-parser'; + +import { groupBy } from './utils'; + +export function extractJSDocTags(description: string) { + const lines = description.split('\n'); + const jsDoc = ['/**', ...lines.map((line) => ` * ${line}`), ' */'].join('\n'); + + const parsed = parse(jsDoc); + + return Object.fromEntries( + Object.entries(groupBy(parsed[0].tags, (it) => it.tag)).map(([key, tags]) => [ + key, + tags?.map((tag) => (tag.type ? `{${tag.type}} ` : '') + `${tag.name} ${tag.description}`), + ]) + ); +} + +export function removeTags(description: string) { + return description + .split('\n') + .filter((line) => !line.trim().startsWith('@')) + .join('\n'); +} diff --git a/code/renderers/react/src/component-manifest/react-docgen.ts b/code/renderers/react/src/component-manifest/react-docgen.ts new file mode 100644 index 000000000000..66a0333dc47f --- /dev/null +++ b/code/renderers/react/src/component-manifest/react-docgen.ts @@ -0,0 +1,103 @@ +import { existsSync } from 'node:fs'; +import { sep } from 'node:path'; + +import { getProjectRoot } from 'storybook/internal/common'; +import { logger } from 'storybook/internal/node-logger'; + +import * as find from 'empathic/find'; +import type { Documentation } from 'react-docgen'; +import { + builtinHandlers as docgenHandlers, + builtinResolvers as docgenResolver, + makeFsImporter, + parse, +} from 'react-docgen'; +import * as TsconfigPaths from 'tsconfig-paths'; + +import actualNameHandler from './docgen-handlers/actualNameHandler'; +import { + RESOLVE_EXTENSIONS, + ReactDocgenResolveError, + defaultLookupModule, +} from './docgen-resolver'; + +type DocObj = Documentation & { actualName: string; definedInFile: string }; + +// TODO: None of these are able to be overridden, so `default` is aspirational here. +const defaultHandlers = Object.values(docgenHandlers).map((handler) => handler); +const defaultResolver = new docgenResolver.FindExportedDefinitionsResolver(); +const handlers = [...defaultHandlers, actualNameHandler]; + +export function getMatchingDocgen(docgens: DocObj[], componentName: string | undefined) { + if (docgens.length === 1) { + return docgens[0]; + } + if (!componentName) { + return; + } + const docgen = docgens.find( + (docgen) => docgen.displayName === componentName || docgen.actualName === componentName + ); + if (docgen) { + return docgen; + } +} + +export async function parseWithReactDocgen({ code, filename }: { code: string; filename: string }) { + const tsconfigPath = find.up('tsconfig.json', { cwd: process.cwd(), last: getProjectRoot() }); + const tsconfig = TsconfigPaths.loadConfig(tsconfigPath); + + let matchPath: TsconfigPaths.MatchPath | undefined; + + if (tsconfig.resultType === 'success') { + logger.info('Using tsconfig paths for react-docgen'); + matchPath = TsconfigPaths.createMatchPath(tsconfig.absoluteBaseUrl, tsconfig.paths, [ + 'browser', + 'module', + 'main', + ]); + } + + try { + return parse(code, { + resolver: defaultResolver, + handlers, + importer: getReactDocgenImporter(matchPath), + filename, + }) as DocObj[]; + } catch (e) { + return []; + } +} + +export function getReactDocgenImporter(matchPath: TsconfigPaths.MatchPath | undefined) { + return makeFsImporter((filename, basedir) => { + const mappedFilenameByPaths = (() => { + if (matchPath) { + const match = matchPath(filename); + return match || filename; + } else { + return filename; + } + })(); + + const result = defaultLookupModule(mappedFilenameByPaths, basedir); + + if (result.includes(`${sep}react-native${sep}index.js`)) { + const replaced = result.replace( + `${sep}react-native${sep}index.js`, + `${sep}react-native-web${sep}dist${sep}index.js` + ); + if (existsSync(replaced)) { + if (RESOLVE_EXTENSIONS.find((ext) => result.endsWith(ext))) { + return replaced; + } + } + } + if (RESOLVE_EXTENSIONS.find((ext) => result.endsWith(ext))) { + return result; + } + + throw new ReactDocgenResolveError(filename); + }); +} diff --git a/code/renderers/react/src/component-manifest/utils.ts b/code/renderers/react/src/component-manifest/utils.ts new file mode 100644 index 000000000000..bd19e7ce6fcb --- /dev/null +++ b/code/renderers/react/src/component-manifest/utils.ts @@ -0,0 +1,13 @@ +// Object.groupBy polyfill +export const groupBy = ( + items: T[], + keySelector: (item: T, index: number) => K +) => { + return items.reduce>>((acc = {}, item, index) => { + const key = keySelector(item, index); + acc[key] ??= []; + acc[key].push(item); + return acc; + }, {}); +}; + diff --git a/code/renderers/react/src/preset.ts b/code/renderers/react/src/preset.ts index bc157cfd14a8..4586681cdae1 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 } from './component-manifest/generator'; + export const previewAnnotations: PresetProperty<'previewAnnotations'> = async ( input = [], options diff --git a/code/yarn.lock b/code/yarn.lock index fbc0b812a177..cc935e924e02 100644 --- a/code/yarn.lock +++ b/code/yarn.lock @@ -6841,6 +6841,7 @@ __metadata: acorn-jsx: "npm:^5.3.1" acorn-walk: "npm:^7.2.0" babel-plugin-react-docgen: "npm:^4.2.1" + comment-parser: "npm:^1.4.1" es-toolkit: "npm:^1.36.0" escodegen: "npm:^2.1.0" expect-type: "npm:^0.15.0"