From 04ea84587b15c3669b4e1ee193a75018ba162341 Mon Sep 17 00:00:00 2001 From: Yann Braga Date: Wed, 15 Jan 2025 13:58:14 +0100 Subject: [PATCH] Use csf-tools in csf factory codemod --- code/core/src/csf-tools/CsfFile.ts | 12 +- .../transforms/__tests__/csf-3-to-4.test.ts | 105 +++---- code/lib/codemod/src/transforms/csf-3-to-4.ts | 274 ++++++++++-------- 3 files changed, 212 insertions(+), 179 deletions(-) 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/lib/codemod/src/transforms/__tests__/csf-3-to-4.test.ts b/code/lib/codemod/src/transforms/__tests__/csf-3-to-4.test.ts index 8cf66dff1ea2..7dcef73c2e53 100644 --- 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 @@ -21,10 +21,9 @@ describe('csf-3-to-4', () => { export default meta; `) ).resolves.toMatchInlineSnapshot(` - import { config } from "#.storybook/preview"; - const meta = config.meta({ - title: 'Component' - }); + import { config } from '#.storybook/preview'; + + const meta = config.meta({ title: 'Component' }); `); }); @@ -34,9 +33,10 @@ describe('csf-3-to-4', () => { export default { title: 'Component' }; `) ).resolves.toMatchInlineSnapshot(` - import { config } from "#.storybook/preview"; + import { config } from '#.storybook/preview'; + const meta = config.meta({ - title: 'Component' + title: 'Component', }); `); }); @@ -48,10 +48,9 @@ describe('csf-3-to-4', () => { export default componentMeta; `) ).resolves.toMatchInlineSnapshot(` - import { config } from "#.storybook/preview"; - const meta = config.meta({ - title: 'Component' - }); + import { config } from '#.storybook/preview'; + + const meta = config.meta({ title: 'Component' }); `); }); @@ -66,15 +65,12 @@ describe('csf-3-to-4', () => { }; `) ).resolves.toMatchInlineSnapshot(` - import { config } from "#.storybook/preview"; - const meta = config.meta({ - title: 'Component' - }); + import { config } from '#.storybook/preview'; + + const meta = config.meta({ title: 'Component' }); export const A = meta.story({ - args: { - primary: true - }, - render: args => + args: { primary: true }, + render: (args) => , }); `); }); @@ -91,15 +87,12 @@ describe('csf-3-to-4', () => { }; `) ).resolves.toMatchInlineSnapshot(` - import { decorators, config } from "#.storybook/preview"; - const meta = config.meta({ - title: 'Component' - }); + import { config, decorators } from '#.storybook/preview'; + + const meta = config.meta({ title: 'Component' }); export const A = meta.story({ - args: { - primary: true - }, - render: args => + args: { primary: true }, + render: (args) => , }); `); }); @@ -119,17 +112,14 @@ describe('csf-3-to-4', () => { `; 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 { config } from '#.storybook/preview'; + import { ComponentProps } from './Component'; - const meta = config.meta({ - title: 'Component', - component: Component - }); + + const meta = config.meta({ title: 'Component', component: Component }); + export const A = meta.story({ - args: { - primary: true - } + args: { primary: true }, }); `); }); @@ -147,17 +137,14 @@ describe('csf-3-to-4', () => { `; 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 { config } from '#.storybook/preview'; + import { ComponentProps } from './Component'; - const meta = config.meta({ - title: 'Component', - component: Component - }); + + const meta = config.meta({ title: 'Component', component: Component }); + export const A = meta.story({ - args: { - primary: true - } + args: { primary: true }, }); `); }); @@ -175,17 +162,14 @@ describe('csf-3-to-4', () => { `; 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 { config } from '#.storybook/preview'; + import { ComponentProps } from './Component'; - const meta = config.meta({ - title: 'Component', - component: Component - }); + + const meta = config.meta({ title: 'Component', component: Component }); + export const A = meta.story({ - args: { - primary: true - } + args: { primary: true }, }); `); }); @@ -203,17 +187,14 @@ describe('csf-3-to-4', () => { `; 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 { config } from '#.storybook/preview'; + import { ComponentProps } from './Component'; - const meta = config.meta({ - title: 'Component', - component: Component - }); + + const meta = config.meta({ title: 'Component', component: Component }); + export const A = meta.story({ - args: { - primary: true - } + args: { primary: true }, }); `); }); diff --git a/code/lib/codemod/src/transforms/csf-3-to-4.ts b/code/lib/codemod/src/transforms/csf-3-to-4.ts index 7efe6a430c06..9d8856f25150 100644 --- a/code/lib/codemod/src/transforms/csf-3-to-4.ts +++ b/code/lib/codemod/src/transforms/csf-3-to-4.ts @@ -1,27 +1,24 @@ /* eslint-disable no-underscore-dangle */ -import { isValidPreviewPath, loadCsf } from '@storybook/core/csf-tools'; +import { types as t, traverse } from '@storybook/core/babel'; + +import { isValidPreviewPath, loadCsf, printCsf } 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'; +import prettier from 'prettier'; + +const logger = console; 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 } - ); + + try { + csf.parse(); + } catch (err) { + logger.log(`Error ${err}, skipping`); + return info.source; + } const metaVariableName = 'meta'; @@ -34,10 +31,10 @@ export default async function transform(info: FileInfo) { let foundConfigImport = false; programNode.body.forEach((node) => { - if (isImportDeclaration(node) && isValidPreviewPath(node.source.value)) { + if (t.isImportDeclaration(node) && isValidPreviewPath(node.source.value)) { const hasConfigSpecifier = node.specifiers.some( (specifier) => - isImportSpecifier(specifier) && isIdentifier(specifier.imported, { name: 'config' }) + t.isImportSpecifier(specifier) && t.isIdentifier(specifier.imported, { name: 'config' }) ); if (!hasConfigSpecifier) { @@ -53,110 +50,93 @@ export default async function transform(info: FileInfo) { } }); - let hasMeta = false; + const hasMeta = !!csf._meta; - file.path.traverse({ - // Meta export - ExportDefaultDeclaration: (path) => { - hasMeta = true; - const declaration = path.node.declaration; + Object.entries(csf._storyExports).forEach(([key, decl]) => { + const id = decl.id; + const declarator = decl as babel.types.VariableDeclarator; + let init = t.isVariableDeclarator(declarator) ? declarator.init : undefined; - /** - * 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] - ); - } + if (t.isIdentifier(id) && init) { + if (t.isTSSatisfiesExpression(init) || t.isTSAsExpression(init)) { + init = init.expression; + } - // Update all references to the original name - path.scope.rename(originalName, metaVariableName); + if (t.isObjectExpression(init)) { + const typeAnnotation = id.typeAnnotation; + // Remove type annotation as it's now inferred + if (typeAnnotation) { + id.typeAnnotation = null; } - // 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; + // Wrap the object in `meta.story()` + declarator.init = babel.types.callExpression( + babel.types.memberExpression( + babel.types.identifier(metaVariableName), + babel.types.identifier('story') + ), + [init] + ); } + } + }); - declaration.declarations.forEach((decl) => { - const id = decl.id; - let init = decl.init; + // modify meta + if (csf._metaPath) { + const declaration = csf._metaPath.node.declaration; + if (t.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] + ) + ), + ]); + 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; - if (isIdentifier(id) && init) { - if (isTSSatisfiesExpression(init) || isTSAsExpression(init)) { - init = init.expression; - } + // Always rename the meta variable to 'meta' + binding.path.node.id = babel.types.identifier(metaVariableName); - 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] - ); - } + let init = binding.path.node.init; + if (t.isTSSatisfiesExpression(init) || t.isTSAsExpression(init)) { + init = init.expression; } - }); - }, - }); + if (t.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 + csf._metaPath.scope.rename(originalName, metaVariableName); + } + + // Remove the default export, it's not needed anymore + csf._metaPath.remove(); + } + } if (hasMeta && !foundConfigImport) { const configImport = babel.types.importDeclaration( @@ -171,9 +151,71 @@ export default async function transform(info: FileInfo) { programNode.body.unshift(configImport); } - // Generate the transformed code - const { code } = babel.transformFromAstSync(fileNode, info.source, { - parserOpts: { sourceType: 'module' }, + function isSpecifierUsed(name: string) { + let isUsed = false; + + // Traverse the AST and check for usage of the name + traverse(programNode, { + Identifier(path) { + if (path.node.name === name) { + isUsed = true; + // Stop traversal early if we've found a match + path.stop(); + } + }, + }); + + return isUsed; + } + + // Remove type imports – now inferred – from @storybook/* packages + const disallowlist = [ + 'Story', + 'StoryFn', + 'StoryObj', + 'Meta', + 'MetaObj', + 'ComponentStory', + 'ComponentMeta', + ]; + + programNode.body = 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; }); - return code; + + let output = printCsf(csf).code; + + try { + output = await prettier.format(output, { + ...(await prettier.resolveConfig(info.path)), + filepath: info.path, + }); + } catch (e) { + logger.log(`Failed applying prettier to ${info.path}.`); + } + return output; }