diff --git a/code/.storybook/main.ts b/code/.storybook/main.ts index 279e8a138b51..d5e117afdc74 100644 --- a/code/.storybook/main.ts +++ b/code/.storybook/main.ts @@ -138,6 +138,7 @@ const config = defineMain({ developmentModeForBuild: true, experimentalTestSyntax: true, experimentalComponentsManifest: true, + experimentalCodeExamples: true, }, staticDirs: [{ from: './bench/bundle-analyzer', to: '/bundle-analyzer' }], viteFinal: async (viteConfig, { configType }) => { diff --git a/code/addons/docs/src/preset.ts b/code/addons/docs/src/preset.ts index 28316ad5580d..3b9e463a07eb 100644 --- a/code/addons/docs/src/preset.ts +++ b/code/addons/docs/src/preset.ts @@ -3,6 +3,7 @@ import { fileURLToPath } from 'node:url'; import { logger } from 'storybook/internal/node-logger'; import type { Options, PresetProperty, StorybookConfigRaw } from 'storybook/internal/types'; +import { type CsfEnricher } from 'storybook/internal/types'; import type { CsfPluginOptions } from '@storybook/csf-plugin'; @@ -41,6 +42,8 @@ async function webpack( const { csfPluginOptions = {}, mdxPluginOptions = {} } = options; + const enrichCsf = await options.presets.apply('experimental_enrichCsf'); + const rehypeSlug = (await import('rehype-slug')).default; const rehypeExternalLinks = (await import('rehype-external-links')).default; @@ -100,7 +103,12 @@ async function webpack( ...(webpackConfig.plugins || []), ...(csfPluginOptions - ? [(await import('@storybook/csf-plugin')).webpack(csfPluginOptions)] + ? [ + (await import('@storybook/csf-plugin')).webpack({ + ...csfPluginOptions, + enrichCsf, + }), + ] : []), ], resolve: { diff --git a/code/builders/builder-vite/src/plugins/csf-plugin.ts b/code/builders/builder-vite/src/plugins/csf-plugin.ts index 2541f7b74758..d941d2968812 100644 --- a/code/builders/builder-vite/src/plugins/csf-plugin.ts +++ b/code/builders/builder-vite/src/plugins/csf-plugin.ts @@ -12,6 +12,11 @@ export async function csfPlugin(config: Options): Promise { // @ts-expect-error - not sure what type to use here addons.find((a) => [a, a.name].includes('@storybook/addon-docs'))?.options ?? {}; + const enrichCsf = await presets.apply('experimental_enrichCsf'); + // TODO: looks like unplugin can return an array of plugins - return vite(docsOptions?.csfPluginOptions) as Plugin; + return vite({ + ...docsOptions?.csfPluginOptions, + enrichCsf, + }) as Plugin; } diff --git a/code/core/src/common/presets.ts b/code/core/src/common/presets.ts index 818e0ce41d8b..cb340f7cb6b2 100644 --- a/code/core/src/common/presets.ts +++ b/code/core/src/common/presets.ts @@ -20,7 +20,7 @@ import { getInterpretedFile } from './utils/interpret-files'; import { stripAbsNodeModulesPath } from './utils/strip-abs-node-modules-path'; import { validateConfigurationFiles } from './utils/validate-configuration-files'; -type InterPresetOptions = Omit< +export type InterPresetOptions = Omit< CLIOptions & LoadOptions & BuilderOptions & { isCritical?: boolean; build?: StorybookConfigRaw['build'] }, @@ -321,7 +321,7 @@ export async function getPresets( const loadedPresets: LoadedPreset[] = await loadPresets(presets, 0, storybookOptions); return { - apply: async (extension: string, config: any, args = {}) => + apply: async (extension: string, config?: any, args = {}) => applyPresets(loadedPresets, extension, config, args, storybookOptions), }; } diff --git a/code/core/src/common/utils/formatter.ts b/code/core/src/common/utils/formatter.ts index 83b0e6d72ba8..67114256ba7c 100644 --- a/code/core/src/common/utils/formatter.ts +++ b/code/core/src/common/utils/formatter.ts @@ -1,4 +1,4 @@ -async function getPrettier() { +export async function getPrettier() { return import('prettier').catch((e) => ({ resolveConfig: async () => null, format: (content: string) => content, diff --git a/code/core/src/core-server/presets/favicon.test.ts b/code/core/src/core-server/presets/favicon.test.ts index dada54a547ca..13c1593775ae 100644 --- a/code/core/src/core-server/presets/favicon.test.ts +++ b/code/core/src/core-server/presets/favicon.test.ts @@ -4,6 +4,7 @@ import { dirname, join } from 'node:path'; import { expect, it, vi } from 'vitest'; import { logger } from 'storybook/internal/node-logger'; +import { type Presets } from 'storybook/internal/types'; import * as m from './common-preset'; @@ -41,7 +42,7 @@ const createOptions = (locations: string[]): Parameters[1] => } } }, - }, + } as Presets, }); vi.mock('storybook/internal/node-logger', () => { diff --git a/code/core/src/csf-tools/enrichCsf.test.ts b/code/core/src/csf-tools/enrichCsf.test.ts index 99572bde7a62..fe766ff30588 100644 --- a/code/core/src/csf-tools/enrichCsf.test.ts +++ b/code/core/src/csf-tools/enrichCsf.test.ts @@ -11,7 +11,7 @@ expect.addSnapshotSerializer({ test: () => true, }); -const enrich = (code: string, originalCode: string, options?: EnrichCsfOptions) => { +const enrich = async (code: string, originalCode: string, options?: EnrichCsfOptions) => { // we don't actually care about the title const csf = loadCsf(code, { @@ -20,15 +20,15 @@ const enrich = (code: string, originalCode: string, options?: EnrichCsfOptions) const csfSource = loadCsf(originalCode, { makeTitle: (userTitle) => userTitle || 'default', }).parse(); - enrichCsf(csf, csfSource, options); + await enrichCsf(csf, csfSource, options); return formatCsf(csf); }; describe('enrichCsf', () => { describe('source', () => { - it('csf1', () => { + it('csf1', async () => { expect( - enrich( + await enrich( dedent` // compiled code export default { @@ -62,9 +62,9 @@ describe('enrichCsf', () => { }; `); }); - it('csf2', () => { + it('csf2', async () => { expect( - enrich( + await enrich( dedent` // compiled code export default { @@ -106,9 +106,9 @@ describe('enrichCsf', () => { }; `); }); - it('csf3', () => { + it('csf3', async () => { expect( - enrich( + await enrich( dedent` // compiled code export default { @@ -148,9 +148,9 @@ describe('enrichCsf', () => { }; `); }); - it('csf factories', () => { + it('csf factories', async () => { expect( - enrich( + await enrich( dedent` // compiled code import {config} from "/.storybook/preview.ts"; @@ -193,9 +193,9 @@ describe('enrichCsf', () => { }; `); }); - it('multiple stories', () => { + it('multiple stories', async () => { expect( - enrich( + await enrich( dedent` // compiled code export default { @@ -245,9 +245,9 @@ describe('enrichCsf', () => { }); describe('story descriptions', () => { - it('skips inline comments', () => { + it('skips inline comments', async () => { expect( - enrich( + await enrich( dedent` // compiled code export default { @@ -285,9 +285,9 @@ describe('enrichCsf', () => { `); }); - it('skips blocks without jsdoc', () => { + it('skips blocks without jsdoc', async () => { expect( - enrich( + await enrich( dedent` // compiled code export default { @@ -323,9 +323,9 @@ describe('enrichCsf', () => { `); }); - it('JSDoc single-line', () => { + it('JSDoc single-line', async () => { expect( - enrich( + await enrich( dedent` // compiled code export default { @@ -365,9 +365,9 @@ describe('enrichCsf', () => { `); }); - it('JSDoc multi-line', () => { + it('JSDoc multi-line', async () => { expect( - enrich( + await enrich( dedent` // compiled code export default { @@ -411,9 +411,9 @@ describe('enrichCsf', () => { `); }); - it('preserves indentation', () => { + it('preserves indentation', async () => { expect( - enrich( + await enrich( dedent` // compiled code export default { @@ -459,9 +459,9 @@ describe('enrichCsf', () => { }); describe('meta descriptions', () => { - it('skips inline comments', () => { + it('skips inline comments', async () => { expect( - enrich( + await enrich( dedent` // compiled code export default { @@ -497,9 +497,9 @@ describe('enrichCsf', () => { `); }); - it('skips blocks without jsdoc', () => { + it('skips blocks without jsdoc', async () => { expect( - enrich( + await enrich( dedent` // compiled code export default { @@ -535,9 +535,9 @@ describe('enrichCsf', () => { `); }); - it('JSDoc single-line', () => { + it('JSDoc single-line', async () => { expect( - enrich( + await enrich( dedent` // compiled code export default { @@ -580,9 +580,9 @@ describe('enrichCsf', () => { `); }); - it('JSDoc multi-line', () => { + it('JSDoc multi-line', async () => { expect( - enrich( + await enrich( dedent` // compiled code export default { @@ -629,9 +629,9 @@ describe('enrichCsf', () => { `); }); - it('preserves indentation', () => { + it('preserves indentation', async () => { expect( - enrich( + await enrich( dedent` // compiled code export default { @@ -678,9 +678,9 @@ describe('enrichCsf', () => { `); }); - it('correctly interleaves parameters', () => { + it('correctly interleaves parameters', async () => { expect( - enrich( + await enrich( dedent` // compiled code export default { @@ -734,9 +734,9 @@ describe('enrichCsf', () => { `); }); - it('respects user component description', () => { + it('respects user component description', async () => { expect( - enrich( + await enrich( dedent` // compiled code export default { @@ -793,9 +793,9 @@ describe('enrichCsf', () => { `); }); - it('respects meta variables', () => { + it('respects meta variables', async () => { expect( - enrich( + await enrich( dedent` // compiled code const meta = { @@ -844,9 +844,9 @@ describe('enrichCsf', () => { }); describe('options', () => { - it('disableSource', () => { + it('disableSource', async () => { expect( - enrich( + await enrich( dedent` // compiled code export default { @@ -883,9 +883,9 @@ describe('enrichCsf', () => { `); }); - it('disableDescription', () => { + it('disableDescription', async () => { expect( - enrich( + await enrich( dedent` // compiled code export default { @@ -922,9 +922,9 @@ describe('enrichCsf', () => { `); }); - it('disable all', () => { + it('disable all', async () => { expect( - enrich( + await enrich( dedent` // compiled code export default { diff --git a/code/core/src/csf-tools/enrichCsf.ts b/code/core/src/csf-tools/enrichCsf.ts index eb8c2dfff7fe..9f3e8aaf6d03 100644 --- a/code/core/src/csf-tools/enrichCsf.ts +++ b/code/core/src/csf-tools/enrichCsf.ts @@ -1,10 +1,12 @@ import { generate, types as t } from 'storybook/internal/babel'; +import { type CsfEnricher } from 'storybook/internal/types'; import type { CsfFile } from './CsfFile'; export interface EnrichCsfOptions { disableSource?: boolean; disableDescription?: boolean; + enrichCsf?: CsfEnricher; } export const enrichCsfStory = ( @@ -139,8 +141,9 @@ export const enrichCsfMeta = (csf: CsfFile, csfSource: CsfFile, options?: Enrich } }; -export const enrichCsf = (csf: CsfFile, csfSource: CsfFile, options?: EnrichCsfOptions) => { +export const enrichCsf = async (csf: CsfFile, csfSource: CsfFile, options?: EnrichCsfOptions) => { enrichCsfMeta(csf, csfSource, options); + await options?.enrichCsf?.(csf, csfSource); Object.keys(csf._storyExports).forEach((key) => { enrichCsfStory(csf, csfSource, key, options); }); diff --git a/code/core/src/types/modules/core-common.ts b/code/core/src/types/modules/core-common.ts index 6c3d4eb23a52..c24da7a5d473 100644 --- a/code/core/src/types/modules/core-common.ts +++ b/code/core/src/types/modules/core-common.ts @@ -1,6 +1,7 @@ // 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 CsfFile } from 'storybook/internal/csf-tools'; import type { Server as HttpServer, IncomingMessage, ServerResponse } from 'http'; import type { Server as NetServer } from 'net'; @@ -106,6 +107,9 @@ export interface Presets { config?: StorybookConfigRaw['staticDirs'], args?: any ): Promise; + + /** The second and third parameter are not needed. And make type inference easier. */ + apply(extension: T): Promise; apply(extension: string, config?: T, args?: unknown): Promise; } @@ -360,6 +364,8 @@ export type ComponentManifestGenerator = ( storyIndexGenerator: StoryIndexGenerator ) => Promise; +export type CsfEnricher = (csf: CsfFile, csfSource: CsfFile) => Promise; + export interface StorybookConfigRaw { /** * Sets the addons you want to use with Storybook. @@ -374,6 +380,7 @@ export interface StorybookConfigRaw { addons?: Preset[]; core?: CoreConfig; componentManifestGenerator?: ComponentManifestGenerator; + experimental_enrichCsf?: CsfEnricher; staticDirs?: (DirectoryMapping | string)[]; logLevel?: string; features?: { @@ -473,6 +480,19 @@ export interface StorybookConfigRaw { angularFilterNonInputControls?: boolean; experimentalComponentsManifest?: boolean; + + /** + * Enables the new code example generation for React components. You can see those examples when + * clicking on the "Show code" button in the Storybook UI. + * + * We refactored the code examples by reading the actual source file. This should make the code + * examples a lot faster, more readable and more accurate. They are not dynamic though, it won't + * change if you change when using the control panel. + * + * @default false + * @experimental This feature is in early development and may change significantly in future releases. + */ + experimentalCodeExamples?: boolean; }; build?: TestBuildConfig; diff --git a/code/lib/csf-plugin/src/rollup-based-plugin.ts b/code/lib/csf-plugin/src/rollup-based-plugin.ts index aea557e96372..cac461195d21 100644 --- a/code/lib/csf-plugin/src/rollup-based-plugin.ts +++ b/code/lib/csf-plugin/src/rollup-based-plugin.ts @@ -23,7 +23,7 @@ export function rollupBasedPlugin(options: EnrichCsfOptions): Partial userTitle || 'default'; const csf = loadCsf(content, { makeTitle }).parse(); const csfSource = loadCsf(sourceCode, { makeTitle }).parse(); - enrichCsf(csf, csfSource, options); + await enrichCsf(csf, csfSource, options); const formattedCsf = formatCsf( csf, { sourceMaps: true, inputSourceMap: map, sourceFileName: id }, diff --git a/code/renderers/react/src/componentManifest/generator.test.ts b/code/renderers/react/src/componentManifest/generator.test.ts index e4b8f3294fbf..d50652724469 100644 --- a/code/renderers/react/src/componentManifest/generator.test.ts +++ b/code/renderers/react/src/componentManifest/generator.test.ts @@ -4,13 +4,13 @@ import { type StoryIndexGenerator } from 'storybook/internal/core-server'; import { vol } from 'memfs'; import { dedent } from 'ts-dedent'; -import * as TsconfigPaths from 'tsconfig-paths'; +import { loadConfig } 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); +vi.mock('tsconfig-paths', { spy: true }); // Use the provided indexJson from this file const indexJson = { @@ -95,10 +95,7 @@ const indexJson = { }; beforeEach(() => { - vi.mocked(TsconfigPaths.loadConfig).mockImplementation(() => ({ - resultType: null!, - message: null!, - })); + vi.mocked(loadConfig).mockImplementation(() => ({ resultType: null!, message: null! })); vi.spyOn(process, 'cwd').mockReturnValue('/app'); vol.fromJSON( { diff --git a/code/renderers/react/src/componentManifest/generator.ts b/code/renderers/react/src/componentManifest/generator.ts index 9867acd67e52..afe58b703144 100644 --- a/code/renderers/react/src/componentManifest/generator.ts +++ b/code/renderers/react/src/componentManifest/generator.ts @@ -9,7 +9,7 @@ import { type ComponentManifest } from 'storybook/internal/types'; import path from 'pathe'; import { getCodeSnippet } from './generateCodeSnippet'; -import { extractJSDocTags, removeTags } from './jsdocTags'; +import { extractJSDocInfo } from './jsdocTags'; import { type DocObj, getMatchingDocgen, parseWithReactDocgen } from './reactDocgen'; import { groupBy } from './utils'; @@ -69,16 +69,14 @@ export const componentManifestGenerator = async () => { const metaDescription = extractDescription(csf._metaStatement); const jsdocComment = metaDescription || docgen?.description; - const tags = jsdocComment ? extractJSDocTags(jsdocComment) : {}; + const { tags = {}, description } = jsdocComment ? extractJSDocInfo(jsdocComment) : {}; - const manifestDescription = jsdocComment - ? removeTags(tags.describe?.[0] || tags.desc?.[0] || jsdocComment).trim() - : undefined; + const manifestDescription = (tags?.describe?.[0] || tags?.desc?.[0]) ?? description; return { id, name: componentName, - description: manifestDescription, + description: manifestDescription?.trim(), summary: tags.summary?.[0], import: tags.import?.[0], reactDocgen: docgen, diff --git a/code/renderers/react/src/componentManifest/jsdocTags.test.ts b/code/renderers/react/src/componentManifest/jsdocTags.test.ts index fef2d44b1c78..20e365ac01f6 100644 --- a/code/renderers/react/src/componentManifest/jsdocTags.test.ts +++ b/code/renderers/react/src/componentManifest/jsdocTags.test.ts @@ -2,16 +2,19 @@ import { expect, it } from 'vitest'; import { dedent } from 'ts-dedent'; -import { extractJSDocTags } from './jsdocTags'; +import { extractJSDocInfo } from './jsdocTags'; it('should extract @summary tag', () => { - const code = dedent`@summary This is the summary`; - const tags = extractJSDocTags(code); + const code = dedent`description\n@summary\n my summary`; + const tags = extractJSDocInfo(code); expect(tags).toMatchInlineSnapshot(` { - "summary": [ - "This is the summary", - ], + "description": "description", + "tags": { + "summary": [ + " my summary", + ], + }, } `); }); @@ -21,14 +24,17 @@ it('should extract @param tag with type', () => { @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); + const tags = extractJSDocInfo(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.", - ], + "description": "", + "tags": { + "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 index 74e5f4486c3d..30c4210692b8 100644 --- a/code/renderers/react/src/componentManifest/jsdocTags.ts +++ b/code/renderers/react/src/componentManifest/jsdocTags.ts @@ -2,24 +2,20 @@ import { parse } from 'comment-parser'; import { groupBy } from './utils'; -export function extractJSDocTags(jsdocComment: string) { +export function extractJSDocInfo(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'); + return { + description: parsed[0].description, + tags: 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}`) ?? + [], + ]) + ), + }; } diff --git a/code/renderers/react/src/enrichCsf.test.ts b/code/renderers/react/src/enrichCsf.test.ts new file mode 100644 index 000000000000..ed25d1de06a3 --- /dev/null +++ b/code/renderers/react/src/enrichCsf.test.ts @@ -0,0 +1,108 @@ +import { register } from 'node:module'; + +import { beforeEach, expect, test, vi } from 'vitest'; + +import { generate } from 'storybook/internal/babel'; +import { type InterPresetOptions, getPresets } from 'storybook/internal/common'; +import { loadCsf } from 'storybook/internal/csf-tools'; + +import { dedent } from 'ts-dedent'; + +import { enrichCsf } from './enrichCsf'; + +vi.mock('node:module', { spy: true }); +vi.mock('my-preset', () => ({ + default: { experimental_enrichCsf: enrichCsf, features: { experimentalCodeExamples: true } }, +})); + +beforeEach(() => { + vi.mocked(register).mockImplementation(() => {}); +}); + +test('should enrich csf with code parameters', async () => { + const presets = await getPresets(['my-preset'], { isCritical: true } as InterPresetOptions); + const enrichCsf = await presets.apply('experimental_enrichCsf'); + + const code = dedent` + import preview from '#.storybook/preview'; + import { Button } from './Button'; + + const meta = preview.meta({ component: Button }) + + export const Primary = meta.story({ args: { primary: true, label: 'Button' } }); + export const Secondary = meta.story({ args: { label: 'Button' } }); + `; + const csf = loadCsf(code, { makeTitle: (x) => x ?? 'title' }); + csf.parse(); + await enrichCsf?.(csf, csf); + expect(generate(csf._ast).code).toMatchInlineSnapshot(` + "import preview from '#.storybook/preview'; + import { Button } from './Button'; + const meta = preview.meta({ + component: Button + }); + export const Primary = meta.story({ + args: { + primary: true, + label: 'Button' + } + }); + export const Secondary = meta.story({ + args: { + label: 'Button' + } + }); + Primary.input.parameters = { + ...Primary.input.parameters, + docs: { + ...Primary.input.parameters?.docs, + source: { + code: "const Primary = () => ;\\n", + ...Primary.input.parameters?.docs?.source + } + } + }; + Secondary.input.parameters = { + ...Secondary.input.parameters, + docs: { + ...Secondary.input.parameters?.docs, + source: { + code: "const Secondary = () => ;\\n", + ...Secondary.input.parameters?.docs?.source + } + } + };" + `); +}); + +test('should not enrich when experimentalCodeExamples is disabled', async () => { + // @ts-expect-error module does not exist + vi.spyOn((await import('my-preset')).default, 'features', 'get').mockImplementation(() => ({ + experimentalCodeExamples: false, + })); + const presets = await getPresets(['my-preset'], { isCritical: true } as InterPresetOptions); + const enrichCsf = await presets.apply('experimental_enrichCsf'); + + const code = dedent` + import preview from '#.storybook/preview'; + import { Button } from './Button'; + const meta = preview.meta({ component: Button }) + export const Primary = meta.story({ args: { primary: true, label: 'Button' } }); + `; + const csf = loadCsf(code, { makeTitle: (x) => x ?? 'title' }); + csf.parse(); + await enrichCsf?.(csf, csf); + expect(generate(csf._ast).code).toMatchInlineSnapshot(` + "import preview from '#.storybook/preview'; + import { Button } from './Button'; + const meta = preview.meta({ + component: Button + }); + export const Primary = meta.story({ + args: { + primary: true, + label: 'Button' + } + });" + `); +}); diff --git a/code/renderers/react/src/enrichCsf.ts b/code/renderers/react/src/enrichCsf.ts new file mode 100644 index 000000000000..d56e2fc2ab3d --- /dev/null +++ b/code/renderers/react/src/enrichCsf.ts @@ -0,0 +1,115 @@ +import { type NodePath, recast, types as t } from 'storybook/internal/babel'; +import { getPrettier } from 'storybook/internal/common'; +import { type CsfFile } from 'storybook/internal/csf-tools'; +import type { PresetPropertyFn } from 'storybook/internal/types'; + +import { join } from 'pathe'; + +import { getCodeSnippet } from './componentManifest/generateCodeSnippet'; + +export const enrichCsf: PresetPropertyFn<'experimental_enrichCsf'> = async (input, options) => { + const features = await options.presets.apply('features'); + if (!features.experimentalCodeExamples) { + return; + } + return async (csf: CsfFile, csfSource: CsfFile) => { + const promises = Object.entries(csf._storyPaths).map(async ([key, storyExport]) => { + if (!csfSource._meta?.component) { + return; + } + const { format } = await getPrettier(); + + let snippet; + try { + const code = recast.print( + getCodeSnippet(storyExport, csfSource._metaNode, csfSource._meta?.component) + ).code; + + // TODO read the user config + snippet = await format(code, { filepath: join(process.cwd(), 'component.tsx') }); + } catch (e) { + // don't bother the user if we can't generate a snippet + return; + } + + const declaration = storyExport.get('declaration'); + if (!declaration.isVariableDeclaration()) { + return; + } + + const declarator = declaration.get('declarations')[0]; + const init = declarator.get('init') as NodePath; + + if (!init.isExpression()) { + return; + } + + const isCsfFactory = + t.isCallExpression(init.node) && + t.isMemberExpression(init.node.callee) && + t.isIdentifier(init.node.callee.object) && + init.node.callee.object.name === 'meta'; + + // e.g. Story.input.parameters + const originalParameters = t.memberExpression( + isCsfFactory + ? t.memberExpression(t.identifier(key), t.identifier('input')) + : t.identifier(key), + t.identifier('parameters') + ); + + // e.g. Story.input.parameters?.docs + const docsParameter = t.optionalMemberExpression( + originalParameters, + t.identifier('docs'), + false, + true + ); + + // For example: + // Story.input.parameters = { + // ...Story.input.parameters, + // docs: { + // ...Story.input.parameters?.docs, + // source: { + // code: "snippet", + // ...Story.input.parameters?.docs?.source + // } + // } + // }; + + csf._ast.program.body.push( + t.expressionStatement( + t.assignmentExpression( + '=', + originalParameters, + t.objectExpression([ + t.spreadElement(originalParameters), + t.objectProperty( + t.identifier('docs'), + t.objectExpression([ + t.spreadElement(docsParameter), + t.objectProperty( + t.identifier('source'), + t.objectExpression([ + t.objectProperty(t.identifier('code'), t.stringLiteral(snippet)), + t.spreadElement( + t.optionalMemberExpression( + docsParameter, + t.identifier('source'), + false, + true + ) + ), + ]) + ), + ]) + ), + ]) + ) + ) + ); + }); + await Promise.all(promises); + }; +}; diff --git a/code/renderers/react/src/entry-preview-docs.ts b/code/renderers/react/src/entry-preview-docs.ts index b649034286cd..6c35913476b5 100644 --- a/code/renderers/react/src/entry-preview-docs.ts +++ b/code/renderers/react/src/entry-preview-docs.ts @@ -3,7 +3,8 @@ import type { DecoratorFunction } from 'storybook/internal/types'; import { jsxDecorator } from './docs/jsxDecorator'; import type { ReactRenderer } from './types'; -export const decorators: DecoratorFunction[] = [jsxDecorator]; +export const decorators: DecoratorFunction[] = + 'FEATURES' in globalThis && globalThis?.FEATURES?.experimentalCodeExamples ? [] : [jsxDecorator]; export { applyDecorators } from './docs/applyDecorators'; diff --git a/code/renderers/react/src/preset.ts b/code/renderers/react/src/preset.ts index 86f4b7b27652..6e36130b3a27 100644 --- a/code/renderers/react/src/preset.ts +++ b/code/renderers/react/src/preset.ts @@ -10,6 +10,8 @@ export const addons: PresetProperty<'addons'> = [ export { componentManifestGenerator as experimental_componentManifestGenerator } from './componentManifest/generator'; +export { enrichCsf as experimental_enrichCsf } from './enrichCsf'; + export const previewAnnotations: PresetProperty<'previewAnnotations'> = async ( input = [], options diff --git a/code/renderers/react/vitest.config.ts b/code/renderers/react/vitest.config.ts index 7420176b2e46..a5664ea547c3 100644 --- a/code/renderers/react/vitest.config.ts +++ b/code/renderers/react/vitest.config.ts @@ -5,6 +5,8 @@ import { vitestCommonConfig } from '../../vitest.workspace'; export default mergeConfig( vitestCommonConfig, defineConfig({ - // Add custom config here + test: { + setupFiles: ['./vitest.setup.ts'], + }, }) ); diff --git a/code/renderers/react/vitest.setup.ts b/code/renderers/react/vitest.setup.ts new file mode 100644 index 000000000000..3245d9759235 --- /dev/null +++ b/code/renderers/react/vitest.setup.ts @@ -0,0 +1,6 @@ +import { afterEach, vi } from 'vitest'; + +afterEach(() => { + // can not run in beforeEach because then all { spy: true } mocks get removed + vi.restoreAllMocks(); +}); diff --git a/code/vitest-setup.ts b/code/vitest-setup.ts index 547fd65ebc90..a13755321eb5 100644 --- a/code/vitest-setup.ts +++ b/code/vitest-setup.ts @@ -39,6 +39,8 @@ const throwMessage = (type: any, message: any) => { const throwWarning = (message: any) => throwMessage('warn: ', message); const throwError = (message: any) => throwMessage('error: ', message); +globalThis.FEATURES ??= {}; + vi.spyOn(console, 'warn').mockImplementation(throwWarning); vi.spyOn(console, 'error').mockImplementation(throwError); diff --git a/test-storybooks/portable-stories-kitchen-sink/react/globals.setup.ts b/test-storybooks/portable-stories-kitchen-sink/react/globals.setup.ts new file mode 100644 index 000000000000..061c72bbe2cc --- /dev/null +++ b/test-storybooks/portable-stories-kitchen-sink/react/globals.setup.ts @@ -0,0 +1 @@ +globalThis.FEATURES = {}; diff --git a/test-storybooks/portable-stories-kitchen-sink/react/jest.config.js b/test-storybooks/portable-stories-kitchen-sink/react/jest.config.js index 3741e83f009f..a97ced26f76e 100644 --- a/test-storybooks/portable-stories-kitchen-sink/react/jest.config.js +++ b/test-storybooks/portable-stories-kitchen-sink/react/jest.config.js @@ -1,5 +1,6 @@ module.exports = { testMatch: ['**/?(*.)+(test).[jt]s?(x)'], + setupFiles: ['/globals.setup.ts'], setupFilesAfterEnv: ['/jest.setup.ts'], transform: { '^.+\\.(t|j)sx?$': [ diff --git a/test-storybooks/portable-stories-kitchen-sink/react/jest.setup.ts b/test-storybooks/portable-stories-kitchen-sink/react/jest.setup.ts index 957821b25da3..f34563844d9c 100644 --- a/test-storybooks/portable-stories-kitchen-sink/react/jest.setup.ts +++ b/test-storybooks/portable-stories-kitchen-sink/react/jest.setup.ts @@ -1,5 +1,5 @@ -import "@testing-library/jest-dom"; -import { setProjectAnnotations } from "@storybook/react-vite"; -import sbAnnotations from "./.storybook/preview"; +import '@testing-library/jest-dom'; +import { setProjectAnnotations } from '@storybook/react-vite'; +import sbAnnotations from './.storybook/preview'; setProjectAnnotations([sbAnnotations]);