diff --git a/code/.storybook/main.ts b/code/.storybook/main.ts index 48109681b6b0..371aa942f5f3 100644 --- a/code/.storybook/main.ts +++ b/code/.storybook/main.ts @@ -1,12 +1,12 @@ import { join } from 'node:path'; -import type { StorybookConfig } from '../frameworks/react-vite'; +import { defineMain } from '../frameworks/react-vite'; const componentsPath = join(__dirname, '../core/src/components'); const managerApiPath = join(__dirname, '../core/src/manager-api'); const imageContextPath = join(__dirname, '..//frameworks/nextjs/src/image-context.ts'); -const config: StorybookConfig = { +const config = defineMain({ stories: [ './*.stories.@(js|jsx|ts|tsx)', { @@ -169,6 +169,6 @@ const config: StorybookConfig = { } satisfies typeof viteConfig); }, // logLevel: 'debug', -}; +}); export default config; diff --git a/code/.storybook/preview.tsx b/code/.storybook/preview.tsx index 2437d15e4a39..a4f48da6580f 100644 --- a/code/.storybook/preview.tsx +++ b/code/.storybook/preview.tsx @@ -18,7 +18,7 @@ import { import { DocsContext } from '@storybook/blocks'; import { global } from '@storybook/global'; import type { Decorator, Loader, ReactRenderer } from '@storybook/react'; -import { defineConfig } from '@storybook/react/preview'; +import { definePreview } from '@storybook/react/preview'; // TODO add empty preview // import * as storysource from '@storybook/addon-storysource'; @@ -375,7 +375,7 @@ const parameters = { }, }; -export const config = defineConfig({ +export const config = definePreview({ addons: [addonThemes, essentials, a11y, addonsPreview, templatePreview], parameters, decorators, diff --git a/code/core/src/csf-tools/ConfigFile.test.ts b/code/core/src/csf-tools/ConfigFile.test.ts index 18b2365f25ec..6661cfb37622 100644 --- a/code/core/src/csf-tools/ConfigFile.test.ts +++ b/code/core/src/csf-tools/ConfigFile.test.ts @@ -245,13 +245,25 @@ describe('ConfigFile', () => { }); describe('factory config', () => { + it('parses correctly', () => { + const source = dedent` + import { definePreview } from '@storybook/react-vite/preview'; + + const config = definePreview({ + framework: 'foo', + }); + export default config; + `; + const config = loadConfig(source).parse(); + expect(config.getNameFromPath(['framework'])).toEqual('foo'); + }); it('found scalar', () => { expect( getField( ['core', 'builder'], dedent` - import { defineConfig } from '@storybook/react-vite/preview'; - export const foo = defineConfig({ core: { builder: 'webpack5' } }); + import { definePreview } from '@storybook/react-vite/preview'; + export const foo = definePreview({ core: { builder: 'webpack5' } }); ` ) ).toEqual('webpack5'); @@ -261,9 +273,9 @@ describe('ConfigFile', () => { getField( ['tags'], dedent` - import { defineConfig } from '@storybook/react-vite/preview'; + import { definePreview } from '@storybook/react-vite/preview'; const parameters = {}; - export const config = defineConfig({ + export const config = definePreview({ parameters, tags: ['test', 'vitest', '!a11ytest'], }); @@ -516,15 +528,15 @@ describe('ConfigFile', () => { ['core', 'builder'], 'webpack5', dedent` - import { defineConfig } from '@storybook/react-vite/preview'; - export const foo = defineConfig({ + import { definePreview } from '@storybook/react-vite/preview'; + export const foo = definePreview({ addons: [], }); ` ) ).toMatchInlineSnapshot(` - import { defineConfig } from '@storybook/react-vite/preview'; - export const foo = defineConfig({ + import { definePreview } from '@storybook/react-vite/preview'; + export const foo = definePreview({ addons: [], core: { @@ -539,15 +551,15 @@ describe('ConfigFile', () => { ['core', 'builder'], 'webpack5', dedent` - import { defineConfig } from '@storybook/react-vite/preview'; - export const foo = defineConfig({ + import { definePreview } from '@storybook/react-vite/preview'; + export const foo = definePreview({ core: { foo: 'bar' }, }); ` ) ).toMatchInlineSnapshot(` - import { defineConfig } from '@storybook/react-vite/preview'; - export const foo = defineConfig({ + import { definePreview } from '@storybook/react-vite/preview'; + export const foo = definePreview({ core: { foo: 'bar', builder: 'webpack5' @@ -561,15 +573,15 @@ describe('ConfigFile', () => { ['core', 'builder'], 'webpack5', dedent` - import { defineConfig } from '@storybook/react-vite/preview'; - export const foo = defineConfig({ + import { definePreview } from '@storybook/react-vite/preview'; + export const foo = definePreview({ core: { builder: 'webpack4' }, }); ` ) ).toMatchInlineSnapshot(` - import { defineConfig } from '@storybook/react-vite/preview'; - export const foo = defineConfig({ + import { definePreview } from '@storybook/react-vite/preview'; + export const foo = definePreview({ core: { builder: 'webpack5' }, }); `); @@ -1017,6 +1029,19 @@ describe('ConfigFile', () => { expect(config.getNameFromPath(['otherField'])).toEqual('foo'); }); + it(`supports pnp wrapped names`, () => { + const source = dedent` + import type { StorybookConfig } from '@storybook/react-webpack5'; + + const config: StorybookConfig = { + framework: getAbsolutePath('foo'), + } + export default config; + `; + const config = loadConfig(source).parse(); + expect(config.getNameFromPath(['framework'])).toEqual('foo'); + }); + it(`returns undefined when accessing a field that does not exist`, () => { const source = dedent` import type { StorybookConfig } from '@storybook/react-webpack5'; diff --git a/code/core/src/csf-tools/ConfigFile.ts b/code/core/src/csf-tools/ConfigFile.ts index 60bea05b56dd..d1aecfb7ae63 100644 --- a/code/core/src/csf-tools/ConfigFile.ts +++ b/code/core/src/csf-tools/ConfigFile.ts @@ -25,18 +25,8 @@ const getCsfParsingErrorMessage = ({ foundType: string | undefined; node: any | undefined; }) => { - let nodeInfo = ''; - if (node) { - try { - nodeInfo = JSON.stringify(node); - } catch (e) { - // - } - } - return dedent` CSF Parsing error: Expected '${expectedType}' but found '${foundType}' instead in '${node?.type}'. - ${nodeInfo} `; }; @@ -215,6 +205,11 @@ export class ConfigFile { decl = unwrap(decl); + // csf factory + if (t.isCallExpression(decl) && t.isObjectExpression(decl.arguments[0])) { + decl = decl.arguments[0]; + } + if (t.isObjectExpression(decl)) { self._parseExportsObject(decl); } else { @@ -323,7 +318,7 @@ export class ConfigFile { enter: ({ node }) => { if ( t.isIdentifier(node.callee) && - node.callee.name === 'defineConfig' && + node.callee.name === 'definePreview' && node.arguments.length === 1 && t.isObjectExpression(node.arguments[0]) ) { @@ -499,6 +494,8 @@ export class ConfigFile { value = prop.value.value; } }); + } else if (t.isCallExpression(node)) { + value = this._getPnpWrappedValue(node); } if (!value) { diff --git a/code/core/src/csf-tools/CsfFile.ts b/code/core/src/csf-tools/CsfFile.ts index d7ed7fe8eeb9..cfbc9b08f957 100644 --- a/code/core/src/csf-tools/CsfFile.ts +++ b/code/core/src/csf-tools/CsfFile.ts @@ -4,6 +4,7 @@ import { readFile, writeFile } from 'node:fs/promises'; import { BabelFileClass, type GeneratorOptions, + type NodePath, type RecastOptions, babelParse, generate, @@ -255,10 +256,14 @@ export class CsfFile { _storyExports: Record = {}; + _storyPaths: Record> = {}; + _metaStatement: t.Statement | undefined; _metaNode: t.Expression | undefined; + _metaPath: NodePath | undefined; + _metaVariableName: string | undefined; _metaIsFactory: boolean | undefined; @@ -466,10 +471,13 @@ export class CsfFile { self._options.fileName ); } + + self._metaPath = path; }, }, ExportNamedDeclaration: { - enter({ node, parent }) { + enter(path) { + const { node, parent } = path; let declarations; if (t.isVariableDeclaration(node.declaration)) { declarations = node.declaration.declarations.filter((d) => t.isVariableDeclarator(d)); @@ -487,6 +495,7 @@ export class CsfFile { return; } self._storyExports[exportName] = decl; + self._storyPaths[exportName] = path; self._storyStatements[exportName] = node; let name = storyNameFromExport(exportName); if (self._storyAnnotations[exportName]) { @@ -611,6 +620,7 @@ export class CsfFile { } else { self._storyAnnotations[exportName] = {}; self._storyStatements[exportName] = decl; + self._storyPaths[exportName] = path; self._stories[exportName] = { id: 'FIXME', name: exportName, diff --git a/code/core/src/csf-tools/enrichCsf.test.ts b/code/core/src/csf-tools/enrichCsf.test.ts index e8c0ce6250eb..e46c908bd658 100644 --- a/code/core/src/csf-tools/enrichCsf.test.ts +++ b/code/core/src/csf-tools/enrichCsf.test.ts @@ -149,6 +149,51 @@ describe('enrichCsf', () => { }; `); }); + it('csf factories', () => { + expect( + enrich( + dedent` + // compiled code + import {config} from "/.storybook/preview.ts"; + const meta = config.meta({ + args: { + label: "Hello world!" + } + }); + export const Story = meta.story({}); + `, + dedent` + // original code + import {config} from "#.storybook/preview.ts"; + const meta = config.meta({ + args: { + label: "Hello world!" + } + }); + export const Story = meta.story({}); + ` + ) + ).toMatchInlineSnapshot(` + // compiled code + import { config } from "/.storybook/preview.ts"; + const meta = config.meta({ + args: { + label: "Hello world!" + } + }); + export const Story = meta.story({}); + Story.annotations.parameters = { + ...Story.annotations.parameters, + docs: { + ...Story.annotations.parameters?.docs, + source: { + originalSource: "meta.story({})", + ...Story.annotations.parameters?.docs?.source + } + } + }; + `); + }); it('multiple stories', () => { expect( enrich( diff --git a/code/core/src/csf-tools/enrichCsf.ts b/code/core/src/csf-tools/enrichCsf.ts index aa4a205e6bf3..11b1a239c7ae 100644 --- a/code/core/src/csf-tools/enrichCsf.ts +++ b/code/core/src/csf-tools/enrichCsf.ts @@ -15,11 +15,20 @@ export const enrichCsfStory = ( options?: EnrichCsfOptions ) => { const storyExport = csfSource.getStoryExport(key); + const isCsfFactory = + t.isCallExpression(storyExport) && + t.isMemberExpression(storyExport.callee) && + t.isIdentifier(storyExport.callee.object) && + storyExport.callee.object.name === 'meta'; const source = !options?.disableSource && extractSource(storyExport); const description = !options?.disableDescription && extractDescription(csfSource._storyStatements[key]); const parameters = []; - const originalParameters = t.memberExpression(t.identifier(key), t.identifier('parameters')); + // in csf 1/2/3 use Story.parameters; CSF factories use Story.annotations.parameters + const baseStoryObject = isCsfFactory + ? t.memberExpression(t.identifier(key), t.identifier('annotations')) + : t.identifier(key); + const originalParameters = t.memberExpression(baseStoryObject, t.identifier('parameters')); parameters.push(t.spreadElement(originalParameters)); const optionalDocs = t.optionalMemberExpression( originalParameters, diff --git a/code/frameworks/angular/package.json b/code/frameworks/angular/package.json index 946679a7a129..bc7162bad711 100644 --- a/code/frameworks/angular/package.json +++ b/code/frameworks/angular/package.json @@ -20,6 +20,16 @@ "url": "https://opencollective.com/storybook" }, "license": "MIT", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "node": "./dist/index.js", + "import": "./dist/index.mjs", + "require": "./dist/index.js" + }, + "./preset": "./preset.js", + "./package.json": "./package.json" + }, "main": "dist/index.js", "module": "dist/index.mjs", "types": "dist/index.d.ts", diff --git a/code/frameworks/angular/src/defineMainConfig.ts b/code/frameworks/angular/src/defineMainConfig.ts new file mode 100644 index 000000000000..e0bb43b41ff9 --- /dev/null +++ b/code/frameworks/angular/src/defineMainConfig.ts @@ -0,0 +1,5 @@ +import { StorybookConfig } from './types'; + +export function defineMain(config: StorybookConfig) { + return config; +} diff --git a/code/frameworks/angular/src/index.ts b/code/frameworks/angular/src/index.ts index 92f1fac0da0f..d57916a453f4 100644 --- a/code/frameworks/angular/src/index.ts +++ b/code/frameworks/angular/src/index.ts @@ -1,5 +1,6 @@ export * from './client/index'; export * from './types'; +export * from './defineMainConfig'; /* * ATTENTION: diff --git a/code/frameworks/ember/package.json b/code/frameworks/ember/package.json index 9e308f6b5f6d..f4b021d13e8e 100644 --- a/code/frameworks/ember/package.json +++ b/code/frameworks/ember/package.json @@ -16,6 +16,16 @@ "url": "https://opencollective.com/storybook" }, "license": "MIT", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "node": "./dist/index.js", + "import": "./dist/index.mjs", + "require": "./dist/index.js" + }, + "./preset": "./preset.js", + "./package.json": "./package.json" + }, "main": "dist/index.js", "module": "dist/index.mjs", "types": "dist/index.d.ts", diff --git a/code/frameworks/ember/src/defineMainConfig.ts b/code/frameworks/ember/src/defineMainConfig.ts new file mode 100644 index 000000000000..29be946269ac --- /dev/null +++ b/code/frameworks/ember/src/defineMainConfig.ts @@ -0,0 +1,5 @@ +import type { StorybookConfig } from './types'; + +export function defineMain(config: StorybookConfig) { + return config; +} diff --git a/code/frameworks/ember/src/index.ts b/code/frameworks/ember/src/index.ts index b821bec98227..bdebdaee4b44 100644 --- a/code/frameworks/ember/src/index.ts +++ b/code/frameworks/ember/src/index.ts @@ -1,6 +1,7 @@ /// export * from './types'; +export * from './defineMainConfig'; // optimization: stop HMR propagation in webpack diff --git a/code/frameworks/experimental-nextjs-vite/src/defineMainConfig.ts b/code/frameworks/experimental-nextjs-vite/src/defineMainConfig.ts new file mode 100644 index 000000000000..29be946269ac --- /dev/null +++ b/code/frameworks/experimental-nextjs-vite/src/defineMainConfig.ts @@ -0,0 +1,5 @@ +import type { StorybookConfig } from './types'; + +export function defineMain(config: StorybookConfig) { + return config; +} diff --git a/code/frameworks/experimental-nextjs-vite/src/index.ts b/code/frameworks/experimental-nextjs-vite/src/index.ts index 32476387c88c..af5050bce57f 100644 --- a/code/frameworks/experimental-nextjs-vite/src/index.ts +++ b/code/frameworks/experimental-nextjs-vite/src/index.ts @@ -1,6 +1,7 @@ import type vitePluginStorybookNextJs from 'vite-plugin-storybook-nextjs'; export * from './types'; +export * from './defineMainConfig'; export * from './portable-stories'; // eslint-disable-next-line @typescript-eslint/ban-ts-comment diff --git a/code/frameworks/html-vite/src/defineMainConfig.ts b/code/frameworks/html-vite/src/defineMainConfig.ts new file mode 100644 index 000000000000..29be946269ac --- /dev/null +++ b/code/frameworks/html-vite/src/defineMainConfig.ts @@ -0,0 +1,5 @@ +import type { StorybookConfig } from './types'; + +export function defineMain(config: StorybookConfig) { + return config; +} diff --git a/code/frameworks/html-vite/src/index.ts b/code/frameworks/html-vite/src/index.ts index fcb073fefcd6..bb8ba9918a0d 100644 --- a/code/frameworks/html-vite/src/index.ts +++ b/code/frameworks/html-vite/src/index.ts @@ -1 +1,2 @@ export * from './types'; +export * from './defineMainConfig'; diff --git a/code/frameworks/html-webpack5/src/defineMainConfig.ts b/code/frameworks/html-webpack5/src/defineMainConfig.ts new file mode 100644 index 000000000000..29be946269ac --- /dev/null +++ b/code/frameworks/html-webpack5/src/defineMainConfig.ts @@ -0,0 +1,5 @@ +import type { StorybookConfig } from './types'; + +export function defineMain(config: StorybookConfig) { + return config; +} diff --git a/code/frameworks/html-webpack5/src/index.ts b/code/frameworks/html-webpack5/src/index.ts index fcb073fefcd6..bb8ba9918a0d 100644 --- a/code/frameworks/html-webpack5/src/index.ts +++ b/code/frameworks/html-webpack5/src/index.ts @@ -1 +1,2 @@ export * from './types'; +export * from './defineMainConfig'; diff --git a/code/frameworks/nextjs/src/defineMainConfig.ts b/code/frameworks/nextjs/src/defineMainConfig.ts new file mode 100644 index 000000000000..29be946269ac --- /dev/null +++ b/code/frameworks/nextjs/src/defineMainConfig.ts @@ -0,0 +1,5 @@ +import type { StorybookConfig } from './types'; + +export function defineMain(config: StorybookConfig) { + return config; +} diff --git a/code/frameworks/nextjs/src/index.ts b/code/frameworks/nextjs/src/index.ts index a904f93ec89d..4e3e0a0ec393 100644 --- a/code/frameworks/nextjs/src/index.ts +++ b/code/frameworks/nextjs/src/index.ts @@ -1,2 +1,3 @@ export * from './types'; +export * from './defineMainConfig'; export * from './portable-stories'; diff --git a/code/frameworks/preact-vite/src/defineMainConfig.ts b/code/frameworks/preact-vite/src/defineMainConfig.ts new file mode 100644 index 000000000000..29be946269ac --- /dev/null +++ b/code/frameworks/preact-vite/src/defineMainConfig.ts @@ -0,0 +1,5 @@ +import type { StorybookConfig } from './types'; + +export function defineMain(config: StorybookConfig) { + return config; +} diff --git a/code/frameworks/preact-vite/src/index.ts b/code/frameworks/preact-vite/src/index.ts index fcb073fefcd6..bb8ba9918a0d 100644 --- a/code/frameworks/preact-vite/src/index.ts +++ b/code/frameworks/preact-vite/src/index.ts @@ -1 +1,2 @@ export * from './types'; +export * from './defineMainConfig'; diff --git a/code/frameworks/preact-webpack5/src/defineMainConfig.ts b/code/frameworks/preact-webpack5/src/defineMainConfig.ts new file mode 100644 index 000000000000..29be946269ac --- /dev/null +++ b/code/frameworks/preact-webpack5/src/defineMainConfig.ts @@ -0,0 +1,5 @@ +import type { StorybookConfig } from './types'; + +export function defineMain(config: StorybookConfig) { + return config; +} diff --git a/code/frameworks/preact-webpack5/src/index.ts b/code/frameworks/preact-webpack5/src/index.ts index fcb073fefcd6..bb8ba9918a0d 100644 --- a/code/frameworks/preact-webpack5/src/index.ts +++ b/code/frameworks/preact-webpack5/src/index.ts @@ -1 +1,2 @@ export * from './types'; +export * from './defineMainConfig'; diff --git a/code/frameworks/react-native-web-vite/src/defineMainConfig.ts b/code/frameworks/react-native-web-vite/src/defineMainConfig.ts new file mode 100644 index 000000000000..29be946269ac --- /dev/null +++ b/code/frameworks/react-native-web-vite/src/defineMainConfig.ts @@ -0,0 +1,5 @@ +import type { StorybookConfig } from './types'; + +export function defineMain(config: StorybookConfig) { + return config; +} diff --git a/code/frameworks/react-native-web-vite/src/index.ts b/code/frameworks/react-native-web-vite/src/index.ts index 1855ad61a70b..c316aff16a2c 100644 --- a/code/frameworks/react-native-web-vite/src/index.ts +++ b/code/frameworks/react-native-web-vite/src/index.ts @@ -1 +1,2 @@ export type { FrameworkOptions, StorybookConfig } from './types'; +export * from './defineMainConfig'; diff --git a/code/frameworks/react-vite/src/defineMainConfig.ts b/code/frameworks/react-vite/src/defineMainConfig.ts new file mode 100644 index 000000000000..29be946269ac --- /dev/null +++ b/code/frameworks/react-vite/src/defineMainConfig.ts @@ -0,0 +1,5 @@ +import type { StorybookConfig } from './types'; + +export function defineMain(config: StorybookConfig) { + return config; +} diff --git a/code/frameworks/react-vite/src/index.ts b/code/frameworks/react-vite/src/index.ts index fcb073fefcd6..bb8ba9918a0d 100644 --- a/code/frameworks/react-vite/src/index.ts +++ b/code/frameworks/react-vite/src/index.ts @@ -1 +1,2 @@ export * from './types'; +export * from './defineMainConfig'; diff --git a/code/frameworks/react-vite/template/stories/csf4.stories.tsx b/code/frameworks/react-vite/template/stories/csf4.stories.tsx index 012c356881c6..4bb4a60cbf35 100644 --- a/code/frameworks/react-vite/template/stories/csf4.stories.tsx +++ b/code/frameworks/react-vite/template/stories/csf4.stories.tsx @@ -1,6 +1,5 @@ -import React from 'react'; - -import { config } from '#.storybook/preview'; +// @ts-expect-error this will be part of the package.json of the sandbox +import config from '#.storybook/preview'; const meta = config.meta({ component: globalThis.Components.Button, diff --git a/code/frameworks/react-webpack5/src/defineMainConfig.ts b/code/frameworks/react-webpack5/src/defineMainConfig.ts new file mode 100644 index 000000000000..29be946269ac --- /dev/null +++ b/code/frameworks/react-webpack5/src/defineMainConfig.ts @@ -0,0 +1,5 @@ +import type { StorybookConfig } from './types'; + +export function defineMain(config: StorybookConfig) { + return config; +} diff --git a/code/frameworks/react-webpack5/src/index.ts b/code/frameworks/react-webpack5/src/index.ts index fcb073fefcd6..bb8ba9918a0d 100644 --- a/code/frameworks/react-webpack5/src/index.ts +++ b/code/frameworks/react-webpack5/src/index.ts @@ -1 +1,2 @@ export * from './types'; +export * from './defineMainConfig'; diff --git a/code/frameworks/server-webpack5/src/defineMainConfig.ts b/code/frameworks/server-webpack5/src/defineMainConfig.ts new file mode 100644 index 000000000000..29be946269ac --- /dev/null +++ b/code/frameworks/server-webpack5/src/defineMainConfig.ts @@ -0,0 +1,5 @@ +import type { StorybookConfig } from './types'; + +export function defineMain(config: StorybookConfig) { + return config; +} diff --git a/code/frameworks/server-webpack5/src/index.ts b/code/frameworks/server-webpack5/src/index.ts index fcb073fefcd6..bb8ba9918a0d 100644 --- a/code/frameworks/server-webpack5/src/index.ts +++ b/code/frameworks/server-webpack5/src/index.ts @@ -1 +1,2 @@ export * from './types'; +export * from './defineMainConfig'; diff --git a/code/frameworks/svelte-vite/src/defineMainConfig.ts b/code/frameworks/svelte-vite/src/defineMainConfig.ts new file mode 100644 index 000000000000..29be946269ac --- /dev/null +++ b/code/frameworks/svelte-vite/src/defineMainConfig.ts @@ -0,0 +1,5 @@ +import type { StorybookConfig } from './types'; + +export function defineMain(config: StorybookConfig) { + return config; +} diff --git a/code/frameworks/svelte-vite/src/index.ts b/code/frameworks/svelte-vite/src/index.ts index fcb073fefcd6..bb8ba9918a0d 100644 --- a/code/frameworks/svelte-vite/src/index.ts +++ b/code/frameworks/svelte-vite/src/index.ts @@ -1 +1,2 @@ export * from './types'; +export * from './defineMainConfig'; diff --git a/code/frameworks/svelte-webpack5/src/defineMainConfig.ts b/code/frameworks/svelte-webpack5/src/defineMainConfig.ts new file mode 100644 index 000000000000..29be946269ac --- /dev/null +++ b/code/frameworks/svelte-webpack5/src/defineMainConfig.ts @@ -0,0 +1,5 @@ +import type { StorybookConfig } from './types'; + +export function defineMain(config: StorybookConfig) { + return config; +} diff --git a/code/frameworks/svelte-webpack5/src/index.ts b/code/frameworks/svelte-webpack5/src/index.ts index fcb073fefcd6..bb8ba9918a0d 100644 --- a/code/frameworks/svelte-webpack5/src/index.ts +++ b/code/frameworks/svelte-webpack5/src/index.ts @@ -1 +1,2 @@ export * from './types'; +export * from './defineMainConfig'; diff --git a/code/frameworks/sveltekit/src/defineMainConfig.ts b/code/frameworks/sveltekit/src/defineMainConfig.ts new file mode 100644 index 000000000000..29be946269ac --- /dev/null +++ b/code/frameworks/sveltekit/src/defineMainConfig.ts @@ -0,0 +1,5 @@ +import type { StorybookConfig } from './types'; + +export function defineMain(config: StorybookConfig) { + return config; +} diff --git a/code/frameworks/sveltekit/src/index.ts b/code/frameworks/sveltekit/src/index.ts index a904f93ec89d..4e3e0a0ec393 100644 --- a/code/frameworks/sveltekit/src/index.ts +++ b/code/frameworks/sveltekit/src/index.ts @@ -1,2 +1,3 @@ export * from './types'; +export * from './defineMainConfig'; export * from './portable-stories'; diff --git a/code/frameworks/vue3-vite/src/defineMainConfig.ts b/code/frameworks/vue3-vite/src/defineMainConfig.ts new file mode 100644 index 000000000000..29be946269ac --- /dev/null +++ b/code/frameworks/vue3-vite/src/defineMainConfig.ts @@ -0,0 +1,5 @@ +import type { StorybookConfig } from './types'; + +export function defineMain(config: StorybookConfig) { + return config; +} diff --git a/code/frameworks/vue3-vite/src/index.ts b/code/frameworks/vue3-vite/src/index.ts index fcb073fefcd6..bb8ba9918a0d 100644 --- a/code/frameworks/vue3-vite/src/index.ts +++ b/code/frameworks/vue3-vite/src/index.ts @@ -1 +1,2 @@ export * from './types'; +export * from './defineMainConfig'; diff --git a/code/frameworks/vue3-webpack5/src/defineMainConfig.ts b/code/frameworks/vue3-webpack5/src/defineMainConfig.ts new file mode 100644 index 000000000000..29be946269ac --- /dev/null +++ b/code/frameworks/vue3-webpack5/src/defineMainConfig.ts @@ -0,0 +1,5 @@ +import type { StorybookConfig } from './types'; + +export function defineMain(config: StorybookConfig) { + return config; +} diff --git a/code/frameworks/vue3-webpack5/src/index.ts b/code/frameworks/vue3-webpack5/src/index.ts index fcb073fefcd6..bb8ba9918a0d 100644 --- a/code/frameworks/vue3-webpack5/src/index.ts +++ b/code/frameworks/vue3-webpack5/src/index.ts @@ -1 +1,2 @@ export * from './types'; +export * from './defineMainConfig'; diff --git a/code/frameworks/web-components-vite/src/defineMainConfig.ts b/code/frameworks/web-components-vite/src/defineMainConfig.ts new file mode 100644 index 000000000000..29be946269ac --- /dev/null +++ b/code/frameworks/web-components-vite/src/defineMainConfig.ts @@ -0,0 +1,5 @@ +import type { StorybookConfig } from './types'; + +export function defineMain(config: StorybookConfig) { + return config; +} diff --git a/code/frameworks/web-components-vite/src/index.ts b/code/frameworks/web-components-vite/src/index.ts index fcb073fefcd6..bb8ba9918a0d 100644 --- a/code/frameworks/web-components-vite/src/index.ts +++ b/code/frameworks/web-components-vite/src/index.ts @@ -1 +1,2 @@ export * from './types'; +export * from './defineMainConfig'; diff --git a/code/frameworks/web-components-webpack5/src/defineMainConfig.ts b/code/frameworks/web-components-webpack5/src/defineMainConfig.ts new file mode 100644 index 000000000000..29be946269ac --- /dev/null +++ b/code/frameworks/web-components-webpack5/src/defineMainConfig.ts @@ -0,0 +1,5 @@ +import type { StorybookConfig } from './types'; + +export function defineMain(config: StorybookConfig) { + return config; +} diff --git a/code/frameworks/web-components-webpack5/src/index.ts b/code/frameworks/web-components-webpack5/src/index.ts index fcb073fefcd6..bb8ba9918a0d 100644 --- a/code/frameworks/web-components-webpack5/src/index.ts +++ b/code/frameworks/web-components-webpack5/src/index.ts @@ -1 +1,2 @@ export * from './types'; +export * from './defineMainConfig'; diff --git a/code/lib/cli-storybook/package.json b/code/lib/cli-storybook/package.json index 931146d117ba..7a3a97ed9c3f 100644 --- a/code/lib/cli-storybook/package.json +++ b/code/lib/cli-storybook/package.json @@ -55,6 +55,7 @@ "globby": "^14.0.1", "jscodeshift": "^0.15.1", "leven": "^3.1.0", + "p-limit": "^6.2.0", "prompts": "^2.4.0", "semver": "^7.3.7", "storybook": "workspace:*", diff --git a/code/lib/cli-storybook/src/add.test.ts b/code/lib/cli-storybook/src/add.test.ts index 1bbf88275afc..0d5241b033bc 100644 --- a/code/lib/cli-storybook/src/add.test.ts +++ b/code/lib/cli-storybook/src/add.test.ts @@ -47,10 +47,12 @@ vi.mock('./postinstallAddon', () => { vi.mock('./automigrate/fixes/wrap-require-utils', () => { return MockWrapRequireUtils; }); +vi.mock('./codemod/helpers/csf-factories-utils'); vi.mock('storybook/internal/common', () => { return { getStorybookInfo: vi.fn(() => ({ mainConfig: {}, configDir: '' })), serverRequire: vi.fn(() => ({})), + loadMainConfig: vi.fn(() => ({})), JsPackageManagerFactory: { getPackageManager: vi.fn(() => MockedPackageManager), }, diff --git a/code/lib/cli-storybook/src/add.ts b/code/lib/cli-storybook/src/add.ts index 8ba600883e7e..02b8fc366a4e 100644 --- a/code/lib/cli-storybook/src/add.ts +++ b/code/lib/cli-storybook/src/add.ts @@ -3,13 +3,13 @@ import { isAbsolute, join } from 'node:path'; import { JsPackageManagerFactory, type PackageManagerName, - getCoercedStorybookVersion, - getStorybookInfo, serverRequire, versions, } from 'storybook/internal/common'; import { readConfig, writeConfig } from 'storybook/internal/csf-tools'; +import type { StorybookConfigRaw } from '@storybook/types'; + import prompts from 'prompts'; import SemVer from 'semver'; import { dedent } from 'ts-dedent'; @@ -18,6 +18,8 @@ import { getRequireWrapperName, wrapValueWithRequireWrapper, } from './automigrate/fixes/wrap-require-utils'; +import { getStorybookData } from './automigrate/helpers/mainConfigFile'; +import { syncStorybookAddons } from './codemod/helpers/csf-factories-utils'; import { postinstallAddon } from './postinstallAddon'; export interface PostinstallOptions { @@ -53,7 +55,7 @@ const requireMain = (configDir: string) => { return serverRequire(mainFile) ?? {}; }; -const checkInstalled = (addonName: string, main: any) => { +const checkInstalled = (addonName: string, main: StorybookConfigRaw) => { const existingAddon = main.addons?.find((entry: string | { name: string }) => { const name = typeof entry === 'string' ? entry : entry.name; return name?.endsWith(addonName); @@ -91,12 +93,11 @@ export async function add( const [addonName, inputVersion] = getVersionSpecifier(addon); const packageManager = JsPackageManagerFactory.getPackageManager({ force: pkgMgr }); - const packageJson = await packageManager.retrievePackageJson(); - const { mainConfig, configDir: inferredConfigDir } = getStorybookInfo( - packageJson, - userSpecifiedConfigDir - ); - const configDir = userSpecifiedConfigDir || inferredConfigDir || '.storybook'; + const { mainConfig, mainConfigPath, configDir, previewConfigPath, storybookVersion } = + await getStorybookData({ + packageManager, + configDir: userSpecifiedConfigDir, + }); if (typeof configDir === 'undefined') { throw new Error(dedent` @@ -104,16 +105,16 @@ export async function add( `); } - if (!mainConfig) { + if (!mainConfigPath) { logger.error('Unable to find Storybook main.js config'); return; } let shouldAddToMain = true; - if (checkInstalled(addonName, requireMain(configDir))) { + if (checkInstalled(addonName, mainConfig)) { shouldAddToMain = false; if (!yes) { - logger.log(`The Storybook addon "${addonName}" is already present in ${mainConfig}.`); + logger.log(`The Storybook addon "${addonName}" is already present in ${mainConfigPath}.`); const { shouldForceInstall } = await prompts({ type: 'confirm', name: 'shouldForceInstall', @@ -126,11 +127,9 @@ export async function add( } } - const main = await readConfig(mainConfig); + const main = await readConfig(mainConfigPath); logger.log(`Verifying ${addonName}`); - const storybookVersion = await getCoercedStorybookVersion(packageManager); - let version = inputVersion; if (!version && isCoreAddon(addonName) && storybookVersion) { @@ -154,7 +153,7 @@ export async function add( await packageManager.addDependencies({ installAsDevDependencies: true }, [addonWithVersion]); if (shouldAddToMain) { - logger.log(`Adding '${addon}' to the "addons" field in ${mainConfig}`); + logger.log(`Adding '${addon}' to the "addons" field in ${mainConfigPath}`); const mainConfigAddons = main.getFieldNode(['addons']); if (mainConfigAddons && getRequireWrapperName(main) !== null) { @@ -168,6 +167,8 @@ export async function add( await writeConfig(main); } + await syncStorybookAddons(mainConfig, previewConfigPath!); + if (!skipPostinstall && isCoreAddon(addonName)) { await postinstallAddon(addonName, { packageManager: packageManager.type, configDir, yes }); } diff --git a/code/lib/cli-storybook/src/automigrate/codemod.ts b/code/lib/cli-storybook/src/automigrate/codemod.ts new file mode 100644 index 000000000000..1cb937d73398 --- /dev/null +++ b/code/lib/cli-storybook/src/automigrate/codemod.ts @@ -0,0 +1,108 @@ +import os from 'node:os'; + +import { formatFileContent } from 'storybook/internal/common'; + +import { promises as fs } from 'fs'; +import picocolors from 'picocolors'; +import slash from 'slash'; + +const logger = console; + +export const maxConcurrentTasks = Math.max(1, os.cpus().length - 1); + +export interface FileInfo { + path: string; + source: string; + [key: string]: any; +} + +/** + * Runs a codemod transformation on files matching the specified glob pattern. + * + * The function processes each file matching the glob pattern, applies the transform function, and + * writes the transformed source back to the file if it has changed. + * + * @example + * + * ``` + * await runCodemod('*.stories.tsx', async (fileInfo) => { + * // Transform the file source return + * return fileInfo.source.replace(/foo/g, 'bar'); + * }); + * ``` + */ +export async function runCodemod( + globPattern: string = '**/*.stories.*', + transform: (source: FileInfo, ...rest: any) => Promise, + { dryRun = false, skipFormatting = false }: { dryRun?: boolean; skipFormatting?: boolean } = {} +) { + let modifiedCount = 0; + let unmodifiedCount = 0; + let errorCount = 0; + + // Dynamically import these packages because they are pure ESM modules + // eslint-disable-next-line depend/ban-dependencies + const { globby } = await import('globby'); + + // glob only supports forward slashes + const files = await globby(slash(globPattern), { + followSymbolicLinks: true, + ignore: ['node_modules/**', 'dist/**', 'storybook-static/**', 'build/**'], + }); + + if (!files.length) { + logger.error( + `No files found for glob pattern "${globPattern}".\nPlease try a different pattern.\n` + ); + // eslint-disable-next-line local-rules/no-uncategorized-errors + throw new Error('No files matched'); + } + + try { + const pLimit = (await import('p-limit')).default; + + const limit = pLimit(maxConcurrentTasks); + + await Promise.all( + files.map((file) => + limit(async () => { + try { + const source = await fs.readFile(file, 'utf-8'); + const fileInfo: FileInfo = { path: file, source }; + const transformedSource = await transform(fileInfo); + + if (transformedSource !== source) { + if (!dryRun) { + const fileContent = skipFormatting + ? transformedSource + : await formatFileContent(file, transformedSource); + await fs.writeFile(file, fileContent, 'utf-8'); + } + modifiedCount++; + } else { + unmodifiedCount++; + } + } catch (fileError) { + logger.error(`Error processing file ${file}:`, fileError); + errorCount++; + } + }) + ) + ); + } catch (error) { + logger.error('Error applying transform:', error); + errorCount++; + } + + logger.log( + `Summary: ${picocolors.green(`${modifiedCount} transformed`)}, ${picocolors.yellow(`${unmodifiedCount} unmodified`)}, ${picocolors.red(`${errorCount} errors`)}` + ); + + if (dryRun) { + logger.log( + picocolors.bold( + `This was a dry run. Run without --dry-run to apply the transformation to ${modifiedCount} files.` + ) + ); + } +} diff --git a/code/lib/cli-storybook/src/automigrate/fixes/index.ts b/code/lib/cli-storybook/src/automigrate/fixes/index.ts index dfd43100665c..c66b0106ce5d 100644 --- a/code/lib/cli-storybook/src/automigrate/fixes/index.ts +++ b/code/lib/cli-storybook/src/automigrate/fixes/index.ts @@ -1,4 +1,5 @@ -import type { Fix } from '../types'; +import { csfFactories } from '../../codemod/csf-factories'; +import type { CommandFix, Fix } from '../types'; import { addonA11yAddonTest } from './addon-a11y-addon-test'; import { addonPostCSS } from './addon-postcss'; import { addonsAPI } from './addons-api'; @@ -70,3 +71,7 @@ export const allFixes: Fix[] = [ ]; export const initFixes: Fix[] = [eslintPlugin]; + +// These are specific fixes that only occur when triggered on command, and are hidden otherwise. +// e.g. npx storybook automigrate csf-factories +export const commandFixes: CommandFix[] = [csfFactories]; diff --git a/code/lib/cli-storybook/src/automigrate/fixes/missing-storybook-dependencies.test.ts b/code/lib/cli-storybook/src/automigrate/fixes/missing-storybook-dependencies.test.ts index 2729cfb1da16..b39926413778 100644 --- a/code/lib/cli-storybook/src/automigrate/fixes/missing-storybook-dependencies.test.ts +++ b/code/lib/cli-storybook/src/automigrate/fixes/missing-storybook-dependencies.test.ts @@ -109,6 +109,8 @@ describe('missingStorybookDependencies', () => { await missingStorybookDependencies.run!({ result: { packageUsage }, dryRun, + packageJson: {}, + mainConfig: { stories: [] }, packageManager: mockPackageManager as JsPackageManager, mainConfigPath: 'path/to/main-config.js', }); diff --git a/code/lib/cli-storybook/src/automigrate/helpers/mainConfigFile.ts b/code/lib/cli-storybook/src/automigrate/helpers/mainConfigFile.ts index 57aa4bf7ce07..717f92b646b3 100644 --- a/code/lib/cli-storybook/src/automigrate/helpers/mainConfigFile.ts +++ b/code/lib/cli-storybook/src/automigrate/helpers/mainConfigFile.ts @@ -157,6 +157,7 @@ export const getStorybookData = async ({ storybookVersion, mainConfigPath, previewConfigPath, + packageJson, }; }; export type GetStorybookData = typeof getStorybookData; diff --git a/code/lib/cli-storybook/src/automigrate/index.test.ts b/code/lib/cli-storybook/src/automigrate/index.test.ts index 9bc05affbacc..32a4a512cbc4 100644 --- a/code/lib/cli-storybook/src/automigrate/index.test.ts +++ b/code/lib/cli-storybook/src/automigrate/index.test.ts @@ -89,6 +89,8 @@ const runFixWrapper = async ({ fixes, dryRun, yes, + packageJson: {}, + mainConfig: { stories: [] }, rendererPackage, skipInstall, configDir, @@ -134,15 +136,17 @@ describe('runFixes', () => { expect(fixResults).toEqual({ 'fix-1': 'succeeded', }); - expect(run1).toHaveBeenCalledWith({ - dryRun, - mainConfigPath, - packageManager, - result: { - some: 'result', - }, - skipInstall, - }); + expect(run1).toHaveBeenCalledWith( + expect.objectContaining({ + dryRun, + mainConfigPath, + packageManager, + result: { + some: 'result', + }, + skipInstall, + }) + ); }); it('should fail if an error is thrown', async () => { diff --git a/code/lib/cli-storybook/src/automigrate/index.ts b/code/lib/cli-storybook/src/automigrate/index.ts index 9d98d97d7013..4dd8e7445895 100644 --- a/code/lib/cli-storybook/src/automigrate/index.ts +++ b/code/lib/cli-storybook/src/automigrate/index.ts @@ -2,13 +2,13 @@ import { createWriteStream } from 'node:fs'; import { rename, rm } from 'node:fs/promises'; import { join } from 'node:path'; +import type { PackageJson } from 'storybook/internal/common'; import { type JsPackageManager, JsPackageManagerFactory, - getCoercedStorybookVersion, - getStorybookInfo, temporaryFile, } from 'storybook/internal/common'; +import type { StorybookConfigRaw } from 'storybook/internal/types'; import boxen from 'boxen'; import picocolors from 'picocolors'; @@ -27,7 +27,7 @@ import type { PreCheckFailure, Prompt, } from './fixes'; -import { FixStatus, allFixes } from './fixes'; +import { FixStatus, allFixes, commandFixes } from './fixes'; import { upgradeStorybookRelatedDependencies } from './fixes/upgrade-storybook-related-dependencies'; import { cleanLog } from './helpers/cleanLog'; import { getMigrationSummary } from './helpers/getMigrationSummary'; @@ -60,7 +60,7 @@ const cleanup = () => { }; const logAvailableMigrations = () => { - const availableFixes = allFixes + const availableFixes = [...allFixes, ...commandFixes] .map((f) => picocolors.yellow(f.id)) .map((x) => `- ${x}`) .join('\n'); @@ -77,16 +77,17 @@ export const doAutomigrate = async (options: AutofixOptionsFromCLI) => { force: options.packageManager, }); - const [packageJson, storybookVersion] = await Promise.all([ - packageManager.retrievePackageJson(), - getCoercedStorybookVersion(packageManager), - ]); - - const { configDir: inferredConfigDir, mainConfig: mainConfigPath } = getStorybookInfo( + const { + mainConfig, + mainConfigPath, + previewConfigPath, + storybookVersion, + configDir, packageJson, - options.configDir - ); - const configDir = options.configDir || inferredConfigDir || '.storybook'; + } = await getStorybookData({ + configDir: options.configDir, + packageManager, + }); if (!storybookVersion) { throw new Error('Could not determine Storybook version'); @@ -98,10 +99,13 @@ export const doAutomigrate = async (options: AutofixOptionsFromCLI) => { const outcome = await automigrate({ ...options, + packageJson, packageManager, storybookVersion, beforeVersion: storybookVersion, mainConfigPath, + mainConfig, + previewConfigPath, configDir, isUpgrade: false, isLatest: false, @@ -118,9 +122,12 @@ export const automigrate = async ({ dryRun, yes, packageManager, + packageJson, list, configDir, + mainConfig, mainConfigPath, + previewConfigPath, storybookVersion, beforeVersion, renderer: rendererPackage, @@ -137,6 +144,24 @@ export const automigrate = async ({ return null; } + // if an on-command migration is triggered, run it and bail + const commandFix = commandFixes.find((f) => f.id === fixId); + if (commandFix) { + logger.info(`🔎 Running migration ${picocolors.magenta(fixId)}..`); + + await commandFix.run({ + mainConfigPath, + previewConfigPath, + packageManager, + packageJson, + dryRun, + mainConfig, + result: null, + }); + + return null; + } + const selectedFixes: Fix[] = inputFixes || allFixes.filter((fix) => { @@ -166,9 +191,12 @@ export const automigrate = async ({ const { fixResults, fixSummary, preCheckFailure } = await runFixes({ fixes, packageManager, + packageJson, rendererPackage, skipInstall, configDir, + previewConfigPath, + mainConfig, mainConfigPath, storybookVersion, beforeVersion, @@ -214,7 +242,10 @@ export async function runFixes({ skipInstall, configDir, packageManager, + packageJson, + mainConfig, mainConfigPath, + previewConfigPath, storybookVersion, beforeVersion, isUpgrade, @@ -226,7 +257,10 @@ export async function runFixes({ skipInstall?: boolean; configDir: string; packageManager: JsPackageManager; + packageJson: PackageJson; mainConfigPath: string; + previewConfigPath?: string; + mainConfig: StorybookConfigRaw; storybookVersion: string; beforeVersion: string; isUpgrade?: boolean; @@ -243,11 +277,6 @@ export async function runFixes({ let result; try { - const { mainConfig, previewConfigPath } = await getStorybookData({ - configDir, - packageManager, - }); - if ( (isUpgrade && semver.satisfies(beforeVersion, f.versionRange[0], { includePrerelease: true }) && @@ -383,6 +412,9 @@ export async function runFixes({ packageManager, dryRun, mainConfigPath, + previewConfigPath, + packageJson, + mainConfig, skipInstall, }); logger.info(`✅ ran ${picocolors.cyan(f.id)} migration`); diff --git a/code/lib/cli-storybook/src/automigrate/types.ts b/code/lib/cli-storybook/src/automigrate/types.ts index 737d8f9018f7..f4eb22d9e740 100644 --- a/code/lib/cli-storybook/src/automigrate/types.ts +++ b/code/lib/cli-storybook/src/automigrate/types.ts @@ -1,4 +1,4 @@ -import type { JsPackageManager, PackageManagerName } from 'storybook/internal/common'; +import type { JsPackageManager, PackageJson, PackageManagerName } from 'storybook/internal/common'; import type { StorybookConfigRaw } from 'storybook/internal/types'; export interface CheckOptions { @@ -13,9 +13,12 @@ export interface CheckOptions { export interface RunOptions { packageManager: JsPackageManager; + packageJson: PackageJson; result: ResultType; dryRun?: boolean; mainConfigPath: string; + previewConfigPath?: string; + mainConfig: StorybookConfigRaw; skipInstall?: boolean; } @@ -25,8 +28,9 @@ export interface RunOptions { * - Auto: the fix will be applied automatically * - Manual: the user will be prompted to apply the fix * - Notification: the user will be notified about some changes. A fix isn't required, though + * - Command: the fix will only be applied when specified directly by its id */ -export type Prompt = 'auto' | 'manual' | 'notification'; +export type Prompt = 'auto' | 'manual' | 'notification' | 'command'; type BaseFix = { id: string; @@ -46,17 +50,20 @@ type PromptType = | T | ((result: ResultType) => Promise | Prompt); -export type Fix = ( - | { +export type Fix = + | ({ promptType?: PromptType; run: (options: RunOptions) => Promise; - } - | { + } & BaseFix) + | ({ promptType: PromptType; run?: never; - } -) & - BaseFix; + } & BaseFix); + +export type CommandFix = { + promptType: PromptType; + run: (options: RunOptions) => Promise; +} & Omit, 'versionRange' | 'check' | 'prompt'>; export type FixId = string; @@ -68,7 +75,10 @@ export enum PreCheckFailure { export interface AutofixOptions extends Omit { packageManager: JsPackageManager; + packageJson: PackageJson; mainConfigPath: string; + previewConfigPath?: string; + mainConfig: StorybookConfigRaw; /** The version of Storybook before the migration. */ beforeVersion: string; storybookVersion: string; diff --git a/code/lib/cli-storybook/src/codemod/csf-factories.ts b/code/lib/cli-storybook/src/codemod/csf-factories.ts new file mode 100644 index 000000000000..963aa3649152 --- /dev/null +++ b/code/lib/cli-storybook/src/codemod/csf-factories.ts @@ -0,0 +1,75 @@ +import prompts from 'prompts'; + +import { runCodemod } from '../automigrate/codemod'; +import { getFrameworkPackageName } from '../automigrate/helpers/mainConfigFile'; +import type { CommandFix } from '../automigrate/types'; +import { configToCsfFactory } from './helpers/config-to-csf-factory'; +import { syncStorybookAddons } from './helpers/csf-factories-utils'; +import { storyToCsfFactory } from './helpers/story-to-csf-factory'; + +export const logger = console; + +async function runStoriesCodemod(dryRun: boolean | undefined) { + try { + let globString = 'src/stories/*.stories.*'; + if (!process.env.IN_STORYBOOK_SANDBOX) { + logger.log('Please enter the glob for your stories to migrate'); + globString = ( + await prompts({ + type: 'text', + name: 'glob', + message: 'glob', + initial: globString, + }) + ).glob; + } + await runCodemod(globString, storyToCsfFactory, { dryRun }); + } catch (err: any) { + console.log('err message', err.message); + if (err.message === 'No files matched') { + console.log('going to run again'); + await runStoriesCodemod(dryRun); + } else { + throw err; + } + } +} + +export const csfFactories: CommandFix = { + id: 'csf-factories', + promptType: 'command', + async run({ + dryRun, + mainConfig, + mainConfigPath, + previewConfigPath, + packageJson, + packageManager, + }) { + logger.log(`Adding imports map in ${packageManager.packageJsonPath()}`); + packageJson.imports = { + ...packageJson.imports, + // @ts-expect-error we need to upgrade type-fest + '#*': ['./*', './*.ts', './*.tsx', './*.js', './*.jsx'], + }; + await packageManager.writePackageJson(packageJson); + + logger.log('Applying codemod on your stories...'); + await runStoriesCodemod(dryRun); + + logger.log('Applying codemod on your main config...'); + const frameworkPackage = + getFrameworkPackageName(mainConfig) || '@storybook/your-framework-here'; + await runCodemod(mainConfigPath, (fileInfo) => + configToCsfFactory(fileInfo, { configType: 'main', frameworkPackage }, { dryRun }) + ); + + logger.log('Applying codemod on your preview config...'); + await runCodemod(previewConfigPath, (fileInfo) => + configToCsfFactory(fileInfo, { configType: 'preview', frameworkPackage }, { dryRun }) + ); + + logger.log('Synchronizing addons between main and preview config...'); + await syncStorybookAddons(mainConfig, previewConfigPath!); + }, +}; diff --git a/code/lib/cli-storybook/src/codemod/helpers/config-to-csf-factory.test.ts b/code/lib/cli-storybook/src/codemod/helpers/config-to-csf-factory.test.ts new file mode 100644 index 000000000000..ee0a31900810 --- /dev/null +++ b/code/lib/cli-storybook/src/codemod/helpers/config-to-csf-factory.test.ts @@ -0,0 +1,174 @@ +import { describe, expect, it } from 'vitest'; + +import { dedent } from 'ts-dedent'; + +import { configToCsfFactory } from './config-to-csf-factory'; + +expect.addSnapshotSerializer({ + serialize: (val: any) => (typeof val === 'string' ? val : val.toString()), + test: () => true, +}); + +describe('main/preview codemod: general parsing functionality', () => { + const transform = async (source: string) => + ( + await configToCsfFactory( + { source, path: 'main.ts' }, + { configType: 'main', frameworkPackage: '@storybook/react-vite' } + ) + ).trim(); + + it('should wrap defineMain call from inline default export', async () => { + await expect( + transform(dedent` + export default { + stories: ['../src/**/*.stories.@(js|jsx|ts|tsx)'], + addons: ['@storybook/addon-essentials'], + framework: '@storybook/react-vite', + }; + `) + ).resolves.toMatchInlineSnapshot(` + import { defineMain } from '@storybook/react-vite'; + + export default defineMain({ + stories: ['../src/**/*.stories.@(js|jsx|ts|tsx)'], + addons: ['@storybook/addon-essentials'], + framework: '@storybook/react-vite', + }); + `); + }); + it('should wrap defineMain call from const declared default export', async () => { + await expect( + transform(dedent` + const config = { + stories: ['../src/**/*.stories.@(js|jsx|ts|tsx)'], + addons: ['@storybook/addon-essentials'], + framework: '@storybook/react-vite', + }; + + export default config; + `) + ).resolves.toMatchInlineSnapshot(` + import { defineMain } from '@storybook/react-vite'; + + export default defineMain({ + stories: ['../src/**/*.stories.@(js|jsx|ts|tsx)'], + addons: ['@storybook/addon-essentials'], + framework: '@storybook/react-vite', + }); + `); + }); + it('should wrap defineMain call from const declared default export and default export mix', async () => { + await expect( + transform(dedent` + export const tags = []; + const config = { + framework: '@storybook/react-vite', + }; + + export default config; + `) + ).resolves.toMatchInlineSnapshot(` + import { defineMain } from '@storybook/react-vite'; + + const config = { + framework: '@storybook/react-vite', + tags: [], + }; + + export default config; + `); + }); + it('should wrap defineMain call from named exports format', async () => { + await expect( + transform(dedent` + export const stories = ['../src/**/*.stories.@(js|jsx|ts|tsx)']; + export const addons = ['@storybook/addon-essentials']; + export const framework = '@storybook/react-vite'; + `) + ).resolves.toMatchInlineSnapshot(` + import { defineMain } from '@storybook/react-vite'; + + export default defineMain({ + stories: ['../src/**/*.stories.@(js|jsx|ts|tsx)'], + addons: ['@storybook/addon-essentials'], + framework: '@storybook/react-vite', + }); + `); + }); + it('should not add additional imports if there is already one', async () => { + const transformed = await transform(dedent` + import { defineMain } from '@storybook/react-vite/node'; + const config = {}; + + export default config; + `); + expect( + transformed.match(/import { defineMain } from '@storybook\/react-vite\/node'/g) + ).toHaveLength(1); + }); + + it('should remove legacy main config type imports', async () => { + await expect( + transform(dedent` + import { type StorybookConfig } from '@storybook/react-vite' + + const config: StorybookConfig = { + stories: [] + }; + export default config; + `) + ).resolves.toMatchInlineSnapshot(` + import { defineMain } from '@storybook/react-vite'; + + export default defineMain({ + stories: [], + }); + `); + }); +}); + +describe('preview specific functionality', () => { + const transform = async (source: string) => + ( + await configToCsfFactory( + { source, path: 'preview.ts' }, + { configType: 'preview', frameworkPackage: '@storybook/react-vite' } + ) + ).trim(); + + it('should contain a named config export', async () => { + await expect( + transform(dedent` + export default { + tags: ['test'], + }; + `) + ).resolves.toMatchInlineSnapshot(` + import { definePreview } from '@storybook/react/preview'; + + export default definePreview({ + tags: ['test'], + }); + `); + }); + + it('should remove legacy preview type imports', async () => { + await expect( + transform(dedent` + import type { Preview } from '@storybook/react' + + const preview: Preview = { + tags: [] + }; + export default preview; + `) + ).resolves.toMatchInlineSnapshot(` + import { definePreview } from '@storybook/react/preview'; + + export default definePreview({ + tags: [], + }); + `); + }); +}); diff --git a/code/lib/cli-storybook/src/codemod/helpers/config-to-csf-factory.ts b/code/lib/cli-storybook/src/codemod/helpers/config-to-csf-factory.ts new file mode 100644 index 000000000000..65bc136af80c --- /dev/null +++ b/code/lib/cli-storybook/src/codemod/helpers/config-to-csf-factory.ts @@ -0,0 +1,203 @@ +/* eslint-disable no-underscore-dangle */ +import { types as t } from 'storybook/internal/babel'; +import { formatFileContent } from 'storybook/internal/common'; +import { loadConfig, printConfig } from 'storybook/internal/csf-tools'; + +import picocolors from 'picocolors'; + +import type { FileInfo } from '../../automigrate/codemod'; +import { logger } from '../csf-factories'; +import { cleanupTypeImports } from './csf-factories-utils'; + +export async function configToCsfFactory( + info: FileInfo, + { configType, frameworkPackage }: { configType: 'main' | 'preview'; frameworkPackage: string }, + { dryRun = false, skipFormatting = false }: { dryRun?: boolean; skipFormatting?: boolean } = {} +) { + const config = loadConfig(info.source); + try { + config.parse(); + } catch (err) { + logger.log(`Error when parsing ${info.path}, skipping:\n${err}`); + return info.source; + } + + const methodName = configType === 'main' ? 'defineMain' : 'definePreview'; + // TODO: remove this later, it's just a quick workaround for preview imports + // while it is part of @storybook/react and not @storybook/react-vite + frameworkPackage = + configType === 'preview' && frameworkPackage === '@storybook/react-vite' + ? '@storybook/react' + : frameworkPackage; + + const programNode = config._ast.program; + const hasNamedExports = Object.keys(config._exportDecls).length > 0; + + /** + * Scenario 1: Mixed exports + * + * ``` + * export const tags = []; + * export default { + * parameters: {}, + * }; + * ``` + * + * Transform into: `export default defineMain({ tags: [], parameters: {} })` + */ + if (config._exportsObject && hasNamedExports) { + const exportDecls = config._exportDecls; + + for (const [name, decl] of Object.entries(exportDecls)) { + if (decl.init) { + config._exportsObject.properties.push(t.objectProperty(t.identifier(name), decl.init)); + } + } + + programNode.body = programNode.body.filter((node) => { + if (t.isExportNamedDeclaration(node) && node.declaration) { + if (t.isVariableDeclaration(node.declaration)) { + node.declaration.declarations = node.declaration.declarations.filter( + (decl) => t.isIdentifier(decl.id) && !exportDecls[decl.id.name] + ); + return node.declaration.declarations.length > 0; + } + } + return true; + }); + } else if (config._exportsObject) { + /** + * Scenario 2: Default exports + * + * - Syntax 1: `default export const config = {}; export default config;` + * - Syntax 2: `export default {};` + * + * Transform into: `export default defineMain({})` + */ + const defineConfigCall = t.callExpression(t.identifier(methodName), [config._exportsObject]); + + let exportDefaultNode = null as any as t.ExportDefaultDeclaration; + let declarationNodeIndex = -1; + + programNode.body.forEach((node) => { + // Detect Syntax 1 + if (t.isExportDefaultDeclaration(node) && t.isIdentifier(node.declaration)) { + const declarationName = node.declaration.name; + + declarationNodeIndex = programNode.body.findIndex( + (n) => + t.isVariableDeclaration(n) && + n.declarations.some( + (d) => + t.isIdentifier(d.id) && + d.id.name === declarationName && + t.isObjectExpression(d.init) + ) + ); + + if (declarationNodeIndex !== -1) { + exportDefaultNode = node; + // remove the original declaration as it will become a default export + const declarationNode = programNode.body[declarationNodeIndex]; + if (t.isVariableDeclaration(declarationNode)) { + const id = declarationNode.declarations[0].id; + const variableName = t.isIdentifier(id) && id.name; + + if (variableName) { + programNode.body.splice(declarationNodeIndex, 1); + } + } + } + } else if (t.isExportDefaultDeclaration(node) && t.isObjectExpression(node.declaration)) { + // Detect Syntax 2 + exportDefaultNode = node; + } + }); + + if (exportDefaultNode !== null) { + exportDefaultNode.declaration = defineConfigCall; + } + } else if (hasNamedExports) { + /** + * Scenario 3: Named exports export const foo = {}; export bar = ''; + * + * Transform into: export default defineMain({ foo: {}, bar: '' }); + */ + const exportDecls = config._exportDecls; + const defineConfigProps = []; + + // Collect properties from named exports + for (const [name, decl] of Object.entries(exportDecls)) { + if (decl.init) { + defineConfigProps.push(t.objectProperty(t.identifier(name), decl.init)); + } + } + + // Construct the `define` call + const defineConfigCall = t.callExpression(t.identifier(methodName), [ + t.objectExpression(defineConfigProps), + ]); + + // Remove all related named exports + programNode.body = programNode.body.filter((node) => { + if (t.isExportNamedDeclaration(node) && node.declaration) { + if (t.isVariableDeclaration(node.declaration)) { + node.declaration.declarations = node.declaration.declarations.filter( + (decl) => t.isIdentifier(decl.id) && !exportDecls[decl.id.name] + ); + return node.declaration.declarations.length > 0; + } + } + return true; + }); + + // Add the new export default declaration + programNode.body.push(t.exportDefaultDeclaration(defineConfigCall)); + } + + const configImport = t.importDeclaration( + [t.importSpecifier(t.identifier(methodName), t.identifier(methodName))], + t.stringLiteral(frameworkPackage + `${configType === 'preview' ? '/preview' : ''}`) + ); + + // Check whether @storybook/framework import already exists + const existingImport = programNode.body.find( + (node) => + t.isImportDeclaration(node) && + node.source.value === configImport.source.value && + !node.importKind + ); + + if (existingImport && t.isImportDeclaration(existingImport)) { + // If it does, check whether defineMain/definePreview is already imported + // and only add it if it's not + const hasMethodName = existingImport.specifiers.some( + (specifier) => + t.isImportSpecifier(specifier) && + t.isIdentifier(specifier.imported) && + specifier.imported.name === methodName + ); + + if (!hasMethodName) { + existingImport.specifiers.push( + t.importSpecifier(t.identifier(methodName), t.identifier(methodName)) + ); + } + } else { + // if not, add import { defineMain } from '@storybook/framework' + programNode.body.unshift(configImport); + } + + // Remove type imports – now inferred – from @storybook/* packages + const disallowList = ['StorybookConfig', 'Preview']; + programNode.body = cleanupTypeImports(programNode, disallowList); + + const output = printConfig(config).code; + + if (dryRun) { + logger.log(`Would write to ${picocolors.yellow(info.path)}:\n${picocolors.green(output)}`); + return info.source; + } + + return skipFormatting ? output : formatFileContent(info.path, output); +} diff --git a/code/lib/cli-storybook/src/codemod/helpers/csf-factories-utils.test.ts b/code/lib/cli-storybook/src/codemod/helpers/csf-factories-utils.test.ts new file mode 100644 index 000000000000..af79e7442dac --- /dev/null +++ b/code/lib/cli-storybook/src/codemod/helpers/csf-factories-utils.test.ts @@ -0,0 +1,94 @@ +import type { Mock } from 'vitest'; +import { describe, expect, it, vi } from 'vitest'; + +import type { StorybookConfigRaw } from '@storybook/types'; + +import { loadConfig, printConfig } from '@storybook/core/csf-tools'; + +import { dedent } from 'ts-dedent'; + +import { getSyncedStorybookAddons } from './csf-factories-utils'; +import { getAddonAnnotations } from './get-addon-annotations'; + +vi.mock('./get-addon-annotations'); + +expect.addSnapshotSerializer({ + serialize: (val: any) => (typeof val === 'string' ? dedent(val) : dedent(val.toString())), + test: () => true, +}); + +describe('getSyncedStorybookAddons', () => { + const mainConfig: StorybookConfigRaw = { + stories: [], + addons: ['custom-addon', '@storybook/addon-a11y'], + }; + it('should sync addons between main and preview', async () => { + const preview = loadConfig(` + import * as myAddonAnnotations from "custom-addon/preview"; + import { definePreview } from "@storybook/react/preview"; + + export default definePreview({ + addons: [myAddonAnnotations], + }); + `).parse(); + + (getAddonAnnotations as Mock).mockImplementation(() => { + return { importName: 'addonA11yAnnotations', importPath: '@storybook/addon-a11y/preview' }; + }); + + const result = await getSyncedStorybookAddons(mainConfig, preview); + expect(printConfig(result).code).toMatchInlineSnapshot(` + import * as addonA11yAnnotations from "@storybook/addon-a11y/preview"; + import * as myAddonAnnotations from "custom-addon/preview"; + import { definePreview } from "@storybook/react/preview"; + + export default definePreview({ + addons: [myAddonAnnotations, addonA11yAnnotations], + }); + `); + }); + it('should add addons if the preview has no addons field', async () => { + const originalCode = dedent` + import { definePreview } from "@storybook/react/preview"; + + export default definePreview({ + tags: [] + }); + `; + const preview = loadConfig(originalCode).parse(); + + (getAddonAnnotations as Mock).mockImplementation(() => { + return { importName: 'addonA11yAnnotations', importPath: '@storybook/addon-a11y/preview' }; + }); + + const result = await getSyncedStorybookAddons(mainConfig, preview); + expect(printConfig(result).code).toMatchInlineSnapshot(` + import * as addonA11yAnnotations from "@storybook/addon-a11y/preview"; + import { definePreview } from "@storybook/react/preview"; + + export default definePreview({ + tags: [], + addons: [addonA11yAnnotations] + }); + `); + }); + it('should not modify the code if all addons are already synced', async () => { + const originalCode = dedent` + import * as addonA11yAnnotations from "@storybook/addon-a11y/preview"; + import * as myAddonAnnotations from "custom-addon/preview"; + import { definePreview } from "@storybook/react/preview"; + + export default definePreview({ + addons: [myAddonAnnotations, addonA11yAnnotations], + }); + `; + const preview = loadConfig(originalCode).parse(); + + (getAddonAnnotations as Mock).mockImplementation(() => { + return { importName: 'addonA11yAnnotations', importPath: '@storybook/addon-a11y/preview' }; + }); + + const result = await getSyncedStorybookAddons(mainConfig, preview); + expect(printConfig(result).code).toEqual(originalCode); + }); +}); diff --git a/code/lib/cli-storybook/src/codemod/helpers/csf-factories-utils.ts b/code/lib/cli-storybook/src/codemod/helpers/csf-factories-utils.ts new file mode 100644 index 000000000000..7995479f9159 --- /dev/null +++ b/code/lib/cli-storybook/src/codemod/helpers/csf-factories-utils.ts @@ -0,0 +1,103 @@ +/* eslint-disable no-underscore-dangle */ +import { types as t } from 'storybook/internal/babel'; +import { type ConfigFile, readConfig, writeConfig } from 'storybook/internal/csf-tools'; + +import type { StorybookConfigRaw } from '@storybook/types'; + +import { getAddonNames } from '../../automigrate/helpers/mainConfigFile'; +import { logger } from '../csf-factories'; +import { getAddonAnnotations } from './get-addon-annotations'; + +export function cleanupTypeImports(programNode: t.Program, disallowList: string[]) { + return programNode.body.filter((node) => { + if (t.isImportDeclaration(node)) { + const { source, specifiers } = node; + + if (source.value.startsWith('@storybook/')) { + const allowedSpecifiers = specifiers.filter((specifier) => { + if (t.isImportSpecifier(specifier) && t.isIdentifier(specifier.imported)) { + return !disallowList.includes(specifier.imported.name); + } + // Retain non-specifier imports (e.g., namespace imports) + return true; + }); + + // Remove the entire import if no specifiers are left + if (allowedSpecifiers.length > 0) { + node.specifiers = allowedSpecifiers; + return true; + } + + // Remove the import if no specifiers remain + return false; + } + } + + // Retain all other nodes + return true; + // @TODO adding any for now, unsure how to fix the following error: + // error TS4058: Return type of exported function has or is using name 'BlockStatement' from external module "/code/core/dist/babel/index" but cannot be named + }) as any; +} + +export async function syncStorybookAddons( + mainConfig: StorybookConfigRaw, + previewConfigPath: string +) { + const previewConfig = await readConfig(previewConfigPath!); + const modifiedConfig = await getSyncedStorybookAddons(mainConfig, previewConfig); + + await writeConfig(modifiedConfig); +} + +export async function getSyncedStorybookAddons( + mainConfig: StorybookConfigRaw, + previewConfig: ConfigFile +): Promise { + const program = previewConfig._ast.program; + const isCsfFactoryPreview = !!program.body.find((node) => { + return ( + t.isImportDeclaration(node) && + node.source.value.includes('@storybook') && + node.source.value.endsWith('/preview') && + node.specifiers.some((specifier) => { + return ( + t.isImportSpecifier(specifier) && + t.isIdentifier(specifier.imported) && + specifier.imported.name === 'definePreview' + ); + }) + ); + }); + + if (!isCsfFactoryPreview) { + logger.log('Skipping syncStorybookAddons as the preview config is not a csf factory'); + return previewConfig; + } + + const addons = getAddonNames(mainConfig); + + /** + * This goes through all mainConfig.addons, read their package.json and check whether they have an + * exports map called preview, if so add to the array + */ + addons.forEach(async (addon) => { + const annotations = await getAddonAnnotations(addon); + if (annotations) { + previewConfig.setImport({ namespace: annotations.importName }, annotations.importPath); + const existingAddons = previewConfig.getFieldNode(['addons']); + + if ( + !existingAddons || + (t.isArrayExpression(existingAddons) && + !existingAddons.elements.some( + (element) => t.isIdentifier(element) && element.name === annotations.importName + )) + ) { + previewConfig.appendNodeToArray(['addons'], t.identifier(annotations.importName)); + } + } + }); + + return previewConfig; +} diff --git a/code/lib/cli-storybook/src/codemod/helpers/get-addon-annotations.test.ts b/code/lib/cli-storybook/src/codemod/helpers/get-addon-annotations.test.ts new file mode 100644 index 000000000000..c523b2cf74f2 --- /dev/null +++ b/code/lib/cli-storybook/src/codemod/helpers/get-addon-annotations.test.ts @@ -0,0 +1,25 @@ +import { describe, expect, it } from 'vitest'; + +import { getAnnotationsName } from './get-addon-annotations'; + +describe('getAnnotationsName', () => { + it('should handle @storybook namespace and camel case conversion', () => { + expect(getAnnotationsName('@storybook/addon-essentials')).toBe('addonEssentialsAnnotations'); + }); + + it('should handle other namespaces and camel case conversion', () => { + expect(getAnnotationsName('@kudos-components/testing/module')).toBe( + 'kudosComponentsTestingModuleAnnotations' + ); + }); + + it('should handle strings without namespaces', () => { + expect(getAnnotationsName('plain-text/example')).toBe('plainTextExampleAnnotations'); + }); + + it('should handle strings with multiple special characters', () => { + expect(getAnnotationsName('@storybook/multi-part/example-test')).toBe( + 'multiPartExampleTestAnnotations' + ); + }); +}); diff --git a/code/lib/cli-storybook/src/codemod/helpers/get-addon-annotations.ts b/code/lib/cli-storybook/src/codemod/helpers/get-addon-annotations.ts new file mode 100644 index 000000000000..e9d562326915 --- /dev/null +++ b/code/lib/cli-storybook/src/codemod/helpers/get-addon-annotations.ts @@ -0,0 +1,41 @@ +import path from 'node:path'; + +/** + * Get the name of the annotations object for a given addon. + * + * Input: '@storybook/addon-essentials' + * + * Output: 'addonEssentialsAnnotations' + */ +export function getAnnotationsName(addonName: string): string { + // remove @storybook namespace, split by special characters, convert to camelCase + const cleanedUpName = addonName + .replace(/^@storybook\//, '') + .split(/[^a-zA-Z0-9]+/) + .map((word, index) => + index === 0 ? word.toLowerCase() : word.charAt(0).toUpperCase() + word.slice(1).toLowerCase() + ) + .join('') + .replace(/^./, (char) => char.toLowerCase()); + + return `${cleanedUpName}Annotations`; +} + +export async function getAddonAnnotations(addon: string) { + try { + const data = { + importPath: `${addon}/preview`, + importName: getAnnotationsName(addon), + }; + // TODO: current workaround needed only for essentials, fix this once we change the preview entry-point for that package + if (addon === '@storybook/addon-essentials') { + data.importPath = '@storybook/addon-essentials/entry-preview'; + } else { + require.resolve(path.join(addon, 'preview')); + } + + return data; + } catch (err) {} + + return null; +} diff --git a/code/lib/cli-storybook/src/codemod/helpers/story-to-csf-factory.test.ts b/code/lib/cli-storybook/src/codemod/helpers/story-to-csf-factory.test.ts new file mode 100644 index 000000000000..3064115ca976 --- /dev/null +++ b/code/lib/cli-storybook/src/codemod/helpers/story-to-csf-factory.test.ts @@ -0,0 +1,352 @@ +import { describe, expect, it } from 'vitest'; + +import { formatFileContent } from '@storybook/core/common'; + +import { dedent } from 'ts-dedent'; + +import { storyToCsfFactory } from './story-to-csf-factory'; + +expect.addSnapshotSerializer({ + serialize: (val: any) => (typeof val === 'string' ? val : val.toString()), + test: () => true, +}); + +describe('stories codemod', () => { + const transform = async (source: string) => + formatFileContent( + 'Component.stories.tsx', + await storyToCsfFactory({ source, path: 'Component.stories.tsx' }) + ); + describe('javascript', () => { + it('should wrap const declared meta', async () => { + await expect( + transform(dedent` + const meta = { title: 'Component' }; + export default meta; + `) + ).resolves.toMatchInlineSnapshot(` + import config from '#.storybook/preview'; + + const meta = config.meta({ title: 'Component' }); + `); + }); + + it('should transform and wrap inline default exported meta', async () => { + await expect( + transform(dedent` + export default { title: 'Component' }; + `) + ).resolves.toMatchInlineSnapshot(` + import config from '#.storybook/preview'; + + const meta = config.meta({ + title: 'Component', + }); + `); + }); + + it('should rename meta object to meta if it has a different name', async () => { + await expect( + transform(dedent` + const componentMeta = { title: 'Component' }; + export default componentMeta; + `) + ).resolves.toMatchInlineSnapshot(` + import config from '#.storybook/preview'; + + const meta = config.meta({ title: 'Component' }); + `); + }); + + it('should wrap stories in a meta.story method', async () => { + await expect( + transform(dedent` + const componentMeta = { title: 'Component' }; + export default componentMeta; + export const A = { + args: { primary: true }, + render: (args) => + }; + `) + ).resolves.toMatchInlineSnapshot(` + import config from '#.storybook/preview'; + + const meta = config.meta({ title: 'Component' }); + export const A = meta.story({ + args: { primary: true }, + render: (args) => , + }); + `); + }); + + it('should respect existing config imports', async () => { + await expect( + transform(dedent` + import { decorators } from "#.storybook/preview"; + const componentMeta = { title: 'Component' }; + export default componentMeta; + export const A = { + args: { primary: true }, + render: (args) => + }; + `) + ).resolves.toMatchInlineSnapshot(` + import config, { decorators } from '#.storybook/preview'; + + const meta = config.meta({ title: 'Component' }); + export const A = meta.story({ + args: { primary: true }, + render: (args) => , + }); + `); + }); + + it('should reuse existing default config import name', async () => { + await expect( + transform(dedent` + import previewConfig from "#.storybook/preview"; + const componentMeta = { title: 'Component' }; + export default componentMeta; + export const A = { + args: { primary: true }, + render: (args) => + }; + `) + ).resolves.toMatchInlineSnapshot(` + import previewConfig from '#.storybook/preview'; + + const meta = previewConfig.meta({ title: 'Component' }); + export const A = meta.story({ + args: { primary: true }, + render: (args) => , + }); + `); + }); + + it('if there is an existing local constant called config, rename storybook config import', async () => { + await expect( + transform(dedent` + const componentMeta = { title: 'Component' }; + export default componentMeta; + const config = {}; + export const A = { + args: { primary: true }, + render: (args) => + }; + `) + ).resolves.toMatchInlineSnapshot(` + import storybookConfig from '#.storybook/preview'; + + const meta = storybookConfig.meta({ title: 'Component' }); + const config = {}; + export const A = meta.story({ + args: { primary: true }, + render: (args) => , + }); + `); + }); + + it('converts CSF1 into CSF4 with render', async () => { + await expect( + transform(dedent` + const meta = { title: 'Component' }; + export default meta; + export const CSF1Story = () =>
Hello
; + `) + ).resolves.toMatchInlineSnapshot(` + import config from '#.storybook/preview'; + + const meta = config.meta({ title: 'Component' }); + export const CSF1Story = meta.story({ + render: () =>
Hello
, + }); + `); + }); + }); + + describe('typescript', () => { + const inlineMetaSatisfies = dedent` + import { Meta, StoryObj as CSF3 } from '@storybook/react'; + import { ComponentProps } from './Component'; + + export default { title: 'Component', component: Component } satisfies Meta; + + export const A: CSF3 = { + args: { primary: true } + }; + `; + it('meta satisfies syntax', async () => { + await expect(transform(inlineMetaSatisfies)).resolves.toMatchInlineSnapshot(` + import config from '#.storybook/preview'; + + import { ComponentProps } from './Component'; + + const meta = config.meta({ title: 'Component', component: Component }); + + export const A = meta.story({ + args: { primary: true }, + }); + `); + }); + + const inlineMetaAs = dedent` + import { Meta, StoryObj as CSF3 } from '@storybook/react'; + import { ComponentProps } from './Component'; + + export default { title: 'Component', component: Component } as Meta; + + export const A: CSF3 = { + args: { primary: true } + }; + `; + it('meta as syntax', async () => { + await expect(transform(inlineMetaAs)).resolves.toMatchInlineSnapshot(` + import config from '#.storybook/preview'; + + import { ComponentProps } from './Component'; + + const meta = config.meta({ title: 'Component', component: Component }); + + export const A = meta.story({ + args: { primary: true }, + }); + `); + }); + const metaSatisfies = dedent` + import { Meta, StoryObj as CSF3 } from '@storybook/react'; + import { ComponentProps } from './Component'; + + const meta = { title: 'Component', component: Component } satisfies Meta + export default meta; + + export const A: CSF3 = { + args: { primary: true } + }; + `; + it('meta satisfies syntax', async () => { + await expect(transform(metaSatisfies)).resolves.toMatchInlineSnapshot(` + import config from '#.storybook/preview'; + + import { ComponentProps } from './Component'; + + const meta = config.meta({ title: 'Component', component: Component }); + + export const A = meta.story({ + args: { primary: true }, + }); + `); + }); + + const metaAs = dedent` + import { Meta, StoryObj as CSF3 } from '@storybook/react'; + import { ComponentProps } from './Component'; + + const meta = { title: 'Component', component: Component } as Meta + export default meta; + + export const A: CSF3 = { + args: { primary: true } + }; + `; + it('meta as syntax', async () => { + await expect(transform(metaAs)).resolves.toMatchInlineSnapshot(` + import config from '#.storybook/preview'; + + import { ComponentProps } from './Component'; + + const meta = config.meta({ title: 'Component', component: Component }); + + export const A = meta.story({ + args: { primary: true }, + }); + `); + }); + + const storySatisfies = dedent` + import { Meta, StoryObj as CSF3 } from '@storybook/react'; + import { ComponentProps } from './Component'; + + const meta = { title: 'Component', component: Component } as Meta + export default meta; + + export const A = { + args: { primary: true } + } satisfies CSF3; + `; + it('story satisfies syntax', async () => { + await expect(transform(storySatisfies)).resolves.toMatchInlineSnapshot(` + import config from '#.storybook/preview'; + + import { ComponentProps } from './Component'; + + const meta = config.meta({ title: 'Component', component: Component }); + + export const A = meta.story({ + args: { primary: true }, + }); + `); + }); + + const storyAs = dedent` + import { Meta, StoryObj as CSF3 } from '@storybook/react'; + import { ComponentProps } from './Component'; + + const meta = { title: 'Component', component: Component } as Meta + export default meta; + + export const A = { + args: { primary: true } + } as CSF3; + `; + it('story as syntax', async () => { + await expect(transform(storyAs)).resolves.toMatchInlineSnapshot(` + import config from '#.storybook/preview'; + + import { ComponentProps } from './Component'; + + const meta = config.meta({ title: 'Component', component: Component }); + + export const A = meta.story({ + args: { primary: true }, + }); + `); + }); + + it('should yield the same result to all syntaxes', async () => { + const allSnippets = await Promise.all([ + transform(inlineMetaSatisfies), + transform(inlineMetaAs), + transform(metaSatisfies), + transform(metaAs), + transform(storySatisfies), + transform(storyAs), + ]); + + allSnippets.forEach((result) => { + expect(result).toEqual(allSnippets[0]); + }); + }); + + it('should remove unused Story types', async () => { + await expect( + transform( + `import { Meta, StoryObj as CSF3 } from '@storybook/react'; + import { ComponentProps } from './Component'; + + export default {}; + type Story = StoryObj; + + export const A: Story = {};` + ) + ).resolves.toMatchInlineSnapshot(` + import config from '#.storybook/preview'; + + import { ComponentProps } from './Component'; + + const meta = config.meta({}); + + export const A = meta.story({}); + `); + }); + }); +}); diff --git a/code/lib/cli-storybook/src/codemod/helpers/story-to-csf-factory.ts b/code/lib/cli-storybook/src/codemod/helpers/story-to-csf-factory.ts new file mode 100644 index 000000000000..f374eea9df80 --- /dev/null +++ b/code/lib/cli-storybook/src/codemod/helpers/story-to-csf-factory.ts @@ -0,0 +1,188 @@ +/* eslint-disable no-underscore-dangle */ +import { types as t } from 'storybook/internal/babel'; +import { isValidPreviewPath, loadCsf, printCsf } from 'storybook/internal/csf-tools'; + +import type { FileInfo } from '../../automigrate/codemod'; +import { logger } from '../csf-factories'; +import { cleanupTypeImports } from './csf-factories-utils'; + +export async function storyToCsfFactory(info: FileInfo) { + const csf = loadCsf(info.source, { makeTitle: () => 'FIXME' }); + try { + csf.parse(); + } catch (err) { + logger.log(`Error when parsing ${info.path}, skipping:\n${err}`); + return info.source; + } + + const metaVariableName = 'meta'; + + /** + * Add the preview import if it doesn't exist yet: + * + * `import { config } from '#.storybook/preview'`; + */ + const programNode = csf._ast.program; + let foundConfigImport = false; + + // Check if a root-level constant named 'config' exists + const hasRootLevelConfig = programNode.body.some( + (n) => + t.isVariableDeclaration(n) && + n.declarations.some((declaration) => t.isIdentifier(declaration.id, { name: 'config' })) + ); + + let sbConfigImportName = hasRootLevelConfig ? 'storybookConfig' : 'config'; + + const sbConfigImportSpecifier = t.importDefaultSpecifier(t.identifier(sbConfigImportName)); + + programNode.body.forEach((node) => { + if (t.isImportDeclaration(node) && isValidPreviewPath(node.source.value)) { + const defaultImportSpecifier = node.specifiers.find((specifier) => + t.isImportDefaultSpecifier(specifier) + ); + + if (!defaultImportSpecifier) { + node.specifiers.push(sbConfigImportSpecifier); + } else if (defaultImportSpecifier.local.name !== sbConfigImportName) { + sbConfigImportName = defaultImportSpecifier.local.name; + } + + foundConfigImport = true; + } + }); + + const hasMeta = !!csf._meta; + + Object.entries(csf._storyExports).forEach(([key, decl]) => { + const id = decl.id; + const declarator = decl as t.VariableDeclarator; + let init = t.isVariableDeclarator(declarator) ? declarator.init : undefined; + + if (t.isIdentifier(id) && init) { + if (t.isTSSatisfiesExpression(init) || t.isTSAsExpression(init)) { + init = init.expression; + } + + if (t.isObjectExpression(init)) { + const typeAnnotation = id.typeAnnotation; + // Remove type annotation as it's now inferred + if (typeAnnotation) { + id.typeAnnotation = null; + } + + // Wrap the object in `meta.story()` + declarator.init = t.callExpression( + t.memberExpression(t.identifier(metaVariableName), t.identifier('story')), + [init] + ); + } else if (t.isArrowFunctionExpression(init)) { + // Transform CSF1 to meta.story({ render: }) + const renderProperty = t.objectProperty(t.identifier('render'), init); + + const objectExpression = t.objectExpression([renderProperty]); + + declarator.init = t.callExpression( + t.memberExpression(t.identifier(metaVariableName), t.identifier('story')), + [objectExpression] + ); + } + } + }); + + // modify meta + if (csf._metaPath) { + let declaration = csf._metaPath.node.declaration; + if (t.isTSSatisfiesExpression(declaration) || t.isTSAsExpression(declaration)) { + declaration = declaration.expression; + } + + if (t.isObjectExpression(declaration)) { + const metaVariable = t.variableDeclaration('const', [ + t.variableDeclarator( + t.identifier(metaVariableName), + t.callExpression( + t.memberExpression(t.identifier(sbConfigImportName), t.identifier('meta')), + [declaration] + ) + ), + ]); + csf._metaPath.replaceWith(metaVariable); + } else if (t.isIdentifier(declaration)) { + /** + * Transform const declared metas: + * + * `const meta = {}; export default meta;` + * + * Into a meta call: + * + * `const meta = config.meta({ title: 'A' });` + */ + const binding = csf._metaPath.scope.getBinding(declaration.name); + if (binding && binding.path.isVariableDeclarator()) { + const originalName = declaration.name; + + // Always rename the meta variable to 'meta' + binding.path.node.id = t.identifier(metaVariableName); + + let init = binding.path.node.init; + if (t.isTSSatisfiesExpression(init) || t.isTSAsExpression(init)) { + init = init.expression; + } + if (t.isObjectExpression(init)) { + binding.path.node.init = t.callExpression( + t.memberExpression(t.identifier(sbConfigImportName), t.identifier('meta')), + [init] + ); + } + + // Update all references to the original name + csf._metaPath.scope.rename(originalName, metaVariableName); + } + + // Remove the default export, it's not needed anymore + csf._metaPath.remove(); + } + } + + if (hasMeta && !foundConfigImport) { + const configImport = t.importDeclaration( + [t.importDefaultSpecifier(t.identifier(sbConfigImportName))], + t.stringLiteral('#.storybook/preview') + ); + programNode.body.unshift(configImport); + } + + // Remove type imports – now inferred – from @storybook/* packages + const disallowList = [ + 'Story', + 'StoryFn', + 'StoryObj', + 'Meta', + 'MetaObj', + 'ComponentStory', + 'ComponentMeta', + ]; + programNode.body = cleanupTypeImports(programNode, disallowList); + + // Remove unused type aliases e.g. `type Story = StoryObj;` + programNode.body.forEach((node, index) => { + if (t.isTSTypeAliasDeclaration(node)) { + const isUsed = programNode.body.some((otherNode) => { + if (t.isVariableDeclaration(otherNode)) { + return otherNode.declarations.some( + (declaration) => + t.isIdentifier(declaration.init) && declaration.init.name === node.id.name + ); + } + return false; + }); + + if (!isUsed) { + programNode.body.splice(index, 1); + } + } + }); + + return printCsf(csf).code; +} diff --git a/code/lib/cli-storybook/src/migrate.ts b/code/lib/cli-storybook/src/migrate.ts index e985971b5a05..9075a9d9e8b2 100644 --- a/code/lib/cli-storybook/src/migrate.ts +++ b/code/lib/cli-storybook/src/migrate.ts @@ -9,6 +9,7 @@ import { listCodemods, runCodemod } from '@storybook/codemod'; import { runFixes } from './automigrate'; import { mdxToCSF } from './automigrate/fixes/mdx-to-csf'; +import { getStorybookData } from './automigrate/helpers/mainConfigFile'; const logger = console; @@ -33,15 +34,11 @@ export async function migrate( if (migration === 'mdx-to-csf' && !dryRun) { const packageManager = JsPackageManagerFactory.getPackageManager(); - const [packageJson, storybookVersion] = await Promise.all([ - packageManager.retrievePackageJson(), - getCoercedStorybookVersion(packageManager), - ]); - const { configDir: inferredConfigDir, mainConfig: mainConfigPath } = getStorybookInfo( - packageJson, - userSpecifiedConfigDir - ); - const configDir = userSpecifiedConfigDir || inferredConfigDir || '.storybook'; + const { configDir, mainConfig, mainConfigPath, storybookVersion, packageJson } = + await getStorybookData({ + packageManager, + configDir: userSpecifiedConfigDir, + }); // GUARDS if (!storybookVersion) { @@ -57,6 +54,8 @@ export async function migrate( configDir, mainConfigPath, packageManager, + mainConfig, + packageJson, storybookVersion, beforeVersion: storybookVersion, isUpgrade: false, diff --git a/code/lib/cli-storybook/src/upgrade.ts b/code/lib/cli-storybook/src/upgrade.ts index 6657fb0ee691..5a8bd0a4efb0 100644 --- a/code/lib/cli-storybook/src/upgrade.ts +++ b/code/lib/cli-storybook/src/upgrade.ts @@ -24,6 +24,7 @@ import semver, { clean, eq, lt, prerelease } from 'semver'; import { dedent } from 'ts-dedent'; import { autoblock } from './autoblock/index'; +import { getStorybookData } from './automigrate/helpers/mainConfigFile'; import { automigrate } from './automigrate/index'; type Package = { @@ -157,10 +158,7 @@ export const doUpgrade = async ({ logger.warn(new UpgradeStorybookToSameVersionError({ beforeVersion }).message); } - const [latestCLIVersionOnNPM, packageJson] = await Promise.all([ - packageManager.latestVersion('storybook'), - packageManager.retrievePackageJson(), - ]); + const latestCLIVersionOnNPM = await packageManager.latestVersion('storybook'); const isCLIOutdated = lt(currentCLIVersion, latestCLIVersionOnNPM); const isCLIExactLatest = currentCLIVersion === latestCLIVersionOnNPM; @@ -198,13 +196,11 @@ export const doUpgrade = async ({ let results; - const { configDir: inferredConfigDir, mainConfig: mainConfigPath } = getStorybookInfo( - packageJson, - userSpecifiedConfigDir - ); - const configDir = userSpecifiedConfigDir || inferredConfigDir || '.storybook'; - - const mainConfig = await loadMainConfig({ configDir }); + const { configDir, mainConfig, mainConfigPath, previewConfigPath, packageJson } = + await getStorybookData({ + packageManager, + configDir: userSpecifiedConfigDir, + }); // GUARDS if (!beforeVersion) { @@ -277,7 +273,10 @@ export const doUpgrade = async ({ dryRun, yes, packageManager, + packageJson, + mainConfig, configDir, + previewConfigPath, mainConfigPath, beforeVersion, storybookVersion: currentCLIVersion, diff --git a/code/lib/codemod/package.json b/code/lib/codemod/package.json index db7317edca1f..81ec7b4b59ee 100644 --- a/code/lib/codemod/package.json +++ b/code/lib/codemod/package.json @@ -28,7 +28,6 @@ }, "./dist/transforms/add-component-parameters.js": "./dist/transforms/add-component-parameters.js", "./dist/transforms/csf-2-to-3.js": "./dist/transforms/csf-2-to-3.js", - "./dist/transforms/csf-3-to-4.js": "./dist/transforms/csf-3-to-4.js", "./dist/transforms/csf-hoist-story-annotations.js": "./dist/transforms/csf-hoist-story-annotations.js", "./dist/transforms/find-implicit-spies.js": "./dist/transforms/find-implicit-spies.js", "./dist/transforms/move-builtin-addons.js": "./dist/transforms/move-builtin-addons.js", @@ -94,7 +93,6 @@ "./src/transforms/storiesof-to-csf.js", "./src/transforms/mdx-to-csf.ts", "./src/transforms/csf-2-to-3.ts", - "./src/transforms/csf-3-to-4.ts", "./src/transforms/csf-hoist-story-annotations.js", "./src/transforms/find-implicit-spies.ts", "./src/transforms/add-component-parameters.js", diff --git a/code/lib/codemod/src/transforms/__tests__/csf-3-to-4.test.ts b/code/lib/codemod/src/transforms/__tests__/csf-3-to-4.test.ts deleted file mode 100644 index 8cf66dff1ea2..000000000000 --- a/code/lib/codemod/src/transforms/__tests__/csf-3-to-4.test.ts +++ /dev/null @@ -1,234 +0,0 @@ -import { describe, expect, it } from 'vitest'; - -import { dedent } from 'ts-dedent'; - -import _transform from '../csf-3-to-4'; - -expect.addSnapshotSerializer({ - serialize: (val: any) => (typeof val === 'string' ? val : val.toString()), - test: () => true, -}); - -const transform = async (source: string) => - (await _transform({ source, path: 'Component.stories.tsx' })).trim(); - -describe('csf-3-to-4', () => { - describe('javascript', () => { - it('should wrap const declared meta', async () => { - await expect( - transform(dedent` - const meta = { title: 'Component' }; - export default meta; - `) - ).resolves.toMatchInlineSnapshot(` - import { config } from "#.storybook/preview"; - const meta = config.meta({ - title: 'Component' - }); - `); - }); - - it('should transform and wrap inline default exported meta', async () => { - await expect( - transform(dedent` - export default { title: 'Component' }; - `) - ).resolves.toMatchInlineSnapshot(` - import { config } from "#.storybook/preview"; - const meta = config.meta({ - title: 'Component' - }); - `); - }); - - it('should rename meta object to meta if it has a different name', async () => { - await expect( - transform(dedent` - const componentMeta = { title: 'Component' }; - export default componentMeta; - `) - ).resolves.toMatchInlineSnapshot(` - import { config } from "#.storybook/preview"; - const meta = config.meta({ - title: 'Component' - }); - `); - }); - - it('should wrap stories in a meta.story method', async () => { - await expect( - transform(dedent` - const componentMeta = { title: 'Component' }; - export default componentMeta; - export const A = { - args: { primary: true }, - render: (args) => - }; - `) - ).resolves.toMatchInlineSnapshot(` - import { config } from "#.storybook/preview"; - const meta = config.meta({ - title: 'Component' - }); - export const A = meta.story({ - args: { - primary: true - }, - render: args => - }); - `); - }); - - it('should respect existing config imports', async () => { - await expect( - transform(dedent` - import { decorators } from "#.storybook/preview"; - const componentMeta = { title: 'Component' }; - export default componentMeta; - export const A = { - args: { primary: true }, - render: (args) => - }; - `) - ).resolves.toMatchInlineSnapshot(` - import { decorators, config } from "#.storybook/preview"; - const meta = config.meta({ - title: 'Component' - }); - export const A = meta.story({ - args: { - primary: true - }, - render: args => - }); - `); - }); - }); - - describe('typescript', () => { - const metaSatisfies = dedent` - import { Meta, StoryObj as CSF3 } from '@storybook/react'; - import { ComponentProps } from './Component'; - - const meta = { title: 'Component', component: Component } satisfies Meta - export default meta; - - export const A: CSF3 = { - args: { primary: true } - }; - `; - it('meta satisfies syntax', async () => { - await expect(transform(metaSatisfies)).resolves.toMatchInlineSnapshot(` - import { config } from "#.storybook/preview"; - import { Meta, StoryObj as CSF3 } from '@storybook/react'; - import { ComponentProps } from './Component'; - const meta = config.meta({ - title: 'Component', - component: Component - }); - export const A = meta.story({ - args: { - primary: true - } - }); - `); - }); - - const metaAs = dedent` - import { Meta, StoryObj as CSF3 } from '@storybook/react'; - import { ComponentProps } from './Component'; - - const meta = { title: 'Component', component: Component } as Meta - export default meta; - - export const A: CSF3 = { - args: { primary: true } - }; - `; - it('meta as syntax', async () => { - await expect(transform(metaAs)).resolves.toMatchInlineSnapshot(` - import { config } from "#.storybook/preview"; - import { Meta, StoryObj as CSF3 } from '@storybook/react'; - import { ComponentProps } from './Component'; - const meta = config.meta({ - title: 'Component', - component: Component - }); - export const A = meta.story({ - args: { - primary: true - } - }); - `); - }); - - const storySatisfies = dedent` - import { Meta, StoryObj as CSF3 } from '@storybook/react'; - import { ComponentProps } from './Component'; - - const meta = { title: 'Component', component: Component } as Meta - export default meta; - - export const A = { - args: { primary: true } - } satisfies CSF3; - `; - it('story satisfies syntax', async () => { - await expect(transform(storySatisfies)).resolves.toMatchInlineSnapshot(` - import { config } from "#.storybook/preview"; - import { Meta, StoryObj as CSF3 } from '@storybook/react'; - import { ComponentProps } from './Component'; - const meta = config.meta({ - title: 'Component', - component: Component - }); - export const A = meta.story({ - args: { - primary: true - } - }); - `); - }); - - const storyAs = dedent` - import { Meta, StoryObj as CSF3 } from '@storybook/react'; - import { ComponentProps } from './Component'; - - const meta = { title: 'Component', component: Component } as Meta - export default meta; - - export const A = { - args: { primary: true } - } as CSF3; - `; - it('story as syntax', async () => { - await expect(transform(storyAs)).resolves.toMatchInlineSnapshot(` - import { config } from "#.storybook/preview"; - import { Meta, StoryObj as CSF3 } from '@storybook/react'; - import { ComponentProps } from './Component'; - const meta = config.meta({ - title: 'Component', - component: Component - }); - export const A = meta.story({ - args: { - primary: true - } - }); - `); - }); - - it('should yield the same result to all syntaxes', async () => { - const allSnippets = await Promise.all([ - transform(metaSatisfies), - transform(metaAs), - transform(storySatisfies), - transform(storyAs), - ]); - - allSnippets.forEach((result) => { - expect(result).toEqual(allSnippets[0]); - }); - }); - }); -}); diff --git a/code/lib/codemod/src/transforms/csf-3-to-4.ts b/code/lib/codemod/src/transforms/csf-3-to-4.ts deleted file mode 100644 index 7efe6a430c06..000000000000 --- a/code/lib/codemod/src/transforms/csf-3-to-4.ts +++ /dev/null @@ -1,179 +0,0 @@ -/* eslint-disable no-underscore-dangle */ -import { isValidPreviewPath, loadCsf } from '@storybook/core/csf-tools'; - -import type { BabelFile } from '@babel/core'; -import * as babel from '@babel/core'; -import { - isIdentifier, - isImportDeclaration, - isImportSpecifier, - isObjectExpression, - isTSAsExpression, - isTSSatisfiesExpression, - isVariableDeclaration, -} from '@babel/types'; -import type { FileInfo } from 'jscodeshift'; - -export default async function transform(info: FileInfo) { - const csf = loadCsf(info.source, { makeTitle: (title) => title }); - const fileNode = csf._ast; - // @ts-expect-error File is not yet exposed, see https://github.com/babel/babel/issues/11350#issuecomment-644118606 - const file: BabelFile = new babel.File( - { filename: info.path }, - { code: info.source, ast: fileNode } - ); - - const metaVariableName = 'meta'; - - /** - * Add the preview import if it doesn't exist yet: - * - * `import { config } from '#.storybook/preview'`; - */ - const programNode = fileNode.program; - let foundConfigImport = false; - - programNode.body.forEach((node) => { - if (isImportDeclaration(node) && isValidPreviewPath(node.source.value)) { - const hasConfigSpecifier = node.specifiers.some( - (specifier) => - isImportSpecifier(specifier) && isIdentifier(specifier.imported, { name: 'config' }) - ); - - if (!hasConfigSpecifier) { - node.specifiers.push( - babel.types.importSpecifier( - babel.types.identifier('config'), - babel.types.identifier('config') - ) - ); - } - - foundConfigImport = true; - } - }); - - let hasMeta = false; - - file.path.traverse({ - // Meta export - ExportDefaultDeclaration: (path) => { - hasMeta = true; - const declaration = path.node.declaration; - - /** - * Transform inline default export: `export default { title: 'A' };` - * - * Into a meta call: `const meta = config.meta({ title: 'A' });` - */ - if (isObjectExpression(declaration)) { - const metaVariable = babel.types.variableDeclaration('const', [ - babel.types.variableDeclarator( - babel.types.identifier(metaVariableName), - babel.types.callExpression( - babel.types.memberExpression( - babel.types.identifier('config'), - babel.types.identifier('meta') - ), - [declaration] - ) - ), - ]); - - path.replaceWith(metaVariable); - } else if (isIdentifier(declaration)) { - /** - * Transform const declared metas: - * - * `const meta = {}; export default meta;` - * - * Into a meta call: - * - * `const meta = config.meta({ title: 'A' });` - */ - const binding = path.scope.getBinding(declaration.name); - if (binding && binding.path.isVariableDeclarator()) { - const originalName = declaration.name; - - // Always rename the meta variable to 'meta' - binding.path.node.id = babel.types.identifier(metaVariableName); - - let init = binding.path.node.init; - if (isTSSatisfiesExpression(init) || isTSAsExpression(init)) { - init = init.expression; - } - if (isObjectExpression(init)) { - binding.path.node.init = babel.types.callExpression( - babel.types.memberExpression( - babel.types.identifier('config'), - babel.types.identifier('meta') - ), - [init] - ); - } - - // Update all references to the original name - path.scope.rename(originalName, metaVariableName); - } - - // Remove the default export, it's not needed anymore - path.remove(); - } - }, - // Story export - ExportNamedDeclaration: (path) => { - const declaration = path.node.declaration; - - if (!declaration || !isVariableDeclaration(declaration) || !hasMeta) { - return; - } - - declaration.declarations.forEach((decl) => { - const id = decl.id; - let init = decl.init; - - if (isIdentifier(id) && init) { - if (isTSSatisfiesExpression(init) || isTSAsExpression(init)) { - init = init.expression; - } - - if (isObjectExpression(init)) { - const typeAnnotation = id.typeAnnotation; - // Remove type annotation as it's now inferred - if (typeAnnotation) { - id.typeAnnotation = null; - } - - // Wrap the object in `meta.story()` - decl.init = babel.types.callExpression( - babel.types.memberExpression( - babel.types.identifier(metaVariableName), - babel.types.identifier('story') - ), - [init] - ); - } - } - }); - }, - }); - - if (hasMeta && !foundConfigImport) { - const configImport = babel.types.importDeclaration( - [ - babel.types.importSpecifier( - babel.types.identifier('config'), - babel.types.identifier('config') - ), - ], - babel.types.stringLiteral('#.storybook/preview') - ); - programNode.body.unshift(configImport); - } - - // Generate the transformed code - const { code } = babel.transformFromAstSync(fileNode, info.source, { - parserOpts: { sourceType: 'module' }, - }); - return code; -} diff --git a/code/renderers/react/src/__test__/Button.csf4.stories.tsx b/code/renderers/react/src/__test__/Button.csf4.stories.tsx index 6a57e1d5a909..067e481a3d6a 100644 --- a/code/renderers/react/src/__test__/Button.csf4.stories.tsx +++ b/code/renderers/react/src/__test__/Button.csf4.stories.tsx @@ -5,10 +5,10 @@ import { expect, fn, mocked, userEvent, within } from '@storybook/test'; import { action } from '@storybook/addon-actions'; -import { defineConfig } from '../preview'; +import { definePreview } from '../preview'; import { Button } from './Button'; -const config = defineConfig({ args: { children: 'TODO: THIS IS NOT WORKING YET' } }); +const config = definePreview({}); const meta = config.meta({ id: 'button-component', diff --git a/code/renderers/react/src/csf-factories.test.tsx b/code/renderers/react/src/csf-factories.test.tsx index 12ea20a79ce2..2bceb23a53c6 100644 --- a/code/renderers/react/src/csf-factories.test.tsx +++ b/code/renderers/react/src/csf-factories.test.tsx @@ -1,10 +1,10 @@ import { expect, test } from 'vitest'; import { Button } from './__test__/Button'; -import { defineConfig } from './preview'; +import { definePreview } from './preview'; test('csf factories', () => { - const config = defineConfig({ + const config = definePreview({ addons: [ { decorators: [], diff --git a/code/renderers/react/src/preview.tsx b/code/renderers/react/src/preview.tsx index 55489ef5bdab..9230029d907c 100644 --- a/code/renderers/react/src/preview.tsx +++ b/code/renderers/react/src/preview.tsx @@ -17,7 +17,7 @@ import * as reactAnnotations from './entry-preview'; import * as reactDocsAnnotations from './entry-preview-docs'; import type { ReactRenderer } from './types'; -export function defineConfig(config: PreviewConfigData) { +export function definePreview(config: PreviewConfigData) { return new PreviewConfig({ ...config, addons: [reactAnnotations, reactDocsAnnotations, ...(config.addons ?? [])], diff --git a/code/yarn.lock b/code/yarn.lock index 2307a03bf243..4fc46e9e2c86 100644 --- a/code/yarn.lock +++ b/code/yarn.lock @@ -6128,6 +6128,7 @@ __metadata: globby: "npm:^14.0.1" jscodeshift: "npm:^0.15.1" leven: "npm:^3.1.0" + p-limit: "npm:^6.2.0" picocolors: "npm:^1.1.0" prompts: "npm:^2.4.0" semver: "npm:^7.3.7" @@ -22691,6 +22692,15 @@ __metadata: languageName: node linkType: hard +"p-limit@npm:^6.2.0": + version: 6.2.0 + resolution: "p-limit@npm:6.2.0" + dependencies: + yocto-queue: "npm:^1.1.1" + checksum: 10c0/448bf55a1776ca1444594d53b3c731e68cdca00d44a6c8df06a2f6e506d5bbd540ebb57b05280f8c8bff992a630ed782a69612473f769a7473495d19e2270166 + languageName: node + linkType: hard + "p-locate@npm:^2.0.0": version: 2.0.0 resolution: "p-locate@npm:2.0.0" @@ -30496,6 +30506,13 @@ __metadata: languageName: node linkType: hard +"yocto-queue@npm:^1.1.1": + version: 1.1.1 + resolution: "yocto-queue@npm:1.1.1" + checksum: 10c0/cb287fe5e6acfa82690acb43c283de34e945c571a78a939774f6eaba7c285bacdf6c90fbc16ce530060863984c906d2b4c6ceb069c94d1e0a06d5f2b458e2a92 + languageName: node + linkType: hard + "yoctocolors-cjs@npm:^2.1.2": version: 2.1.2 resolution: "yoctocolors-cjs@npm:2.1.2" diff --git a/scripts/tasks/sandbox-parts.ts b/scripts/tasks/sandbox-parts.ts index 4055d84b7bc7..9c5a40bf2771 100644 --- a/scripts/tasks/sandbox-parts.ts +++ b/scripts/tasks/sandbox-parts.ts @@ -426,10 +426,10 @@ export async function setupVitest(details: TemplateDetails, options: PassedOptio setupFilePath, dedent`import { beforeAll } from 'vitest' import { setProjectAnnotations } from '${storybookPackage}' - import * as projectAnnotations from './preview' + import projectAnnotations from './preview' // setProjectAnnotations still kept to support non-CSF4 story tests - const annotations = setProjectAnnotations(projectAnnotations.config.annotations) + const annotations = setProjectAnnotations(projectAnnotations.annotations) beforeAll(annotations.beforeAll) ` ); @@ -819,55 +819,14 @@ export const extendPreview: Task['run'] = async ({ template, sandboxDir }) => { template.expected.framework === '@storybook/react-vite' && !template.skipTasks.includes('vitest-integration') ) { - // add CSF4 style config - previewConfig.setImport(['defineConfig'], '@storybook/react/preview'); - // and all of the addons/previewAnnotations that are needed previewConfig.setImport(null, '../src/stories/components'); - previewConfig.setImport( - { namespace: 'addonEssentialsAnnotations' }, - '@storybook/addon-essentials/entry-preview' - ); - previewConfig.setImport({ namespace: 'addonA11yAnnotations' }, '@storybook/addon-a11y/preview'); - previewConfig.setImport( - { namespace: 'addonActionsAnnotations' }, - '@storybook/addon-actions/preview' - ); - previewConfig.setImport( - { namespace: 'addonTestAnnotations' }, - '@storybook/experimental-addon-test/preview' - ); previewConfig.setImport({ namespace: 'coreAnnotations' }, '../template-stories/core/preview'); previewConfig.setImport( { namespace: 'toolbarAnnotations' }, '../template-stories/addons/toolbars/preview' ); - - previewConfig.setBodyDeclaration( - t.exportNamedDeclaration( - t.variableDeclaration('const', [ - t.variableDeclarator( - t.identifier('config'), - t.callExpression(t.identifier('defineConfig'), [ - t.objectExpression([ - t.spreadElement(t.identifier('preview')), - t.objectProperty( - t.identifier('addons'), - t.arrayExpression([ - t.identifier('addonEssentialsAnnotations'), - t.identifier('addonA11yAnnotations'), - t.identifier('addonActionsAnnotations'), - t.identifier('addonTestAnnotations'), - t.identifier('coreAnnotations'), - t.identifier('toolbarAnnotations'), - ]) - ), - ]), - ]) - ), - ]), - [] - ) - ); + previewConfig.appendNodeToArray(['addons'], t.identifier('coreAnnotations')); + previewConfig.appendNodeToArray(['addons'], t.identifier('toolbarAnnotations')); } if (template.expected.builder.includes('vite')) { @@ -882,10 +841,9 @@ export const runMigrations: Task['run'] = async ({ sandboxDir, template }, { dry template.expected.framework === '@storybook/react-vite' && !template.skipTasks.includes('vitest-integration') ) { - await executeCLIStep(steps.migrate, { + await executeCLIStep(steps.automigrate, { cwd: sandboxDir, - argument: 'csf-3-to-4', - optionValues: { glob: 'src/stories/*.stories.*' }, + argument: 'csf-factories', dryRun, debug, }); @@ -900,7 +858,6 @@ export async function setImportMap(cwd: string) { storybook: './template-stories/core/utils.mock.ts', default: './template-stories/core/utils.ts', }, - '#*': ['./*', './*.ts', './*.tsx'], }; await writeJson(join(cwd, 'package.json'), packageJson, { spaces: 2 }); diff --git a/scripts/tasks/sandbox.ts b/scripts/tasks/sandbox.ts index ef79e3d4c32f..33a3282d44d2 100644 --- a/scripts/tasks/sandbox.ts +++ b/scripts/tasks/sandbox.ts @@ -144,12 +144,12 @@ export const sandbox: Task = { await extendMain(details, options); - await extendPreview(details, options); - await setImportMap(details.sandboxDir); await runMigrations(details, options); + await extendPreview(details, options); + logger.info(`✅ Storybook sandbox created at ${details.sandboxDir}`); }, }; diff --git a/scripts/utils/cli-step.ts b/scripts/utils/cli-step.ts index b685eb7b16ee..1432f9b264c7 100644 --- a/scripts/utils/cli-step.ts +++ b/scripts/utils/cli-step.ts @@ -82,6 +82,13 @@ export const steps = { glob: { type: 'string' }, }), }, + automigrate: { + command: 'automigrate', + hasArgument: true, + description: 'Run automigrations', + icon: '🤖', + options: createOptions({}), + }, }; export async function executeCLIStep(