Skip to content

Commit 41c83af

Browse files
authored
Merge pull request #32414 from storybookjs/yann/fix-csf-factories-codemods
CSF: Enhance csf-factories codemods
2 parents c2aedef + 54ecd3a commit 41c83af

File tree

4 files changed

+176
-11
lines changed

4 files changed

+176
-11
lines changed

code/lib/cli-storybook/src/codemod/helpers/config-to-csf-factory.test.ts

Lines changed: 20 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -73,15 +73,13 @@ describe('main/preview codemod: general parsing functionality', () => {
7373
).resolves.toMatchInlineSnapshot(`
7474
import { defineMain } from '@storybook/react-vite/node';
7575
76-
const config = {
77-
framework: '@storybook/react-vite',
76+
export default defineMain({
7877
tags: [],
7978
viteFinal: () => {
8079
return config;
8180
},
82-
};
83-
84-
export default config;
81+
framework: '@storybook/react-vite',
82+
});
8583
`);
8684
});
8785
it('should wrap defineMain call from named exports format', async () => {
@@ -244,4 +242,21 @@ describe('preview specific functionality', () => {
244242
});
245243
`);
246244
});
245+
it('should work', async () => {
246+
await expect(
247+
transform(dedent`
248+
export const decorators = [1]
249+
export default {
250+
parameters: {},
251+
}
252+
`)
253+
).resolves.toMatchInlineSnapshot(`
254+
import { definePreview } from '@storybook/react-vite';
255+
256+
export default definePreview({
257+
decorators: [1],
258+
parameters: {},
259+
});
260+
`);
261+
});
247262
});

code/lib/cli-storybook/src/codemod/helpers/config-to-csf-factory.ts

Lines changed: 47 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,8 +45,54 @@ export async function configToCsfFactory(
4545
* Transform into: `export default defineMain({ tags: [], parameters: {} })`
4646
*/
4747
if (config._exportsObject && hasNamedExports) {
48-
config._exportsObject.properties.push(...defineConfigProps);
48+
// when merging named exports with default exports, add the named exports first in the list
49+
config._exportsObject.properties = [...defineConfigProps, ...config._exportsObject.properties];
4950
programNode.body = removeExportDeclarations(programNode, exportDecls);
51+
52+
// After merging, ensure the default export is wrapped with defineMain/definePreview
53+
const defineConfigCall = t.callExpression(t.identifier(methodName), [config._exportsObject]);
54+
55+
let exportDefaultNode = null as unknown as t.ExportDefaultDeclaration;
56+
let declarationNodeIndex = -1;
57+
58+
programNode.body.forEach((node) => {
59+
// Detect Syntax 1: export default <identifier>
60+
if (t.isExportDefaultDeclaration(node) && t.isIdentifier(node.declaration)) {
61+
const declarationName = node.declaration.name;
62+
63+
declarationNodeIndex = programNode.body.findIndex(
64+
(n) =>
65+
t.isVariableDeclaration(n) &&
66+
n.declarations.some(
67+
(d) =>
68+
t.isIdentifier(d.id) &&
69+
d.id.name === declarationName &&
70+
t.isObjectExpression(d.init)
71+
)
72+
);
73+
74+
if (declarationNodeIndex !== -1) {
75+
exportDefaultNode = node;
76+
// remove the original declaration as it will become a default export
77+
const declarationNode = programNode.body[declarationNodeIndex];
78+
if (t.isVariableDeclaration(declarationNode)) {
79+
const id = declarationNode.declarations[0].id;
80+
const variableName = t.isIdentifier(id) && id.name;
81+
82+
if (variableName) {
83+
programNode.body.splice(declarationNodeIndex, 1);
84+
}
85+
}
86+
}
87+
} else if (t.isExportDefaultDeclaration(node) && t.isObjectExpression(node.declaration)) {
88+
// Detect Syntax 2: export default { ... }
89+
exportDefaultNode = node;
90+
}
91+
});
92+
93+
if (exportDefaultNode !== null) {
94+
exportDefaultNode.declaration = defineConfigCall;
95+
}
5096
} else if (config._exportsObject) {
5197
/**
5298
* Scenario 2: Default exports

code/lib/cli-storybook/src/codemod/helpers/story-to-csf-factory.test.ts

Lines changed: 80 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -303,16 +303,39 @@ describe('stories codemod', () => {
303303
};
304304
const data = {};
305305
export const A = () => {};
306-
// not supported yet (story as function)
307306
export function B() { };
308307
// not supported yet (story redeclared)
309308
const C = { ...A, args: data, };
310-
export { C };
309+
const D = { args: data };
310+
export { C, D as E };
311311
`);
312312

313+
expect(transformed).toMatchInlineSnapshot(`
314+
import preview from '#.storybook/preview';
315+
316+
import { A as Component } from './Button';
317+
import * as Stories from './Other.stories';
318+
import someData from './fixtures';
319+
320+
const meta = preview.meta({
321+
component: Component,
322+
323+
// not supported yet (story coming from another file)
324+
args: Stories.A.args,
325+
});
326+
327+
const data = {};
328+
export const A = meta.story(() => {});
329+
export const B = meta.story(() => {});
330+
// not supported yet (story redeclared)
331+
const C = { ...A.input, args: data };
332+
const D = { args: data };
333+
export { C, D as E };
334+
`);
335+
313336
expect(transformed).toContain('A = meta.story');
314-
// @TODO: when we support these, uncomment these lines
315-
// expect(transformed).toContain('B = meta.story');
337+
expect(transformed).toContain('B = meta.story');
338+
// @TODO: when we support these, uncomment this line
316339
// expect(transformed).toContain('C = meta.story');
317340
});
318341

@@ -589,5 +612,58 @@ describe('stories codemod', () => {
589612
export const A = meta.story();
590613
`);
591614
});
615+
616+
it('should support non-conventional formats', async () => {
617+
const transformed = await transform(dedent`
618+
import { Meta, StoryObj as CSF3 } from '@storybook/react';
619+
import { ComponentProps } from './Component';
620+
import { A as Component } from './Button';
621+
import * as Stories from './Other.stories';
622+
import someData from './fixtures'
623+
export default {
624+
title: 'Component',
625+
component: Component,
626+
// not supported yet (story coming from another file)
627+
args: Stories.A.args
628+
};
629+
const data = {};
630+
export const A: StoryObj = () => {};
631+
export function B() { };
632+
// not supported yet (story redeclared)
633+
const C = { ...A, args: data, } satisfies CSF3<ComponentProps>;
634+
const D = { args: data };
635+
export { C, D as E };
636+
`);
637+
638+
expect(transformed).toMatchInlineSnapshot(`
639+
import preview from '#.storybook/preview';
640+
641+
import { A as Component } from './Button';
642+
import { ComponentProps } from './Component';
643+
import * as Stories from './Other.stories';
644+
import someData from './fixtures';
645+
646+
const meta = preview.meta({
647+
title: 'Component',
648+
component: Component,
649+
650+
// not supported yet (story coming from another file)
651+
args: Stories.A.args,
652+
});
653+
654+
const data = {};
655+
export const A = meta.story(() => {});
656+
export const B = meta.story(() => {});
657+
// not supported yet (story redeclared)
658+
const C = { ...A.input, args: data } satisfies CSF3<ComponentProps>;
659+
const D = { args: data };
660+
export { C, D as E };
661+
`);
662+
663+
expect(transformed).toContain('A = meta.story');
664+
expect(transformed).toContain('B = meta.story');
665+
// @TODO: when we support these, uncomment this line
666+
// expect(transformed).toContain('C = meta.story');
667+
});
592668
});
593669
});

code/lib/cli-storybook/src/codemod/helpers/story-to-csf-factory.ts

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -95,7 +95,7 @@ export async function storyToCsfFactory(
9595
// @TODO: Support unconventional formats:
9696
// `export function Story() { };` and `export { Story };
9797
// These are not part of csf._storyExports but rather csf._storyStatements and are tricky to support.
98-
Object.entries(csf._storyExports).forEach(([_key, decl]) => {
98+
Object.entries(csf._storyExports).forEach(([, decl]) => {
9999
const id = decl.id;
100100
const declarator = decl as t.VariableDeclarator;
101101
let init = t.isVariableDeclarator(declarator) ? declarator.init : undefined;
@@ -128,6 +128,34 @@ export async function storyToCsfFactory(
128128
}
129129
});
130130

131+
// Support function-declared stories
132+
Object.entries(csf._storyExports).forEach(([exportName, decl]) => {
133+
if (t.isFunctionDeclaration(decl) && decl.id) {
134+
const arrowFn = t.arrowFunctionExpression(decl.params, decl.body);
135+
arrowFn.async = !!decl.async;
136+
137+
const wrappedCall = t.callExpression(
138+
t.memberExpression(t.identifier(metaVariableName), t.identifier('story')),
139+
[arrowFn]
140+
);
141+
142+
const replacement = t.exportNamedDeclaration(
143+
t.variableDeclaration('const', [
144+
t.variableDeclarator(t.identifier(exportName), wrappedCall),
145+
])
146+
);
147+
148+
const pathForExport = (
149+
csf as unknown as {
150+
_storyPaths?: Record<string, { replaceWith?: (node: t.Node) => void }>;
151+
}
152+
)._storyPaths?.[exportName];
153+
if (pathForExport && pathForExport.replaceWith) {
154+
pathForExport.replaceWith(replacement);
155+
}
156+
}
157+
});
158+
131159
const storyExportDecls = new Map(
132160
Object.entries(csf._storyExports).filter(
133161
(

0 commit comments

Comments
 (0)