From a13c4c65204d9a6cb27dca072e12e0eef1357d9e Mon Sep 17 00:00:00 2001 From: Martin Hochel Date: Wed, 15 Jul 2026 21:26:05 +0200 Subject: [PATCH 01/14] feat(babel-preset-storybook-full-source): add opt-in per-story fullSource slicing Adds a 'storyGranularity: story' option that emits a per-story sliced fullSource for files with multiple story exports (standard CSF), instead of attaching the whole file to the last export. - New sliceStory.ts: reachability-based slicer keeping only the imports/helpers a story references; normalizes CSF3 object stories (render, render-with-args, args-only synth via meta.component) and spread stories into renderable functions; omits play/parameters from the sample only. - fullsource.ts collects all story exports and branches by granularity; 'file' mode unchanged. - Guards args-only synth to genuine story-shaped objects so capitalized data exports are ignored. - Fixtures + tests for CSF2 multi-story, CSF3 render/args/spread, and data-export cases. --- ...preset-storybook-full-source-perstory.json | 7 + .../README.md | 1 + .../babel-preset-storybook-full-source.api.md | 1 + .../csf3-data-export/code.js | 20 + .../csf3-data-export/output.js | 21 + .../csf3-render-and-args/code.js | 39 ++ .../csf3-render-and-args/output.js | 43 ++ .../csf3-spread/code.js | 24 + .../csf3-spread/output.js | 33 + .../multi-story-csf2/code.js | 12 + .../multi-story-csf2/output.js | 36 + .../src/fullsource.test.ts | 17 + .../src/fullsource.ts | 99 ++- .../src/sliceStory.ts | 649 ++++++++++++++++++ .../src/types.ts | 16 + 15 files changed, 1000 insertions(+), 18 deletions(-) create mode 100644 change/@fluentui-babel-preset-storybook-full-source-perstory.json create mode 100644 packages/react-components/babel-preset-storybook-full-source/src/__fixtures__/storybook-stories-fullsource-per-story/csf3-data-export/code.js create mode 100644 packages/react-components/babel-preset-storybook-full-source/src/__fixtures__/storybook-stories-fullsource-per-story/csf3-data-export/output.js create mode 100644 packages/react-components/babel-preset-storybook-full-source/src/__fixtures__/storybook-stories-fullsource-per-story/csf3-render-and-args/code.js create mode 100644 packages/react-components/babel-preset-storybook-full-source/src/__fixtures__/storybook-stories-fullsource-per-story/csf3-render-and-args/output.js create mode 100644 packages/react-components/babel-preset-storybook-full-source/src/__fixtures__/storybook-stories-fullsource-per-story/csf3-spread/code.js create mode 100644 packages/react-components/babel-preset-storybook-full-source/src/__fixtures__/storybook-stories-fullsource-per-story/csf3-spread/output.js create mode 100644 packages/react-components/babel-preset-storybook-full-source/src/__fixtures__/storybook-stories-fullsource-per-story/multi-story-csf2/code.js create mode 100644 packages/react-components/babel-preset-storybook-full-source/src/__fixtures__/storybook-stories-fullsource-per-story/multi-story-csf2/output.js create mode 100644 packages/react-components/babel-preset-storybook-full-source/src/sliceStory.ts diff --git a/change/@fluentui-babel-preset-storybook-full-source-perstory.json b/change/@fluentui-babel-preset-storybook-full-source-perstory.json new file mode 100644 index 00000000000000..932cbe90f121d6 --- /dev/null +++ b/change/@fluentui-babel-preset-storybook-full-source-perstory.json @@ -0,0 +1,7 @@ +{ + "type": "patch", + "comment": "feat: add opt-in `storyGranularity: 'story'` mode that emits a per-story sliced `fullSource` for files with multiple story exports", + "packageName": "@fluentui/babel-preset-storybook-full-source", + "email": "martinhochel@microsoft.com", + "dependentChangeType": "patch" +} diff --git a/packages/react-components/babel-preset-storybook-full-source/README.md b/packages/react-components/babel-preset-storybook-full-source/README.md index 279ba6b29dc16c..389e0a1945ca14 100644 --- a/packages/react-components/babel-preset-storybook-full-source/README.md +++ b/packages/react-components/babel-preset-storybook-full-source/README.md @@ -19,6 +19,7 @@ To use this Babel preset, add it to your Babel configuration: - **Removes Storybook specific assignments**: Avoids issues with undefined stories and unnecessary clutter. - **Collects and modifies import declarations**: Ensures valid single-file code examples. - **Adds the `context.parameters.fullSource` property**: post-processed, single-file source for the "Open in Sandbox" flow. +- **Per-story source** (opt-in via `storyGranularity: 'story'`): when a story file contains multiple story exports (the standard Component Story Format convention), each story gets its **own** sliced `fullSource` containing only the imports and helper declarations it references, plus that single story converted to a renderable function. CSF3 object stories (`{ render }`, or `args`-only stories rendered via the meta `component`) and spread stories (`{ ...Base }`) are supported. Capitalized non-story exports (e.g. shared data objects) are ignored — only genuine story-shaped exports are emitted. `play`/`parameters` are omitted from the generated sample (source files are never modified). Defaults to `'file'`, which attaches the whole file to the last story export (suited to the one-story-per-file convention). - **CSS module support** (opt-in via `cssModules` option): when enabled, reads `*.module.css` files from disk and injects `context.parameters.cssModuleSources` with `{ cssModules, tokensSource }` entries for the sandbox addon and docs panel. Set `cssModules: true` to enable, or `cssModules: { tokensFilePath: '...' }` to also inject a tokens CSS file as `tokensSource`. ## Note diff --git a/packages/react-components/babel-preset-storybook-full-source/etc/babel-preset-storybook-full-source.api.md b/packages/react-components/babel-preset-storybook-full-source/etc/babel-preset-storybook-full-source.api.md index 9a5d9239071e6c..a4778b72e27b40 100644 --- a/packages/react-components/babel-preset-storybook-full-source/etc/babel-preset-storybook-full-source.api.md +++ b/packages/react-components/babel-preset-storybook-full-source/etc/babel-preset-storybook-full-source.api.md @@ -10,6 +10,7 @@ import * as Babel from '@babel/core'; export interface BabelPluginOptions { cssModules?: boolean | CssModulesConfig; importMappings: Record; + storyGranularity?: 'file' | 'story'; } // @public diff --git a/packages/react-components/babel-preset-storybook-full-source/src/__fixtures__/storybook-stories-fullsource-per-story/csf3-data-export/code.js b/packages/react-components/babel-preset-storybook-full-source/src/__fixtures__/storybook-stories-fullsource-per-story/csf3-data-export/code.js new file mode 100644 index 00000000000000..4644963ef98690 --- /dev/null +++ b/packages/react-components/babel-preset-storybook-full-source/src/__fixtures__/storybook-stories-fullsource-per-story/csf3-data-export/code.js @@ -0,0 +1,20 @@ +import * as React from 'react'; +import { Button } from '@fluentui/react-button'; + +const meta = { + title: 'Button', + component: Button, +}; + +export default meta; + +// Genuine story — should get a sliced fullSource. +export const Default = { + render: () => , +}; + +// Capitalized data export (not a story) — must NOT be turned into a fake story. +export const BrandColors = { + primary: '#0f6cbd', + danger: '#c50f1f', +}; diff --git a/packages/react-components/babel-preset-storybook-full-source/src/__fixtures__/storybook-stories-fullsource-per-story/csf3-data-export/output.js b/packages/react-components/babel-preset-storybook-full-source/src/__fixtures__/storybook-stories-fullsource-per-story/csf3-data-export/output.js new file mode 100644 index 00000000000000..38d80436198c97 --- /dev/null +++ b/packages/react-components/babel-preset-storybook-full-source/src/__fixtures__/storybook-stories-fullsource-per-story/csf3-data-export/output.js @@ -0,0 +1,21 @@ +import * as React from 'react'; +import { Button } from '@fluentui/react-button'; +const meta = { + title: 'Button', + component: Button, +}; +export default meta; + +// Genuine story — should get a sliced fullSource. +export const Default = { + render: () => /*#__PURE__*/ React.createElement(Button, null, 'Default'), +}; + +// Capitalized data export (not a story) — must NOT be turned into a fake story. +export const BrandColors = { + primary: '#0f6cbd', + danger: '#c50f1f', +}; +Default.parameters = {}; +Default.parameters.fullSource = + 'import { Button } from "@fluentui/react-components";\nimport * as React from "react";\nexport const Default = () => ;\n'; diff --git a/packages/react-components/babel-preset-storybook-full-source/src/__fixtures__/storybook-stories-fullsource-per-story/csf3-render-and-args/code.js b/packages/react-components/babel-preset-storybook-full-source/src/__fixtures__/storybook-stories-fullsource-per-story/csf3-render-and-args/code.js new file mode 100644 index 00000000000000..180dfec1d23ee1 --- /dev/null +++ b/packages/react-components/babel-preset-storybook-full-source/src/__fixtures__/storybook-stories-fullsource-per-story/csf3-render-and-args/code.js @@ -0,0 +1,39 @@ +import * as React from 'react'; +import { Button } from '@fluentui/react-button'; + +const meta = { + title: 'Button', + component: Button, + args: { + appearance: 'primary', + }, +}; + +export default meta; + +export const Default = { + render: () => , +}; + +export const WithArgs = { + args: { + appearance: 'outline', + }, + render: args => , +}; + +export const ArgsOnly = { + args: { + children: 'Args only', + }, + play: async () => { + /* interaction test - must not leak into fullSource */ + }, + parameters: { + docs: { + description: { + story: 'Rendered purely from args.', + }, + }, + }, +}; diff --git a/packages/react-components/babel-preset-storybook-full-source/src/__fixtures__/storybook-stories-fullsource-per-story/csf3-render-and-args/output.js b/packages/react-components/babel-preset-storybook-full-source/src/__fixtures__/storybook-stories-fullsource-per-story/csf3-render-and-args/output.js new file mode 100644 index 00000000000000..4a37582601e4f6 --- /dev/null +++ b/packages/react-components/babel-preset-storybook-full-source/src/__fixtures__/storybook-stories-fullsource-per-story/csf3-render-and-args/output.js @@ -0,0 +1,43 @@ +import * as React from 'react'; +import { Button } from '@fluentui/react-button'; +const meta = { + title: 'Button', + component: Button, + args: { + appearance: 'primary', + }, +}; +export default meta; +export const Default = { + render: () => /*#__PURE__*/ React.createElement(Button, null, 'Default'), +}; +export const WithArgs = { + args: { + appearance: 'outline', + }, + render: args => /*#__PURE__*/ React.createElement(Button, args, 'With args'), +}; +export const ArgsOnly = { + args: { + children: 'Args only', + }, + play: async () => { + /* interaction test - must not leak into fullSource */ + }, + parameters: { + docs: { + description: { + story: 'Rendered purely from args.', + }, + }, + }, +}; +Default.parameters = {}; +Default.parameters.fullSource = + 'import { Button } from "@fluentui/react-components";\nimport * as React from "react";\nexport const Default = () => ;\n'; +WithArgs.parameters = {}; +WithArgs.parameters.fullSource = + 'import { Button } from "@fluentui/react-components";\nimport * as React from "react";\nexport const WithArgs = () => {\n const args = {\n appearance: "outline",\n };\n return ;\n};\n'; +ArgsOnly.parameters = {}; +ArgsOnly.parameters.fullSource = + 'import { Button } from "@fluentui/react-components";\nimport * as React from "react";\nexport const ArgsOnly = () => {\n const args = {\n appearance: "primary",\n children: "Args only",\n };\n return , +}; + +export const Derived = { + ...Base, + parameters: { + docs: { + description: { + story: 'Spreads Base.', + }, + }, + }, +}; diff --git a/packages/react-components/babel-preset-storybook-full-source/src/__fixtures__/storybook-stories-fullsource-per-story/csf3-spread/output.js b/packages/react-components/babel-preset-storybook-full-source/src/__fixtures__/storybook-stories-fullsource-per-story/csf3-spread/output.js new file mode 100644 index 00000000000000..0952f8d44e023c --- /dev/null +++ b/packages/react-components/babel-preset-storybook-full-source/src/__fixtures__/storybook-stories-fullsource-per-story/csf3-spread/output.js @@ -0,0 +1,33 @@ +import * as React from 'react'; +import { Button } from '@fluentui/react-button'; +const meta = { + title: 'Button', + component: Button, +}; +export default meta; +export const Base = { + render: () => + /*#__PURE__*/ React.createElement( + Button, + { + appearance: 'primary', + }, + 'Base', + ), +}; +export const Derived = { + ...Base, + parameters: { + docs: { + description: { + story: 'Spreads Base.', + }, + }, + }, +}; +Base.parameters = {}; +Base.parameters.fullSource = + 'import { Button } from "@fluentui/react-components";\nimport * as React from "react";\nexport const Base = () => ;\n'; +Derived.parameters = {}; +Derived.parameters.fullSource = + 'import { Button } from "@fluentui/react-components";\nimport * as React from "react";\nexport const Derived = () => ;\n'; diff --git a/packages/react-components/babel-preset-storybook-full-source/src/__fixtures__/storybook-stories-fullsource-per-story/multi-story-csf2/code.js b/packages/react-components/babel-preset-storybook-full-source/src/__fixtures__/storybook-stories-fullsource-per-story/multi-story-csf2/code.js new file mode 100644 index 00000000000000..276358c6ed769b --- /dev/null +++ b/packages/react-components/babel-preset-storybook-full-source/src/__fixtures__/storybook-stories-fullsource-per-story/multi-story-csf2/code.js @@ -0,0 +1,12 @@ +import * as React from 'react'; +import { Button } from '@fluentui/react-button'; + +const Wrapper = ({ children }) =>
{children}
; + +export const Primary = () => ( + + + +); + +export const Secondary = () => ; diff --git a/packages/react-components/babel-preset-storybook-full-source/src/__fixtures__/storybook-stories-fullsource-per-story/multi-story-csf2/output.js b/packages/react-components/babel-preset-storybook-full-source/src/__fixtures__/storybook-stories-fullsource-per-story/multi-story-csf2/output.js new file mode 100644 index 00000000000000..e3eacaf0858496 --- /dev/null +++ b/packages/react-components/babel-preset-storybook-full-source/src/__fixtures__/storybook-stories-fullsource-per-story/multi-story-csf2/output.js @@ -0,0 +1,36 @@ +import * as React from 'react'; +import { Button } from '@fluentui/react-button'; +const Wrapper = ({ children }) => + /*#__PURE__*/ React.createElement( + 'div', + { + className: 'wrapper', + }, + children, + ); +export const Primary = () => + /*#__PURE__*/ React.createElement( + Wrapper, + null, + /*#__PURE__*/ React.createElement( + Button, + { + appearance: 'primary', + }, + 'Primary', + ), + ); +export const Secondary = () => + /*#__PURE__*/ React.createElement( + Button, + { + appearance: 'secondary', + }, + 'Secondary', + ); +Primary.parameters = {}; +Primary.parameters.fullSource = + 'import { Button } from "@fluentui/react-components";\nimport * as React from "react";\nconst Wrapper = ({ children }) =>
{children}
;\nexport const Primary = () => (\n \n \n \n);\n'; +Secondary.parameters = {}; +Secondary.parameters.fullSource = + 'import { Button } from "@fluentui/react-components";\nimport * as React from "react";\nexport const Secondary = () => (\n \n);\n'; diff --git a/packages/react-components/babel-preset-storybook-full-source/src/fullsource.test.ts b/packages/react-components/babel-preset-storybook-full-source/src/fullsource.test.ts index 43af7003784c60..906d5ddec7e72a 100644 --- a/packages/react-components/babel-preset-storybook-full-source/src/fullsource.test.ts +++ b/packages/react-components/babel-preset-storybook-full-source/src/fullsource.test.ts @@ -41,3 +41,20 @@ pluginTester({ pluginName: PLUGIN_NAME, plugin, }); + +// Per-story granularity: each story export gets its own sliced `fullSource`. +pluginTester({ + babelOptions: { + presets: ['@babel/preset-react'], + }, + fixtures: path.join(__dirname, '__fixtures__/storybook-stories-fullsource-per-story'), + pluginOptions: { + importMappings: { + '@fluentui/react-button': defaultDependencyReplace, + '@fluentui/react-menu': defaultDependencyReplace, + }, + storyGranularity: 'story', + }, + pluginName: PLUGIN_NAME, + plugin, +}); diff --git a/packages/react-components/babel-preset-storybook-full-source/src/fullsource.ts b/packages/react-components/babel-preset-storybook-full-source/src/fullsource.ts index b9e3046fac49fa..1f3c94cdbce88e 100644 --- a/packages/react-components/babel-preset-storybook-full-source/src/fullsource.ts +++ b/packages/react-components/babel-preset-storybook-full-source/src/fullsource.ts @@ -5,6 +5,7 @@ import * as nodePath from 'path'; import { modifyImportsPlugin } from './modifyImports'; import { removeStorybookParameters } from './removeStorybookParameters'; +import { sliceStorySource } from './sliceStory'; import { BabelPluginOptions } from './types'; export const PLUGIN_NAME = 'storybook-stories-fullsource'; @@ -26,26 +27,31 @@ export function fullSourcePlugin(babel: typeof Babel, options: BabelPluginOption const { types: t } = babel; const cssModulesConfig = typeof options.cssModules === 'object' ? options.cssModules : undefined; const cssModulesEnabled = Boolean(options.cssModules); + const granularity = options.storyGranularity ?? 'file'; let storyName: string; let parametersAssignment: Babel.NodePath | undefined; + // All component-like story exports in document order (used by 'story' mode). + let storyNames: string[] = []; + // Story names that already have a `.parameters = …` assignment. + const storiesWithParameters = new Set(); - const createStoryParametersAssignmentExpression = () => { + const createStoryParametersAssignmentExpression = (targetStoryName: string) => { const storyParameters = t.assignmentExpression( '=', - t.memberExpression(t.identifier(storyName), t.identifier('parameters')), + t.memberExpression(t.identifier(targetStoryName), t.identifier('parameters')), t.objectExpression([]), ); return t.expressionStatement(storyParameters); }; - const createFullSourceAssignmentExpression = (fullSource: string) => { + const createFullSourceAssignmentExpression = (targetStoryName: string, fullSource: string) => { return t.expressionStatement( t.assignmentExpression( '=', t.memberExpression( - t.memberExpression(t.identifier(storyName), t.identifier('parameters')), + t.memberExpression(t.identifier(targetStoryName), t.identifier('parameters')), t.identifier('fullSource'), ), t.stringLiteral(fullSource), @@ -62,12 +68,15 @@ export function fullSourcePlugin(babel: typeof Babel, options: BabelPluginOption * tokensSource: '…', * }); */ - const createCssModuleSourcesAssignment = (data: { - cssModules?: Array<{ name: string; source: string }>; - tokensSource?: string; - }) => { + const createCssModuleSourcesAssignment = ( + targetStoryName: string, + data: { + cssModules?: Array<{ name: string; source: string }>; + tokensSource?: string; + }, + ) => { const storyParametersCssModuleSources = t.memberExpression( - t.memberExpression(t.identifier(storyName), t.identifier('parameters')), + t.memberExpression(t.identifier(targetStoryName), t.identifier('parameters')), t.identifier('cssModuleSources'), ); @@ -112,6 +121,7 @@ export function fullSourcePlugin(babel: typeof Babel, options: BabelPluginOption isComponentLikeName(declaration.id.name) ) { storyName = declaration.id.name; + storyNames.push(declaration.id.name); return; } @@ -123,6 +133,7 @@ export function fullSourcePlugin(babel: typeof Babel, options: BabelPluginOption isComponentLikeName(declaration.declarations[0].id.name) ) { storyName = declaration.declarations[0].id.name; + storyNames.push(declaration.declarations[0].id.name); return; } }, @@ -131,24 +142,79 @@ export function fullSourcePlugin(babel: typeof Babel, options: BabelPluginOption if ( t.isMemberExpression(path.node.left) && t.isIdentifier(path.node.left.object) && - path.node.left.object.name === storyName && t.isIdentifier(path.node.left.property) && path.node.left.property.name === 'parameters' ) { - parametersAssignment = path; + storiesWithParameters.add(path.node.left.object.name); + if (path.node.left.object.name === storyName) { + parametersAssignment = path; + } } }, Program: { enter() { storyName = ''; parametersAssignment = undefined; + storyNames = []; + storiesWithParameters.clear(); }, exit(path, state) { - if (!storyName || !state.filename) { + if (!state.filename || storyNames.length === 0) { return; } const fileContents = fs.readFileSync(state.filename, 'utf-8'); + const tokensSource = + cssModulesEnabled && cssModulesConfig?.tokensFilePath + ? fs.readFileSync(cssModulesConfig.tokensFilePath, 'utf-8') + : undefined; + + if (granularity === 'story') { + for (const currentStory of storyNames) { + const sliced = sliceStorySource(babel, fileContents, { + targetStory: currentStory, + filename: state.filename, + }); + + if (!sliced) { + continue; + } + + const transformedStory = babel.transformSync(sliced, { + ...state.file.opts, + compact: false, + comments: false, + plugins: [[modifyImportsPlugin, options], removeStorybookParameters], + })?.code; + + const storyCode = prettier.format(transformedStory ?? '', { parser: 'babel-ts' }); + + if (!storiesWithParameters.has(currentStory)) { + path.pushContainer('body', createStoryParametersAssignmentExpression(currentStory)); + storiesWithParameters.add(currentStory); + } + + path.pushContainer('body', createFullSourceAssignmentExpression(currentStory, storyCode)); + + if (cssModulesEnabled) { + const cssModules = collectCssModuleImports(path, t, state.filename); + if (cssModules.length > 0 || tokensSource) { + path.pushContainer( + 'body', + createCssModuleSourcesAssignment(currentStory, { cssModules, tokensSource }), + ); + } + } + } + + return; + } + + // 'file' granularity (default, legacy): the whole file on the last story. + if (!storyName) { + return; + } + const transformedCode = babel.transformSync(fileContents, { ...state.file.opts, compact: false, @@ -160,21 +226,18 @@ export function fullSourcePlugin(babel: typeof Babel, options: BabelPluginOption const code = prettier.format(transformedCode ?? '', { parser: 'babel-ts' }); if (!parametersAssignment) { - path.pushContainer('body', createStoryParametersAssignmentExpression()); + path.pushContainer('body', createStoryParametersAssignmentExpression(storyName)); } - path.pushContainer('body', createFullSourceAssignmentExpression(code)); + path.pushContainer('body', createFullSourceAssignmentExpression(storyName, code)); // Auto-detect CSS module imports and inject their source as parameters. // This removes the need for manual `?raw` imports + `withCssModuleSource()` calls. if (cssModulesEnabled) { const cssModules = collectCssModuleImports(path, t, state.filename); - const tokensSource = cssModulesConfig?.tokensFilePath - ? fs.readFileSync(cssModulesConfig.tokensFilePath, 'utf-8') - : undefined; if (cssModules.length > 0 || tokensSource) { - path.pushContainer('body', createCssModuleSourcesAssignment({ cssModules, tokensSource })); + path.pushContainer('body', createCssModuleSourcesAssignment(storyName, { cssModules, tokensSource })); } } }, diff --git a/packages/react-components/babel-preset-storybook-full-source/src/sliceStory.ts b/packages/react-components/babel-preset-storybook-full-source/src/sliceStory.ts new file mode 100644 index 00000000000000..4107555f2e2057 --- /dev/null +++ b/packages/react-components/babel-preset-storybook-full-source/src/sliceStory.ts @@ -0,0 +1,649 @@ +import * as Babel from '@babel/core'; + +/** + * Options for {@link sliceStorySource}. + */ +export interface SliceStoryOptions { + /** Name of the story export to keep (all other stories are removed). */ + targetStory: string; + /** Absolute path of the story file being processed (used by the parser). */ + filename?: string; +} + +/** + * Produces a minimal, single-story source string for a given story export. + * + * Given a Component Story Format file that contains multiple story exports, this + * keeps only the `targetStory` export and the module-level imports / helper + * declarations it actually references. The story is normalized into a renderable + * function so the output is valid for both the "Show code" panel and the + * "Open in Sandbox" flow (which renders the exported story as a component). + * + * - CSF2 function stories (`export const X = () => <…/>`) are kept as-is. + * - CSF3 object stories (`export const X = { render: … }`) are unwrapped into a + * function. Their `args` (merged with the meta's `args`) are re-declared as a + * local `const args` so the render body keeps working. + * - CSF3 render-less stories (only `args`) are synthesized into + * `() => { const args = {…}; return ; }` using the + * `component` referenced by the default export (meta). + * + * The default export (meta), sibling stories, `play`/`parameters` and any + * declaration not reachable from the target story are dropped. + * + * @returns the sliced source string, or `null` when the story cannot be sliced + * (e.g. render-less story without a resolvable meta `component`). + */ +export function sliceStorySource(babel: typeof Babel, source: string, options: SliceStoryOptions): string | null { + let sliced: string | null = null; + + babel.transformSync(source, { + configFile: false, + babelrc: false, + filename: options.filename, + code: false, + ast: false, + parserOpts: { + plugins: ['jsx', 'typescript', 'classProperties', 'objectRestSpread'], + }, + plugins: [ + createSliceStoryPlugin(babel, options, code => { + sliced = code; + }), + ], + }); + + return sliced; +} + +function createSliceStoryPlugin( + babel: typeof Babel, + options: SliceStoryOptions, + onResult: (code: string) => void, +): Babel.PluginObj { + const { types: t } = babel; + const { targetStory } = options; + + return { + name: 'storybook-stories-slice-story', + visitor: { + // eslint-disable-next-line @typescript-eslint/naming-convention + Program(path) { + const body = path.get('body'); + + // --- Discovery ------------------------------------------------------- + let metaObject: Babel.types.ObjectExpression | undefined; + let metaLocalName: string | undefined; + let targetPath: Babel.NodePath | undefined; + // Map of `const X = {…}` initializers (exported or not) for spread resolution. + const objectConstsByName = new Map(); + + for (const statementPath of body) { + collectObjectConst(t, statementPath, objectConstsByName); + + if (statementPath.isExportDefaultDeclaration()) { + const resolved = resolveMetaObject(t, statementPath.node.declaration, body); + metaObject = resolved.object; + metaLocalName = resolved.localName; + continue; + } + + if (statementPath.isExportNamedDeclaration()) { + const name = getExportedStoryName(t, statementPath.node); + if (name === targetStory) { + targetPath = statementPath; + } + } + } + + if (!targetPath) { + return; + } + + const metaComponentName = metaObject ? getMetaComponentName(t, metaObject) : undefined; + const metaArgs = metaObject ? getObjectProperty(t, metaObject, 'args') : undefined; + + // --- Normalize the target story into a renderable function ---------- + const normalized = normalizeStory(t, targetPath.node, { + metaArgs: metaArgs && t.isObjectExpression(metaArgs) ? metaArgs : undefined, + metaComponentName, + objectConstsByName, + }); + + if (!normalized) { + // Cannot produce a renderable function (e.g. render-less without meta component). + return; + } + + targetPath.replaceWith(normalized); + + // Rebuild bindings so references introduced by normalization (args, + // the meta component) resolve correctly during reachability analysis. + path.scope.crawl(); + + // --- Reachability from the normalized story -------------------------- + const neededBindings = collectReachableBindings(path, targetPath); + + // --- Prune the program body ----------------------------------------- + for (const statementPath of path.get('body')) { + if (statementPath.node === targetPath.node) { + continue; + } + + if (statementPath.isImportDeclaration()) { + pruneImportDeclaration(t, statementPath, neededBindings); + continue; + } + + if (statementPath.isExportDefaultDeclaration()) { + statementPath.remove(); + continue; + } + + // Remove the `const meta = {…}` declaration backing the default export. + if (metaLocalName && isDeclarationOf(t, statementPath, metaLocalName)) { + statementPath.remove(); + continue; + } + + // Remove sibling story exports. + if (statementPath.isExportNamedDeclaration()) { + statementPath.remove(); + continue; + } + + // Remove CSF2 story annotations (`Story.parameters = …`, `Story.args = …`). + if (isStoryAnnotationAssignment(t, statementPath)) { + statementPath.remove(); + continue; + } + + // Keep only declarations reachable from the target story. + if (isModuleLevelDeclaration(t, statementPath)) { + if (!isDeclarationReachable(statementPath, neededBindings)) { + statementPath.remove(); + } + continue; + } + } + + onResult(generate(babel, path.node)); + path.stop(); + }, + }, + }; +} + +/** + * Resolves the meta object expression from a default export declaration, whether + * it is inline (`export default {…}`), TS-wrapped (`… satisfies Meta`) or a + * reference to a `const meta = {…}` declaration. + */ +function resolveMetaObject( + t: typeof Babel.types, + declaration: Babel.types.ExportDefaultDeclaration['declaration'], + body: Array>, +): { object?: Babel.types.ObjectExpression; localName?: string } { + const unwrapped = unwrapExpression(t, declaration); + + if (t.isObjectExpression(unwrapped)) { + return { object: unwrapped }; + } + + if (t.isIdentifier(unwrapped)) { + const localName = unwrapped.name; + for (const statementPath of body) { + if (statementPath.isVariableDeclaration()) { + for (const declarator of statementPath.node.declarations) { + if (t.isIdentifier(declarator.id) && declarator.id.name === localName && declarator.init) { + const init = unwrapExpression(t, declarator.init); + if (t.isObjectExpression(init)) { + return { object: init, localName }; + } + } + } + } + } + return { localName }; + } + + return {}; +} + +/** Unwraps TS `as`/`satisfies` and parenthesized expressions. */ +function unwrapExpression(t: typeof Babel.types, node: T): Babel.types.Node { + let current: Babel.types.Node = node; + while (t.isTSAsExpression(current) || t.isTSSatisfiesExpression(current) || t.isParenthesizedExpression(current)) { + current = current.expression; + } + return current; +} + +/** Returns the exported story identifier name from a named export, if any. */ +function getExportedStoryName(t: typeof Babel.types, node: Babel.types.ExportNamedDeclaration): string | undefined { + const declaration = node.declaration; + + if (t.isFunctionDeclaration(declaration) && t.isIdentifier(declaration.id)) { + return isComponentLikeName(declaration.id.name) ? declaration.id.name : undefined; + } + + if (t.isVariableDeclaration(declaration) && declaration.declarations.length === 1) { + const id = declaration.declarations[0].id; + if (t.isIdentifier(id) && isComponentLikeName(id.name)) { + return id.name; + } + } + + return undefined; +} + +/** Reads the `component` identifier name from a meta object expression. */ +function getMetaComponentName(t: typeof Babel.types, metaObject: Babel.types.ObjectExpression): string | undefined { + const component = getObjectProperty(t, metaObject, 'component'); + return component && t.isIdentifier(component) ? component.name : undefined; +} + +/** Reads a property value from an object expression by key. */ +function getObjectProperty( + t: typeof Babel.types, + object: Babel.types.ObjectExpression, + key: string, +): Babel.types.Expression | undefined { + for (const property of object.properties) { + if (t.isObjectProperty(property) && !property.computed) { + const propKey = property.key; + const name = t.isIdentifier(propKey) ? propKey.name : t.isStringLiteral(propKey) ? propKey.value : undefined; + if (name === key && t.isExpression(property.value)) { + return property.value; + } + } + } + return undefined; +} + +interface NormalizeContext { + metaArgs?: Babel.types.ObjectExpression; + metaComponentName?: string; + objectConstsByName: Map; +} + +/** + * Normalizes a story export into a renderable function export. Returns the new + * export node, or `null` when normalization is not possible. + */ +function normalizeStory( + t: typeof Babel.types, + node: Babel.types.ExportNamedDeclaration, + context: NormalizeContext, +): Babel.types.ExportNamedDeclaration | null { + const declaration = node.declaration; + + // CSF2 function declaration: `export function X() {…}` — already renderable. + if (t.isFunctionDeclaration(declaration)) { + return node; + } + + if (!t.isVariableDeclaration(declaration) || declaration.declarations.length !== 1) { + return null; + } + + const declarator = declaration.declarations[0]; + if (!t.isIdentifier(declarator.id) || !declarator.init) { + return null; + } + + const storyName = declarator.id.name; + const init = unwrapExpression(t, declarator.init); + + // CSF2 function story: `export const X = () => <…/>` — already renderable. + if (t.isArrowFunctionExpression(init) || t.isFunctionExpression(init)) { + return buildFunctionStoryExport(t, storyName, init); + } + + // CSF3 object story. + if (t.isObjectExpression(init)) { + const flattened = flattenObjectExpression(t, init, context.objectConstsByName, new Set()); + const renderProp = getObjectProperty(t, flattened, 'render'); + const storyArgs = getObjectProperty(t, flattened, 'args'); + const mergedArgs = mergeArgs(t, context.metaArgs, t.isObjectExpression(storyArgs) ? storyArgs : undefined); + + if (renderProp && (t.isArrowFunctionExpression(renderProp) || t.isFunctionExpression(renderProp))) { + const fn = buildRenderFunction(t, renderProp, mergedArgs); + return buildFunctionStoryExport(t, storyName, fn); + } + + // Render-less args-only story: synthesize ``. + // Only do this for genuine story objects (empty, or containing only known + // CSF fields) so arbitrary capitalized data exports are not turned into + // fake stories. + if (context.metaComponentName && isStoryShapedObject(t, flattened)) { + const fn = buildSynthesizedFunction(t, context.metaComponentName, mergedArgs); + return buildFunctionStoryExport(t, storyName, fn); + } + } + + return null; +} + +/** + * Known Component Story Format story-object fields. An object export is treated + * as a story only if it is empty or every own property is one of these (or a + * spread), avoiding false positives on capitalized data exports. + */ +const CSF_STORY_FIELDS = new Set([ + 'render', + 'args', + 'argTypes', + 'play', + 'parameters', + 'decorators', + 'loaders', + 'tags', + 'name', + 'globals', + 'beforeEach', + 'story', +]); + +/** Whether an object expression looks like a CSF story object (not arbitrary data). */ +function isStoryShapedObject(t: typeof Babel.types, object: Babel.types.ObjectExpression): boolean { + return object.properties.every(property => { + if (t.isSpreadElement(property)) { + return true; + } + if ((t.isObjectProperty(property) || t.isObjectMethod(property)) && !property.computed) { + const key = property.key; + const name = t.isIdentifier(key) ? key.name : t.isStringLiteral(key) ? key.value : undefined; + return name !== undefined && CSF_STORY_FIELDS.has(name); + } + return false; + }); +} + +/** Builds `export const = ;`. */ +function buildFunctionStoryExport( + t: typeof Babel.types, + name: string, + fn: Babel.types.ArrowFunctionExpression | Babel.types.FunctionExpression, +): Babel.types.ExportNamedDeclaration { + const declarator = t.variableDeclarator(t.identifier(name), fn); + return t.exportNamedDeclaration(t.variableDeclaration('const', [declarator]), []); +} + +/** + * Converts a story `render` function into a no-arg render function, re-declaring + * the args parameter as a local `const` bound to the merged args object. + */ +function buildRenderFunction( + t: typeof Babel.types, + render: Babel.types.ArrowFunctionExpression | Babel.types.FunctionExpression, + mergedArgs: Babel.types.ObjectExpression | undefined, +): Babel.types.ArrowFunctionExpression { + const argsParam = render.params[0]; + const body = render.body; + + // No args parameter — keep the body as-is. + if (!argsParam) { + return t.arrowFunctionExpression([], body); + } + + const argsDeclaration = t.variableDeclaration('const', [ + t.variableDeclarator(argsParam as Babel.types.LVal, mergedArgs ?? t.objectExpression([])), + ]); + + const statements: Babel.types.Statement[] = [argsDeclaration]; + if (t.isBlockStatement(body)) { + statements.push(...body.body); + } else { + statements.push(t.returnStatement(body)); + } + + return t.arrowFunctionExpression([], t.blockStatement(statements)); +} + +/** Builds `() => { const args = {…}; return ; }`. */ +function buildSynthesizedFunction( + t: typeof Babel.types, + componentName: string, + mergedArgs: Babel.types.ObjectExpression | undefined, +): Babel.types.ArrowFunctionExpression { + const jsx = t.jsxElement( + t.jsxOpeningElement(t.jsxIdentifier(componentName), [t.jsxSpreadAttribute(t.identifier('args'))], true), + null, + [], + true, + ); + + const argsDeclaration = t.variableDeclaration('const', [ + t.variableDeclarator(t.identifier('args'), mergedArgs ?? t.objectExpression([])), + ]); + + return t.arrowFunctionExpression([], t.blockStatement([argsDeclaration, t.returnStatement(jsx)])); +} + +/** Merges meta args and story args into a single object expression (story wins, keys deduped). */ +function mergeArgs( + t: typeof Babel.types, + metaArgs: Babel.types.ObjectExpression | undefined, + storyArgs: Babel.types.ObjectExpression | undefined, +): Babel.types.ObjectExpression | undefined { + if (!metaArgs && !storyArgs) { + return undefined; + } + + const byKey = new Map(); + const order: Array< + { kind: 'key'; key: string } | { kind: 'raw'; node: Babel.types.ObjectExpression['properties'][number] } + > = []; + + const add = (properties: Babel.types.ObjectExpression['properties']) => { + for (const property of properties) { + if (t.isObjectProperty(property) && !property.computed) { + const key = t.isIdentifier(property.key) + ? property.key.name + : t.isStringLiteral(property.key) + ? property.key.value + : undefined; + if (key !== undefined) { + if (!byKey.has(key)) { + order.push({ kind: 'key', key }); + } + byKey.set(key, property); + continue; + } + } + order.push({ kind: 'raw', node: property }); + } + }; + + add(metaArgs?.properties ?? []); + add(storyArgs?.properties ?? []); + + const properties = order.map(entry => (entry.kind === 'key' ? byKey.get(entry.key)! : entry.node)); + return t.objectExpression(properties); +} + +/** Records `const X = {…}` / `export const X = {…}` initializers for spread resolution. */ +function collectObjectConst( + t: typeof Babel.types, + statementPath: Babel.NodePath, + target: Map, +): void { + const declaration = statementPath.isExportNamedDeclaration() ? statementPath.node.declaration : statementPath.node; + if (!declaration || !t.isVariableDeclaration(declaration)) { + return; + } + for (const declarator of declaration.declarations) { + if (t.isIdentifier(declarator.id) && declarator.init) { + const init = unwrapExpression(t, declarator.init); + if (t.isObjectExpression(init)) { + target.set(declarator.id.name, init); + } + } + } +} + +/** + * Expands `{ ...Base, … }` spread elements that reference module-level object + * consts (e.g. sibling CSF3 stories), so the resulting object carries the + * spread source's `render`/`args`. Later properties override earlier ones. + */ +function flattenObjectExpression( + t: typeof Babel.types, + object: Babel.types.ObjectExpression, + objectConstsByName: Map, + seen: Set, +): Babel.types.ObjectExpression { + const properties: Babel.types.ObjectExpression['properties'] = []; + + for (const property of object.properties) { + if (t.isSpreadElement(property) && t.isIdentifier(property.argument)) { + const name = property.argument.name; + const source = objectConstsByName.get(name); + if (source && !seen.has(name)) { + seen.add(name); + const expanded = flattenObjectExpression(t, source, objectConstsByName, seen); + properties.push(...expanded.properties); + continue; + } + } + properties.push(property); + } + + return t.objectExpression(properties); +} + +/** + * Collects the set of module-level binding paths reachable from the target story + * via identifier references, following declarations transitively (BFS). + */ +function collectReachableBindings( + programPath: Babel.NodePath, + targetPath: Babel.NodePath, +): Set { + const needed = new Set(); + const worklist: Array = [targetPath]; + + while (worklist.length > 0) { + const current = worklist.pop()!; + current.traverse({ + // eslint-disable-next-line @typescript-eslint/naming-convention + ReferencedIdentifier(idPath) { + const binding = idPath.scope.getBinding(idPath.node.name); + if (!binding) { + return; + } + // Only module-level bindings are candidates for inclusion. + if (binding.scope !== programPath.scope) { + return; + } + const bindingPath = binding.path; + if (needed.has(bindingPath)) { + return; + } + needed.add(bindingPath); + worklist.push(bindingPath); + }, + }); + } + + return needed; +} + +/** Prunes unused specifiers from an import declaration, removing it if empty. */ +function pruneImportDeclaration( + t: typeof Babel.types, + importPath: Babel.NodePath, + needed: Set, +): void { + const specifiers = importPath.get('specifiers'); + + // React must remain in scope for classic-runtime JSX. + if (importPath.node.source.value === 'react') { + return; + } + + // Side-effect import (`import './x.css'`) — always keep. + if (specifiers.length === 0) { + return; + } + + let kept = 0; + for (const specifierPath of specifiers) { + if (needed.has(specifierPath)) { + kept++; + } else { + specifierPath.remove(); + } + } + + if (kept === 0) { + importPath.remove(); + } +} + +/** Whether the statement declares the given local identifier name. */ +function isDeclarationOf(t: typeof Babel.types, statementPath: Babel.NodePath, name: string): boolean { + if (statementPath.isVariableDeclaration()) { + return statementPath.node.declarations.some(d => t.isIdentifier(d.id) && d.id.name === name); + } + return false; +} + +/** Whether the statement is a module-level value/type declaration. */ +function isModuleLevelDeclaration(t: typeof Babel.types, statementPath: Babel.NodePath): boolean { + return ( + statementPath.isVariableDeclaration() || + statementPath.isFunctionDeclaration() || + statementPath.isClassDeclaration() || + statementPath.isTSTypeAliasDeclaration() || + statementPath.isTSInterfaceDeclaration() || + statementPath.isTSEnumDeclaration() + ); +} + +/** Whether a module-level declaration is in the reachable set. */ +function isDeclarationReachable(statementPath: Babel.NodePath, needed: Set): boolean { + if (needed.has(statementPath)) { + return true; + } + // Variable declarations register bindings on their declarators, not the statement. + if (statementPath.isVariableDeclaration()) { + return statementPath.get('declarations').some(declaratorPath => needed.has(declaratorPath)); + } + return false; +} + +/** Whether the statement is a CSF2 story annotation assignment (`Story.x = …`). */ +function isStoryAnnotationAssignment(t: typeof Babel.types, statementPath: Babel.NodePath): boolean { + if (!statementPath.isExpressionStatement()) { + return false; + } + const expression = statementPath.node.expression; + return ( + t.isAssignmentExpression(expression) && + t.isMemberExpression(expression.left) && + t.isIdentifier(expression.left.object) + ); +} + +/** Generates source code from an AST node. */ +function generate(babel: typeof Babel, node: Babel.types.Node): string { + const result = babel.transformFromAstSync( + babel.types.file(babel.types.program((node as Babel.types.Program).body)), + undefined, + { + configFile: false, + babelrc: false, + code: true, + ast: false, + comments: false, + generatorOpts: { retainLines: false, compact: false }, + }, + ); + return result?.code ?? ''; +} + +/** Checks if the name is component-like (starts with an uppercase letter). */ +function isComponentLikeName(name: string): boolean { + return name.charAt(0) === name.charAt(0).toUpperCase(); +} diff --git a/packages/react-components/babel-preset-storybook-full-source/src/types.ts b/packages/react-components/babel-preset-storybook-full-source/src/types.ts index 683bd284e055da..94301d4deeaeae 100644 --- a/packages/react-components/babel-preset-storybook-full-source/src/types.ts +++ b/packages/react-components/babel-preset-storybook-full-source/src/types.ts @@ -18,6 +18,22 @@ export interface BabelPluginOptions { /** Map of package names to their replacement config (used by `modifyImportsPlugin`). */ importMappings: Record; + /** + * Controls how `fullSource` is generated when a story file contains multiple + * story exports (the standard Component Story Format convention). + * + * - `'file'` (default): legacy behavior — the **entire file** is attached as + * `fullSource` on the **last** story export. Suited to the one-story-per-file + * convention where a file maps to a single example. + * - `'story'`: each story export gets its **own** sliced `fullSource` containing + * only the imports and helper declarations it references, plus that single + * story (converted to a renderable function). Suited to files with multiple + * stories per component. + * + * @default 'file' + */ + storyGranularity?: 'file' | 'story'; + /** * When `true` (or a config object), the plugin will: * - Preserve `*.module.css` imports (rewriting paths to `./styles/`) From 65000b4a1bf6cf0ad07ff3d7aec42e5a24470790 Mon Sep 17 00:00:00 2001 From: Martin Hochel Date: Thu, 16 Jul 2026 10:49:58 +0200 Subject: [PATCH 02/14] refactor(babel-preset-storybook-full-source): preserve source formatting in per-story slices Unify the slicer output with 'file' granularity so single-story slices are byte-identical to the whole-file path (no cosmetic churn): - sliceStorySource now mutates the original AST in place and prints with retainLines + preserved comments, instead of regenerating via @babel/generator. - CSF2 stories/helpers keep their original nodes (only stripping a story-type annotation) so authored formatting survives; only CSF3 transforms build new nodes. - story-mode second pass uses retainLines to match file mode. - Fix: reachability now follows type references (TSTypeReference/TSTypeQuery) and resolves local type/interface/enum declarations, so type aliases and their imports are no longer wrongly pruned (dangling type refs). - Strip locations from synthesized CSF3 merged args for clean formatting. Corpus effect: 916/939 single-story fullSource outputs become byte-identical to file mode; the rest are beneficial pruning (meta + dead argTypes/@storybook config). --- .../csf3-data-export/output.js | 2 +- .../csf3-render-and-args/output.js | 4 +- .../multi-story-csf2/output.js | 4 +- .../src/fullsource.ts | 1 + .../src/sliceStory.ts | 141 ++++++++++++------ 5 files changed, 101 insertions(+), 51 deletions(-) diff --git a/packages/react-components/babel-preset-storybook-full-source/src/__fixtures__/storybook-stories-fullsource-per-story/csf3-data-export/output.js b/packages/react-components/babel-preset-storybook-full-source/src/__fixtures__/storybook-stories-fullsource-per-story/csf3-data-export/output.js index 38d80436198c97..7fbacd5f0b5ccc 100644 --- a/packages/react-components/babel-preset-storybook-full-source/src/__fixtures__/storybook-stories-fullsource-per-story/csf3-data-export/output.js +++ b/packages/react-components/babel-preset-storybook-full-source/src/__fixtures__/storybook-stories-fullsource-per-story/csf3-data-export/output.js @@ -18,4 +18,4 @@ export const BrandColors = { }; Default.parameters = {}; Default.parameters.fullSource = - 'import { Button } from "@fluentui/react-components";\nimport * as React from "react";\nexport const Default = () => ;\n'; + 'import { Button } from "@fluentui/react-components";\nimport * as React from "react";\n\n// Genuine story \u2014 should get a sliced fullSource.\nexport const Default = () => ;\n\n// Capitalized data export (not a story) \u2014 must NOT be turned into a fake story.\n'; diff --git a/packages/react-components/babel-preset-storybook-full-source/src/__fixtures__/storybook-stories-fullsource-per-story/csf3-render-and-args/output.js b/packages/react-components/babel-preset-storybook-full-source/src/__fixtures__/storybook-stories-fullsource-per-story/csf3-render-and-args/output.js index 4a37582601e4f6..f5c9c1582049d9 100644 --- a/packages/react-components/babel-preset-storybook-full-source/src/__fixtures__/storybook-stories-fullsource-per-story/csf3-render-and-args/output.js +++ b/packages/react-components/babel-preset-storybook-full-source/src/__fixtures__/storybook-stories-fullsource-per-story/csf3-render-and-args/output.js @@ -37,7 +37,7 @@ Default.parameters.fullSource = 'import { Button } from "@fluentui/react-components";\nimport * as React from "react";\nexport const Default = () => ;\n'; WithArgs.parameters = {}; WithArgs.parameters.fullSource = - 'import { Button } from "@fluentui/react-components";\nimport * as React from "react";\nexport const WithArgs = () => {\n const args = {\n appearance: "outline",\n };\n return ;\n};\n'; + 'import { Button } from "@fluentui/react-components";\nimport * as React from "react";\nexport const WithArgs = () => {\n const args = { appearance: "outline" };\n return ;\n};\n'; ArgsOnly.parameters = {}; ArgsOnly.parameters.fullSource = - 'import { Button } from "@fluentui/react-components";\nimport * as React from "react";\nexport const ArgsOnly = () => {\n const args = {\n appearance: "primary",\n children: "Args only",\n };\n return \n \n);\n'; + 'import { Button } from "@fluentui/react-components";\nimport * as React from "react";\n\nconst Wrapper = ({ children }) =>
{children}
;\n\nexport const Primary = () => (\n \n \n \n);\n'; Secondary.parameters = {}; Secondary.parameters.fullSource = - 'import { Button } from "@fluentui/react-components";\nimport * as React from "react";\nexport const Secondary = () => (\n \n);\n'; + 'import { Button } from "@fluentui/react-components";\nimport * as React from "react";\n\nexport const Secondary = () => (\n \n);\n'; diff --git a/packages/react-components/babel-preset-storybook-full-source/src/fullsource.ts b/packages/react-components/babel-preset-storybook-full-source/src/fullsource.ts index 1f3c94cdbce88e..817367ea1028f1 100644 --- a/packages/react-components/babel-preset-storybook-full-source/src/fullsource.ts +++ b/packages/react-components/babel-preset-storybook-full-source/src/fullsource.ts @@ -183,6 +183,7 @@ export function fullSourcePlugin(babel: typeof Babel, options: BabelPluginOption const transformedStory = babel.transformSync(sliced, { ...state.file.opts, compact: false, + retainLines: true, comments: false, plugins: [[modifyImportsPlugin, options], removeStorybookParameters], })?.code; diff --git a/packages/react-components/babel-preset-storybook-full-source/src/sliceStory.ts b/packages/react-components/babel-preset-storybook-full-source/src/sliceStory.ts index 4107555f2e2057..abb6084c0f0402 100644 --- a/packages/react-components/babel-preset-storybook-full-source/src/sliceStory.ts +++ b/packages/react-components/babel-preset-storybook-full-source/src/sliceStory.ts @@ -34,31 +34,35 @@ export interface SliceStoryOptions { * (e.g. render-less story without a resolvable meta `component`). */ export function sliceStorySource(babel: typeof Babel, source: string, options: SliceStoryOptions): string | null { - let sliced: string | null = null; + const context = { handled: false }; - babel.transformSync(source, { + const result = babel.transformSync(source, { configFile: false, babelrc: false, filename: options.filename, - code: false, + code: true, ast: false, + // Mutate the original AST in place and print with `retainLines` (mirroring + // `file` granularity) so retained nodes keep their authored formatting and + // the downstream prettier output matches — avoiding cosmetic churn. + retainLines: true, + // Preserve comments so the sliced source matches `file` granularity output + // (comment stripping, if any, happens in the shared downstream pass). + comments: true, + compact: false, parserOpts: { plugins: ['jsx', 'typescript', 'classProperties', 'objectRestSpread'], }, - plugins: [ - createSliceStoryPlugin(babel, options, code => { - sliced = code; - }), - ], + plugins: [createSliceStoryPlugin(babel, options, context)], }); - return sliced; + return context.handled ? result?.code ?? null : null; } function createSliceStoryPlugin( babel: typeof Babel, options: SliceStoryOptions, - onResult: (code: string) => void, + context: { handled: boolean }, ): Babel.PluginObj { const { types: t } = babel; const { targetStory } = options; @@ -76,9 +80,13 @@ function createSliceStoryPlugin( let targetPath: Babel.NodePath | undefined; // Map of `const X = {…}` initializers (exported or not) for spread resolution. const objectConstsByName = new Map(); + // Map of local type declarations (`type`/`interface`/`enum`) by name, used + // to resolve type references (babel scope bindings don't track type-only decls). + const typeDeclsByName = new Map(); for (const statementPath of body) { collectObjectConst(t, statementPath, objectConstsByName); + collectTypeDecl(t, statementPath, typeDeclsByName); if (statementPath.isExportDefaultDeclaration()) { const resolved = resolveMetaObject(t, statementPath.node.declaration, body); @@ -114,14 +122,19 @@ function createSliceStoryPlugin( return; } - targetPath.replaceWith(normalized); + // Replace only when normalization produced a NEW node (CSF3 transforms). + // For CSF2 the original node is kept in place so `retainLines` preserves + // its authored source formatting. + if (normalized !== targetPath.node) { + targetPath.replaceWith(normalized); + } // Rebuild bindings so references introduced by normalization (args, // the meta component) resolve correctly during reachability analysis. path.scope.crawl(); // --- Reachability from the normalized story -------------------------- - const neededBindings = collectReachableBindings(path, targetPath); + const neededBindings = collectReachableBindings(path, targetPath, typeDeclsByName); // --- Prune the program body ----------------------------------------- for (const statementPath of path.get('body')) { @@ -166,8 +179,7 @@ function createSliceStoryPlugin( } } - onResult(generate(babel, path.node)); - path.stop(); + context.handled = true; }, }, }; @@ -291,12 +303,18 @@ function normalizeStory( return null; } - const storyName = declarator.id.name; + const id = declarator.id; + const storyName = id.name; const init = unwrapExpression(t, declarator.init); // CSF2 function story: `export const X = () => <…/>` — already renderable. + // Keep the original node so `retainLines` preserves its authored formatting; + // only drop a story-type annotation whose type import gets pruned. if (t.isArrowFunctionExpression(init) || t.isFunctionExpression(init)) { - return buildFunctionStoryExport(t, storyName, init); + if (id.typeAnnotation) { + id.typeAnnotation = null; + } + return node; } // CSF3 object story. @@ -459,7 +477,9 @@ function mergeArgs( add(storyArgs?.properties ?? []); const properties = order.map(entry => (entry.kind === 'key' ? byKey.get(entry.key)! : entry.node)); - return t.objectExpression(properties); + // Strip source locations so the synthesized object formats cleanly under + // `retainLines` instead of inheriting the (mismatched) lines of merged props. + return t.objectExpression(properties.map(property => t.cloneNode(property, true, true))); } /** Records `const X = {…}` / `export const X = {…}` initializers for spread resolution. */ @@ -482,6 +502,21 @@ function collectObjectConst( } } +/** Records local `type`/`interface`/`enum` declarations by name for type-reference resolution. */ +function collectTypeDecl( + t: typeof Babel.types, + statementPath: Babel.NodePath, + target: Map, +): void { + if (statementPath.isTSTypeAliasDeclaration() && t.isIdentifier(statementPath.node.id)) { + target.set(statementPath.node.id.name, statementPath); + } else if (statementPath.isTSInterfaceDeclaration() && t.isIdentifier(statementPath.node.id)) { + target.set(statementPath.node.id.name, statementPath); + } else if (statementPath.isTSEnumDeclaration() && t.isIdentifier(statementPath.node.id)) { + target.set(statementPath.node.id.name, statementPath); + } +} + /** * Expands `{ ...Base, … }` spread elements that reference module-level object * consts (e.g. sibling CSF3 stories), so the resulting object carries the @@ -519,29 +554,60 @@ function flattenObjectExpression( function collectReachableBindings( programPath: Babel.NodePath, targetPath: Babel.NodePath, + typeDeclsByName: Map, ): Set { const needed = new Set(); const worklist: Array = [targetPath]; + const consider = (name: string, atPath: Babel.NodePath) => { + const binding = atPath.scope.getBinding(name); + // Prefer a module-level value/import binding; fall back to a local type decl + // (type aliases/interfaces are not tracked by babel's scope bindings). + let bindingPath: Babel.NodePath | undefined; + if (binding && binding.scope === programPath.scope) { + bindingPath = binding.path; + } else if (!binding && typeDeclsByName.has(name)) { + bindingPath = typeDeclsByName.get(name); + } + if (!bindingPath || needed.has(bindingPath)) { + return; + } + needed.add(bindingPath); + worklist.push(bindingPath); + }; + + // Resolve the leftmost identifier of a (possibly qualified) type name. + const leftmostName = (node: Babel.types.Node | null | undefined): Babel.types.Identifier | undefined => { + let current = node; + while (current && current.type === 'TSQualifiedName') { + current = (current as Babel.types.TSQualifiedName).left; + } + return current && current.type === 'Identifier' ? (current as Babel.types.Identifier) : undefined; + }; + while (worklist.length > 0) { const current = worklist.pop()!; current.traverse({ // eslint-disable-next-line @typescript-eslint/naming-convention ReferencedIdentifier(idPath) { - const binding = idPath.scope.getBinding(idPath.node.name); - if (!binding) { - return; - } - // Only module-level bindings are candidates for inclusion. - if (binding.scope !== programPath.scope) { - return; + consider(idPath.node.name, idPath); + }, + // Follow type references (annotations, generics) so type aliases / + // interfaces / `import type` used by reachable code are not pruned. + // eslint-disable-next-line @typescript-eslint/naming-convention + TSTypeReference(typePath) { + const id = leftmostName(typePath.node.typeName); + if (id) { + consider(id.name, typePath); } - const bindingPath = binding.path; - if (needed.has(bindingPath)) { - return; + }, + // Follow `typeof X` type queries. + // eslint-disable-next-line @typescript-eslint/naming-convention + TSTypeQuery(queryPath) { + const id = leftmostName(queryPath.node.exprName as Babel.types.Node); + if (id) { + consider(id.name, queryPath); } - needed.add(bindingPath); - worklist.push(bindingPath); }, }); } @@ -626,23 +692,6 @@ function isStoryAnnotationAssignment(t: typeof Babel.types, statementPath: Babel ); } -/** Generates source code from an AST node. */ -function generate(babel: typeof Babel, node: Babel.types.Node): string { - const result = babel.transformFromAstSync( - babel.types.file(babel.types.program((node as Babel.types.Program).body)), - undefined, - { - configFile: false, - babelrc: false, - code: true, - ast: false, - comments: false, - generatorOpts: { retainLines: false, compact: false }, - }, - ); - return result?.code ?? ''; -} - /** Checks if the name is component-like (starts with an uppercase letter). */ function isComponentLikeName(name: string): boolean { return name.charAt(0) === name.charAt(0).toUpperCase(); From 4d1dfaac6a8b772ef2b5c871d4646d8c47451308 Mon Sep 17 00:00:00 2001 From: Martin Hochel Date: Thu, 16 Jul 2026 11:01:17 +0200 Subject: [PATCH 03/14] test(babel-preset-storybook-full-source): add CSF3 multi-story fixture; fix sibling comment leak MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add csf3-multi-story fixture proving per-story reachability across multiple CSF3 object stories that pull in different subsets of shared imports/helpers (Primary -> Button+useStyles, Loading -> Spinner only, Group -> all). - Fix: comments belonging to removed nodes (sibling stories, meta) no longer leak onto retained neighbours in a slice — track each comment's owning node and drop comments whose owner was pruned. --- .../csf3-data-export/output.js | 2 +- .../csf3-multi-story/code.js | 47 +++++++++++++ .../csf3-multi-story/output.js | 68 +++++++++++++++++++ .../src/sliceStory.ts | 43 ++++++++++-- 4 files changed, 154 insertions(+), 6 deletions(-) create mode 100644 packages/react-components/babel-preset-storybook-full-source/src/__fixtures__/storybook-stories-fullsource-per-story/csf3-multi-story/code.js create mode 100644 packages/react-components/babel-preset-storybook-full-source/src/__fixtures__/storybook-stories-fullsource-per-story/csf3-multi-story/output.js diff --git a/packages/react-components/babel-preset-storybook-full-source/src/__fixtures__/storybook-stories-fullsource-per-story/csf3-data-export/output.js b/packages/react-components/babel-preset-storybook-full-source/src/__fixtures__/storybook-stories-fullsource-per-story/csf3-data-export/output.js index 7fbacd5f0b5ccc..8ec3e8db9d8b23 100644 --- a/packages/react-components/babel-preset-storybook-full-source/src/__fixtures__/storybook-stories-fullsource-per-story/csf3-data-export/output.js +++ b/packages/react-components/babel-preset-storybook-full-source/src/__fixtures__/storybook-stories-fullsource-per-story/csf3-data-export/output.js @@ -18,4 +18,4 @@ export const BrandColors = { }; Default.parameters = {}; Default.parameters.fullSource = - 'import { Button } from "@fluentui/react-components";\nimport * as React from "react";\n\n// Genuine story \u2014 should get a sliced fullSource.\nexport const Default = () => ;\n\n// Capitalized data export (not a story) \u2014 must NOT be turned into a fake story.\n'; + 'import { Button } from "@fluentui/react-components";\nimport * as React from "react";\n\n// Genuine story \u2014 should get a sliced fullSource.\nexport const Default = () => ;\n'; diff --git a/packages/react-components/babel-preset-storybook-full-source/src/__fixtures__/storybook-stories-fullsource-per-story/csf3-multi-story/code.js b/packages/react-components/babel-preset-storybook-full-source/src/__fixtures__/storybook-stories-fullsource-per-story/csf3-multi-story/code.js new file mode 100644 index 00000000000000..76db7af4fdfff2 --- /dev/null +++ b/packages/react-components/babel-preset-storybook-full-source/src/__fixtures__/storybook-stories-fullsource-per-story/csf3-multi-story/code.js @@ -0,0 +1,47 @@ +import * as React from 'react'; +import { Button, makeStyles } from '@fluentui/react-button'; +import { Spinner } from '@fluentui/react-menu'; + +const useStyles = makeStyles({ + root: { + display: 'flex', + gap: '8px', + }, +}); + +const meta = { + title: 'Button', + component: Button, +}; + +export default meta; + +// Uses `useStyles` + `Button` — must NOT include `Spinner`. +export const Primary = { + render: () => { + const styles = useStyles(); + return ( +
+ +
+ ); + }, +}; + +// Uses `Spinner` only — must NOT include `useStyles`/`makeStyles`/`Button`. +export const Loading = { + render: () => , +}; + +// Uses everything — the full slice. +export const Group = { + render: () => { + const styles = useStyles(); + return ( +
+ + +
+ ); + }, +}; diff --git a/packages/react-components/babel-preset-storybook-full-source/src/__fixtures__/storybook-stories-fullsource-per-story/csf3-multi-story/output.js b/packages/react-components/babel-preset-storybook-full-source/src/__fixtures__/storybook-stories-fullsource-per-story/csf3-multi-story/output.js new file mode 100644 index 00000000000000..5d9ecd5b5f5bf2 --- /dev/null +++ b/packages/react-components/babel-preset-storybook-full-source/src/__fixtures__/storybook-stories-fullsource-per-story/csf3-multi-story/output.js @@ -0,0 +1,68 @@ +import * as React from 'react'; +import { Button, makeStyles } from '@fluentui/react-button'; +import { Spinner } from '@fluentui/react-menu'; +const useStyles = makeStyles({ + root: { + display: 'flex', + gap: '8px', + }, +}); +const meta = { + title: 'Button', + component: Button, +}; +export default meta; + +// Uses `useStyles` + `Button` — must NOT include `Spinner`. +export const Primary = { + render: () => { + const styles = useStyles(); + return /*#__PURE__*/ React.createElement( + 'div', + { + className: styles.root, + }, + /*#__PURE__*/ React.createElement( + Button, + { + appearance: 'primary', + }, + 'Primary', + ), + ); + }, +}; + +// Uses `Spinner` only — must NOT include `useStyles`/`makeStyles`/`Button`. +export const Loading = { + render: () => + /*#__PURE__*/ React.createElement(Spinner, { + label: 'Loading', + }), +}; + +// Uses everything — the full slice. +export const Group = { + render: () => { + const styles = useStyles(); + return /*#__PURE__*/ React.createElement( + 'div', + { + className: styles.root, + }, + /*#__PURE__*/ React.createElement(Button, null, 'One'), + /*#__PURE__*/ React.createElement(Spinner, { + label: 'Loading', + }), + ); + }, +}; +Primary.parameters = {}; +Primary.parameters.fullSource = + 'import { Button, makeStyles } from "@fluentui/react-components";\nimport * as React from "react";\n\nconst useStyles = makeStyles({\n root: {\n display: "flex",\n gap: "8px",\n },\n});\n\n// Uses `useStyles` + `Button` \u2014 must NOT include `Spinner`.\nexport const Primary = () => {\n const styles = useStyles();\n return (\n
\n \n
\n );\n};\n'; +Loading.parameters = {}; +Loading.parameters.fullSource = + 'import { Spinner } from "@fluentui/react-components";\nimport * as React from "react";\n\n// Uses `Spinner` only \u2014 must NOT include `useStyles`/`makeStyles`/`Button`.\nexport const Loading = () => ;\n'; +Group.parameters = {}; +Group.parameters.fullSource = + 'import { Button, makeStyles, Spinner } from "@fluentui/react-components";\nimport * as React from "react";\n\nconst useStyles = makeStyles({\n root: {\n display: "flex",\n gap: "8px",\n },\n});\n\n// Uses everything \u2014 the full slice.\nexport const Group = () => {\n const styles = useStyles();\n return (\n
\n \n \n
\n );\n};\n'; diff --git a/packages/react-components/babel-preset-storybook-full-source/src/sliceStory.ts b/packages/react-components/babel-preset-storybook-full-source/src/sliceStory.ts index abb6084c0f0402..586e4a093511f4 100644 --- a/packages/react-components/babel-preset-storybook-full-source/src/sliceStory.ts +++ b/packages/react-components/babel-preset-storybook-full-source/src/sliceStory.ts @@ -136,6 +136,25 @@ function createSliceStoryPlugin( // --- Reachability from the normalized story -------------------------- const neededBindings = collectReachableBindings(path, targetPath, typeDeclsByName); + // Map each comment to the node it leads, so comments belonging to removed + // nodes (e.g. sibling stories) can be dropped instead of leaking onto + // retained neighbours (comments are shared as leading/trailing). + const commentOwner = new Map(); + path.traverse({ + enter(nodePath) { + for (const comment of nodePath.node.leadingComments ?? []) { + if (!commentOwner.has(comment)) { + commentOwner.set(comment, nodePath.node); + } + } + }, + }); + const removedNodes = new Set(); + const remove = (statementPath: Babel.NodePath) => { + removedNodes.add(statementPath.node); + statementPath.remove(); + }; + // --- Prune the program body ----------------------------------------- for (const statementPath of path.get('body')) { if (statementPath.node === targetPath.node) { @@ -148,37 +167,51 @@ function createSliceStoryPlugin( } if (statementPath.isExportDefaultDeclaration()) { - statementPath.remove(); + remove(statementPath); continue; } // Remove the `const meta = {…}` declaration backing the default export. if (metaLocalName && isDeclarationOf(t, statementPath, metaLocalName)) { - statementPath.remove(); + remove(statementPath); continue; } // Remove sibling story exports. if (statementPath.isExportNamedDeclaration()) { - statementPath.remove(); + remove(statementPath); continue; } // Remove CSF2 story annotations (`Story.parameters = …`, `Story.args = …`). if (isStoryAnnotationAssignment(t, statementPath)) { - statementPath.remove(); + remove(statementPath); continue; } // Keep only declarations reachable from the target story. if (isModuleLevelDeclaration(t, statementPath)) { if (!isDeclarationReachable(statementPath, neededBindings)) { - statementPath.remove(); + remove(statementPath); } continue; } } + // Drop comments that belonged to removed nodes but linger on retained ones. + if (removedNodes.size > 0) { + const keep = (comments: readonly Babel.types.Comment[] | null | undefined) => + comments ? comments.filter(c => !removedNodes.has(commentOwner.get(c) as Babel.types.Node)) : comments; + path.traverse({ + enter(nodePath) { + const node = nodePath.node; + node.leadingComments = keep(node.leadingComments) as Babel.types.Comment[] | null; + node.trailingComments = keep(node.trailingComments) as Babel.types.Comment[] | null; + node.innerComments = keep(node.innerComments) as Babel.types.Comment[] | null; + }, + }); + } + context.handled = true; }, }, From 2bf17d86cc634fd28e47f6f65c3f06460de680a2 Mon Sep 17 00:00:00 2001 From: Martin Hochel Date: Thu, 16 Jul 2026 11:05:10 +0200 Subject: [PATCH 04/14] test(babel-preset-storybook-full-source): use function-form stories in csf3-multi-story fixture Exercises per-story reachability for function-form story exports in a CSF3-meta file with a module-scoped React component: Primary -> Button, Loading -> Spinner (meta.component Button not leaked), Group -> local ButtonRow. --- .../csf3-multi-story/code.js | 51 +++++------- .../csf3-multi-story/output.js | 80 +++++++------------ 2 files changed, 50 insertions(+), 81 deletions(-) diff --git a/packages/react-components/babel-preset-storybook-full-source/src/__fixtures__/storybook-stories-fullsource-per-story/csf3-multi-story/code.js b/packages/react-components/babel-preset-storybook-full-source/src/__fixtures__/storybook-stories-fullsource-per-story/csf3-multi-story/code.js index 76db7af4fdfff2..f8df7dd241cb3c 100644 --- a/packages/react-components/babel-preset-storybook-full-source/src/__fixtures__/storybook-stories-fullsource-per-story/csf3-multi-story/code.js +++ b/packages/react-components/babel-preset-storybook-full-source/src/__fixtures__/storybook-stories-fullsource-per-story/csf3-multi-story/code.js @@ -1,14 +1,7 @@ import * as React from 'react'; -import { Button, makeStyles } from '@fluentui/react-button'; +import { Button } from '@fluentui/react-button'; import { Spinner } from '@fluentui/react-menu'; -const useStyles = makeStyles({ - root: { - display: 'flex', - gap: '8px', - }, -}); - const meta = { title: 'Button', component: Button, @@ -16,32 +9,26 @@ const meta = { export default meta; -// Uses `useStyles` + `Button` — must NOT include `Spinner`. -export const Primary = { - render: () => { - const styles = useStyles(); - return ( -
- -
- ); - }, +// A module-scoped standard React component, used by the `Group` story only. +const ButtonRow = () => ( + <> + + + +); + +// Function-form story using imported `Button` only — `meta.component` (Button) +// must not leak `Spinner` into other slices. +export const Primary = () => { + return ; }; -// Uses `Spinner` only — must NOT include `useStyles`/`makeStyles`/`Button`. -export const Loading = { - render: () => , +// Function-form story using imported `Spinner` only — must NOT include `Button`/`ButtonRow`. +export const Loading = () => { + return ; }; -// Uses everything — the full slice. -export const Group = { - render: () => { - const styles = useStyles(); - return ( -
- - -
- ); - }, +// Function-form story using the module-scoped `ButtonRow` component — must NOT include `Spinner`. +export const Group = () => { + return ; }; diff --git a/packages/react-components/babel-preset-storybook-full-source/src/__fixtures__/storybook-stories-fullsource-per-story/csf3-multi-story/output.js b/packages/react-components/babel-preset-storybook-full-source/src/__fixtures__/storybook-stories-fullsource-per-story/csf3-multi-story/output.js index 5d9ecd5b5f5bf2..44859729baf978 100644 --- a/packages/react-components/babel-preset-storybook-full-source/src/__fixtures__/storybook-stories-fullsource-per-story/csf3-multi-story/output.js +++ b/packages/react-components/babel-preset-storybook-full-source/src/__fixtures__/storybook-stories-fullsource-per-story/csf3-multi-story/output.js @@ -1,68 +1,50 @@ import * as React from 'react'; -import { Button, makeStyles } from '@fluentui/react-button'; +import { Button } from '@fluentui/react-button'; import { Spinner } from '@fluentui/react-menu'; -const useStyles = makeStyles({ - root: { - display: 'flex', - gap: '8px', - }, -}); const meta = { title: 'Button', component: Button, }; export default meta; -// Uses `useStyles` + `Button` — must NOT include `Spinner`. -export const Primary = { - render: () => { - const styles = useStyles(); - return /*#__PURE__*/ React.createElement( - 'div', - { - className: styles.root, - }, - /*#__PURE__*/ React.createElement( - Button, - { - appearance: 'primary', - }, - 'Primary', - ), - ); - }, +// A module-scoped standard React component, used by the `Group` story only. +const ButtonRow = () => + /*#__PURE__*/ React.createElement( + React.Fragment, + null, + /*#__PURE__*/ React.createElement(Button, null, 'One'), + /*#__PURE__*/ React.createElement(Button, null, 'Two'), + ); + +// Function-form story using imported `Button` only — `meta.component` (Button) +// must not leak `Spinner` into other slices. +export const Primary = () => { + return /*#__PURE__*/ React.createElement( + Button, + { + appearance: 'primary', + }, + 'Primary', + ); }; -// Uses `Spinner` only — must NOT include `useStyles`/`makeStyles`/`Button`. -export const Loading = { - render: () => - /*#__PURE__*/ React.createElement(Spinner, { - label: 'Loading', - }), +// Function-form story using imported `Spinner` only — must NOT include `Button`/`ButtonRow`. +export const Loading = () => { + return /*#__PURE__*/ React.createElement(Spinner, { + label: 'Loading', + }); }; -// Uses everything — the full slice. -export const Group = { - render: () => { - const styles = useStyles(); - return /*#__PURE__*/ React.createElement( - 'div', - { - className: styles.root, - }, - /*#__PURE__*/ React.createElement(Button, null, 'One'), - /*#__PURE__*/ React.createElement(Spinner, { - label: 'Loading', - }), - ); - }, +// Function-form story using the module-scoped `ButtonRow` component — must NOT include `Spinner`. +export const Group = () => { + return /*#__PURE__*/ React.createElement(ButtonRow, null); }; Primary.parameters = {}; Primary.parameters.fullSource = - 'import { Button, makeStyles } from "@fluentui/react-components";\nimport * as React from "react";\n\nconst useStyles = makeStyles({\n root: {\n display: "flex",\n gap: "8px",\n },\n});\n\n// Uses `useStyles` + `Button` \u2014 must NOT include `Spinner`.\nexport const Primary = () => {\n const styles = useStyles();\n return (\n
\n \n
\n );\n};\n'; + 'import { Button } from "@fluentui/react-components";\nimport * as React from "react";\n\n// Function-form story using imported `Button` only \u2014 `meta.component` (Button)\n// must not leak `Spinner` into other slices.\nexport const Primary = () => {\n return ;\n};\n'; Loading.parameters = {}; Loading.parameters.fullSource = - 'import { Spinner } from "@fluentui/react-components";\nimport * as React from "react";\n\n// Uses `Spinner` only \u2014 must NOT include `useStyles`/`makeStyles`/`Button`.\nexport const Loading = () => ;\n'; + 'import { Spinner } from "@fluentui/react-components";\nimport * as React from "react";\n\n// Function-form story using imported `Spinner` only \u2014 must NOT include `Button`/`ButtonRow`.\nexport const Loading = () => {\n return ;\n};\n'; Group.parameters = {}; Group.parameters.fullSource = - 'import { Button, makeStyles, Spinner } from "@fluentui/react-components";\nimport * as React from "react";\n\nconst useStyles = makeStyles({\n root: {\n display: "flex",\n gap: "8px",\n },\n});\n\n// Uses everything \u2014 the full slice.\nexport const Group = () => {\n const styles = useStyles();\n return (\n
\n \n \n
\n );\n};\n'; + 'import { Button } from "@fluentui/react-components";\nimport * as React from "react";\n\n// A module-scoped standard React component, used by the `Group` story only.\nconst ButtonRow = () => (\n <>\n \n \n \n);\n\n// Function-form story using the module-scoped `ButtonRow` component \u2014 must NOT include `Spinner`.\nexport const Group = () => {\n return ;\n};\n'; From 5869368c95897a25b0629544ea62262aaf8a9cd8 Mon Sep 17 00:00:00 2001 From: Martin Hochel Date: Thu, 16 Jul 2026 11:07:56 +0200 Subject: [PATCH 05/14] test(babel-preset-storybook-full-source): csf3-multi-story covers inline custom components Multiple custom React components (Card/CardHeader/CardMedia/CardFooter) in the module; CSF3 object stories each use a subset. Verifies each story's fullSource preserves only the inline custom components it references (Basic -> Card+CardHeader, WithMedia -> Card+CardMedia, Full -> all four). --- .../csf3-multi-story/code.js | 54 +++++---- .../csf3-multi-story/output.js | 105 ++++++++++++------ 2 files changed, 106 insertions(+), 53 deletions(-) diff --git a/packages/react-components/babel-preset-storybook-full-source/src/__fixtures__/storybook-stories-fullsource-per-story/csf3-multi-story/code.js b/packages/react-components/babel-preset-storybook-full-source/src/__fixtures__/storybook-stories-fullsource-per-story/csf3-multi-story/code.js index f8df7dd241cb3c..5cb7bce5e812f5 100644 --- a/packages/react-components/babel-preset-storybook-full-source/src/__fixtures__/storybook-stories-fullsource-per-story/csf3-multi-story/code.js +++ b/packages/react-components/babel-preset-storybook-full-source/src/__fixtures__/storybook-stories-fullsource-per-story/csf3-multi-story/code.js @@ -1,34 +1,48 @@ import * as React from 'react'; import { Button } from '@fluentui/react-button'; -import { Spinner } from '@fluentui/react-menu'; const meta = { - title: 'Button', + title: 'Card', component: Button, }; export default meta; -// A module-scoped standard React component, used by the `Group` story only. -const ButtonRow = () => ( - <> - - - -); - -// Function-form story using imported `Button` only — `meta.component` (Button) -// must not leak `Spinner` into other slices. -export const Primary = () => { - return ; +// --- Custom React components defined in the module --- +const Card = ({ children }) =>
{children}
; + +const CardHeader = ({ title }) =>

{title}

; + +const CardMedia = ({ src }) => ; + +const CardFooter = () =>
© Contoso
; + +// Uses Card + CardHeader + Button. +export const Basic = { + render: () => ( + + + + + ), }; -// Function-form story using imported `Spinner` only — must NOT include `Button`/`ButtonRow`. -export const Loading = () => { - return ; +// Uses Card + CardMedia only. +export const WithMedia = { + render: () => ( + + + + ), }; -// Function-form story using the module-scoped `ButtonRow` component — must NOT include `Spinner`. -export const Group = () => { - return ; +// Uses Card + CardHeader + CardMedia + CardFooter. +export const Full = { + render: () => ( + + + + + + ), }; diff --git a/packages/react-components/babel-preset-storybook-full-source/src/__fixtures__/storybook-stories-fullsource-per-story/csf3-multi-story/output.js b/packages/react-components/babel-preset-storybook-full-source/src/__fixtures__/storybook-stories-fullsource-per-story/csf3-multi-story/output.js index 44859729baf978..00b1ef7e2d08ce 100644 --- a/packages/react-components/babel-preset-storybook-full-source/src/__fixtures__/storybook-stories-fullsource-per-story/csf3-multi-story/output.js +++ b/packages/react-components/babel-preset-storybook-full-source/src/__fixtures__/storybook-stories-fullsource-per-story/csf3-multi-story/output.js @@ -1,50 +1,89 @@ import * as React from 'react'; import { Button } from '@fluentui/react-button'; -import { Spinner } from '@fluentui/react-menu'; const meta = { - title: 'Button', + title: 'Card', component: Button, }; export default meta; -// A module-scoped standard React component, used by the `Group` story only. -const ButtonRow = () => +// --- Custom React components defined in the module --- +const Card = ({ children }) => /*#__PURE__*/ React.createElement( - React.Fragment, - null, - /*#__PURE__*/ React.createElement(Button, null, 'One'), - /*#__PURE__*/ React.createElement(Button, null, 'Two'), + 'div', + { + className: 'card', + }, + children, ); - -// Function-form story using imported `Button` only — `meta.component` (Button) -// must not leak `Spinner` into other slices. -export const Primary = () => { - return /*#__PURE__*/ React.createElement( - Button, +const CardHeader = ({ title }) => + /*#__PURE__*/ React.createElement( + 'h3', { - appearance: 'primary', + className: 'card-header', }, - 'Primary', + title, ); +const CardMedia = ({ src }) => + /*#__PURE__*/ React.createElement('img', { + className: 'card-media', + src: src, + alt: '', + }); +const CardFooter = () => + /*#__PURE__*/ React.createElement( + 'footer', + { + className: 'card-footer', + }, + '\xA9 Contoso', + ); + +// Uses Card + CardHeader + Button. +export const Basic = { + render: () => + /*#__PURE__*/ React.createElement( + Card, + null, + /*#__PURE__*/ React.createElement(CardHeader, { + title: 'Basic', + }), + /*#__PURE__*/ React.createElement(Button, null, 'Action'), + ), }; -// Function-form story using imported `Spinner` only — must NOT include `Button`/`ButtonRow`. -export const Loading = () => { - return /*#__PURE__*/ React.createElement(Spinner, { - label: 'Loading', - }); +// Uses Card + CardMedia only. +export const WithMedia = { + render: () => + /*#__PURE__*/ React.createElement( + Card, + null, + /*#__PURE__*/ React.createElement(CardMedia, { + src: 'cat.png', + }), + ), }; -// Function-form story using the module-scoped `ButtonRow` component — must NOT include `Spinner`. -export const Group = () => { - return /*#__PURE__*/ React.createElement(ButtonRow, null); +// Uses Card + CardHeader + CardMedia + CardFooter. +export const Full = { + render: () => + /*#__PURE__*/ React.createElement( + Card, + null, + /*#__PURE__*/ React.createElement(CardHeader, { + title: 'Full', + }), + /*#__PURE__*/ React.createElement(CardMedia, { + src: 'dog.png', + }), + /*#__PURE__*/ React.createElement(CardFooter, null), + ), }; -Primary.parameters = {}; -Primary.parameters.fullSource = - 'import { Button } from "@fluentui/react-components";\nimport * as React from "react";\n\n// Function-form story using imported `Button` only \u2014 `meta.component` (Button)\n// must not leak `Spinner` into other slices.\nexport const Primary = () => {\n return ;\n};\n'; -Loading.parameters = {}; -Loading.parameters.fullSource = - 'import { Spinner } from "@fluentui/react-components";\nimport * as React from "react";\n\n// Function-form story using imported `Spinner` only \u2014 must NOT include `Button`/`ButtonRow`.\nexport const Loading = () => {\n return ;\n};\n'; -Group.parameters = {}; -Group.parameters.fullSource = - 'import { Button } from "@fluentui/react-components";\nimport * as React from "react";\n\n// A module-scoped standard React component, used by the `Group` story only.\nconst ButtonRow = () => (\n <>\n \n \n \n);\n\n// Function-form story using the module-scoped `ButtonRow` component \u2014 must NOT include `Spinner`.\nexport const Group = () => {\n return ;\n};\n'; +Basic.parameters = {}; +Basic.parameters.fullSource = + 'import { Button } from "@fluentui/react-components";\nimport * as React from "react";\n\n// --- Custom React components defined in the module ---\nconst Card = ({ children }) =>
{children}
;\n\nconst CardHeader = ({ title }) =>

{title}

;\n\n// Uses Card + CardHeader + Button.\nexport const Basic = () => (\n \n \n \n \n);\n'; +WithMedia.parameters = {}; +WithMedia.parameters.fullSource = + 'import * as React from "react";\n\n// --- Custom React components defined in the module ---\nconst Card = ({ children }) =>
{children}
;\n\nconst CardMedia = ({ src }) => ;\n\n// Uses Card + CardMedia only.\nexport const WithMedia = () => (\n \n \n \n);\n'; +Full.parameters = {}; +Full.parameters.fullSource = + 'import * as React from "react";\n\n// --- Custom React components defined in the module ---\nconst Card = ({ children }) =>
{children}
;\n\nconst CardHeader = ({ title }) =>

{title}

;\n\nconst CardMedia = ({ src }) => ;\n\nconst CardFooter = () =>
\xA9 Contoso
;\n\n// Uses Card + CardHeader + CardMedia + CardFooter.\nexport const Full = () => (\n \n \n \n \n \n);\n'; From 6ff2d85d6a36ad50bed31559648e0e4b942ee381 Mon Sep 17 00:00:00 2001 From: Martin Hochel Date: Thu, 16 Jul 2026 11:10:22 +0200 Subject: [PATCH 06/14] test(babel-preset-storybook-full-source): rename csf3-multi-story fixture to multi-story-csf3 Align naming with the sibling multi-story-csf2 fixture (same purpose, CSF3 variant). --- .../{csf3-multi-story => multi-story-csf3}/code.js | 0 .../{csf3-multi-story => multi-story-csf3}/output.js | 0 2 files changed, 0 insertions(+), 0 deletions(-) rename packages/react-components/babel-preset-storybook-full-source/src/__fixtures__/storybook-stories-fullsource-per-story/{csf3-multi-story => multi-story-csf3}/code.js (100%) rename packages/react-components/babel-preset-storybook-full-source/src/__fixtures__/storybook-stories-fullsource-per-story/{csf3-multi-story => multi-story-csf3}/output.js (100%) diff --git a/packages/react-components/babel-preset-storybook-full-source/src/__fixtures__/storybook-stories-fullsource-per-story/csf3-multi-story/code.js b/packages/react-components/babel-preset-storybook-full-source/src/__fixtures__/storybook-stories-fullsource-per-story/multi-story-csf3/code.js similarity index 100% rename from packages/react-components/babel-preset-storybook-full-source/src/__fixtures__/storybook-stories-fullsource-per-story/csf3-multi-story/code.js rename to packages/react-components/babel-preset-storybook-full-source/src/__fixtures__/storybook-stories-fullsource-per-story/multi-story-csf3/code.js diff --git a/packages/react-components/babel-preset-storybook-full-source/src/__fixtures__/storybook-stories-fullsource-per-story/csf3-multi-story/output.js b/packages/react-components/babel-preset-storybook-full-source/src/__fixtures__/storybook-stories-fullsource-per-story/multi-story-csf3/output.js similarity index 100% rename from packages/react-components/babel-preset-storybook-full-source/src/__fixtures__/storybook-stories-fullsource-per-story/csf3-multi-story/output.js rename to packages/react-components/babel-preset-storybook-full-source/src/__fixtures__/storybook-stories-fullsource-per-story/multi-story-csf3/output.js From 2d6c1b6bc352b0a6552c0c0e6543dd6a897be1a1 Mon Sep 17 00:00:00 2001 From: Martin Hochel Date: Thu, 16 Jul 2026 11:14:23 +0200 Subject: [PATCH 07/14] test(babel-preset-storybook-full-source): add multi-story-typescript fixture for type preservation Verifies per-story slicing preserves/prunes TypeScript types: type aliases, interfaces, type imports, generics (React.FC) and indexed-access types (ButtonProps['appearance']). Simple keeps CardProps; WithAction keeps ActionProps + ButtonProps; neither leaks the other's types. Adds @babel/preset-typescript to the per-story test preset to parse the .tsx fixture. --- .../multi-story-typescript/code.tsx | 45 ++++++++++++++++++ .../multi-story-typescript/output.tsx | 47 +++++++++++++++++++ .../src/fullsource.test.ts | 2 +- 3 files changed, 93 insertions(+), 1 deletion(-) create mode 100644 packages/react-components/babel-preset-storybook-full-source/src/__fixtures__/storybook-stories-fullsource-per-story/multi-story-typescript/code.tsx create mode 100644 packages/react-components/babel-preset-storybook-full-source/src/__fixtures__/storybook-stories-fullsource-per-story/multi-story-typescript/output.tsx diff --git a/packages/react-components/babel-preset-storybook-full-source/src/__fixtures__/storybook-stories-fullsource-per-story/multi-story-typescript/code.tsx b/packages/react-components/babel-preset-storybook-full-source/src/__fixtures__/storybook-stories-fullsource-per-story/multi-story-typescript/code.tsx new file mode 100644 index 00000000000000..2d1f7784be12d4 --- /dev/null +++ b/packages/react-components/babel-preset-storybook-full-source/src/__fixtures__/storybook-stories-fullsource-per-story/multi-story-typescript/code.tsx @@ -0,0 +1,45 @@ +import * as React from 'react'; +import { Button } from '@fluentui/react-button'; +import type { ButtonProps } from '@fluentui/react-button'; + +const meta = { + title: 'Card', + component: Button, +}; + +export default meta; + +// --- Custom components with TypeScript types --- + +type CardProps = { + title: string; + children: React.ReactNode; +}; + +const Card: React.FC = ({ title, children }) => ( +
+

{title}

+ {children} +
+); + +interface ActionProps { + label: string; + appearance?: ButtonProps['appearance']; +} + +const Action: React.FC = ({ label, appearance }) => ; + +// Uses `Card` (+ `CardProps`) only — must NOT include `Action`/`ActionProps`/`ButtonProps`. +export const Simple = { + render: () => ( + +

Content

+
+ ), +}; + +// Uses `Action` (+ `ActionProps` + `ButtonProps` type import) — must NOT include `Card`/`CardProps`. +export const WithAction = { + render: () => , +}; diff --git a/packages/react-components/babel-preset-storybook-full-source/src/__fixtures__/storybook-stories-fullsource-per-story/multi-story-typescript/output.tsx b/packages/react-components/babel-preset-storybook-full-source/src/__fixtures__/storybook-stories-fullsource-per-story/multi-story-typescript/output.tsx new file mode 100644 index 00000000000000..76c36d526d65c7 --- /dev/null +++ b/packages/react-components/babel-preset-storybook-full-source/src/__fixtures__/storybook-stories-fullsource-per-story/multi-story-typescript/output.tsx @@ -0,0 +1,47 @@ +import * as React from 'react'; +import { Button } from '@fluentui/react-button'; +const meta = { + title: 'Card', + component: Button, +}; +export default meta; + +// --- Custom components with TypeScript types --- + +const Card = ({ title, children }) => + /*#__PURE__*/ React.createElement('section', null, /*#__PURE__*/ React.createElement('h3', null, title), children); +const Action = ({ label, appearance }) => + /*#__PURE__*/ React.createElement( + Button, + { + appearance: appearance, + }, + label, + ); + +// Uses `Card` (+ `CardProps`) only — must NOT include `Action`/`ActionProps`/`ButtonProps`. +export const Simple = { + render: () => + /*#__PURE__*/ React.createElement( + Card, + { + title: 'Simple', + }, + /*#__PURE__*/ React.createElement('p', null, 'Content'), + ), +}; + +// Uses `Action` (+ `ActionProps` + `ButtonProps` type import) — must NOT include `Card`/`CardProps`. +export const WithAction = { + render: () => + /*#__PURE__*/ React.createElement(Action, { + label: 'Go', + appearance: 'primary', + }), +}; +Simple.parameters = {}; +Simple.parameters.fullSource = + 'import * as React from "react";\n\n// --- Custom components with TypeScript types ---\n\ntype CardProps = {\n title: string;\n children: React.ReactNode;\n};\n\nconst Card: React.FC = ({ title, children }) => (\n
\n

{title}

\n {children}\n
\n);\n\n// Uses `Card` (+ `CardProps`) only \u2014 must NOT include `Action`/`ActionProps`/`ButtonProps`.\nexport const Simple = () => (\n \n

Content

\n
\n);\n'; +WithAction.parameters = {}; +WithAction.parameters.fullSource = + 'import { Button, ButtonProps } from "@fluentui/react-components";\nimport * as React from "react";\n\ninterface ActionProps {\n label: string;\n appearance?: ButtonProps["appearance"];\n}\n\nconst Action: React.FC = ({ label, appearance }) => (\n \n);\n\n// Uses `Action` (+ `ActionProps` + `ButtonProps` type import) \u2014 must NOT include `Card`/`CardProps`.\nexport const WithAction = () => ;\n'; diff --git a/packages/react-components/babel-preset-storybook-full-source/src/fullsource.test.ts b/packages/react-components/babel-preset-storybook-full-source/src/fullsource.test.ts index 906d5ddec7e72a..a3958961de9435 100644 --- a/packages/react-components/babel-preset-storybook-full-source/src/fullsource.test.ts +++ b/packages/react-components/babel-preset-storybook-full-source/src/fullsource.test.ts @@ -45,7 +45,7 @@ pluginTester({ // Per-story granularity: each story export gets its own sliced `fullSource`. pluginTester({ babelOptions: { - presets: ['@babel/preset-react'], + presets: ['@babel/preset-react', '@babel/preset-typescript'], }, fixtures: path.join(__dirname, '__fixtures__/storybook-stories-fullsource-per-story'), pluginOptions: { From a8030f334649287104eb9b8c67ab5a774d9c1eab Mon Sep 17 00:00:00 2001 From: Martin Hochel Date: Thu, 16 Jul 2026 11:19:05 +0200 Subject: [PATCH 08/14] build(babel-preset-storybook-full-source): exclude __fixtures__ from lib/spec tsconfigs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixture code/output files (now including .tsx) are test inputs, not source — exclude them from type-checking to avoid build failures. --- .../babel-preset-storybook-full-source/tsconfig.lib.json | 9 ++++++++- .../tsconfig.spec.json | 3 ++- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/packages/react-components/babel-preset-storybook-full-source/tsconfig.lib.json b/packages/react-components/babel-preset-storybook-full-source/tsconfig.lib.json index 9bf4a69e7084fc..30faba083c73eb 100644 --- a/packages/react-components/babel-preset-storybook-full-source/tsconfig.lib.json +++ b/packages/react-components/babel-preset-storybook-full-source/tsconfig.lib.json @@ -10,6 +10,13 @@ "types": ["static-assets", "environment", "node"], "module": "CommonJS" }, - "exclude": ["**/*.spec.ts", "**/*.spec.tsx", "**/*.test.ts", "**/*.test.tsx", "src/index.dev.d.ts"], + "exclude": [ + "**/*.spec.ts", + "**/*.spec.tsx", + "**/*.test.ts", + "**/*.test.tsx", + "src/index.dev.d.ts", + "**/__fixtures__/**" + ], "include": ["./src/**/*.ts", "./src/**/*.tsx"] } diff --git a/packages/react-components/babel-preset-storybook-full-source/tsconfig.spec.json b/packages/react-components/babel-preset-storybook-full-source/tsconfig.spec.json index 469fcba4d7ba75..176c1638b24c21 100644 --- a/packages/react-components/babel-preset-storybook-full-source/tsconfig.spec.json +++ b/packages/react-components/babel-preset-storybook-full-source/tsconfig.spec.json @@ -5,5 +5,6 @@ "outDir": "dist", "types": ["jest", "node"] }, - "include": ["**/*.spec.ts", "**/*.spec.tsx", "**/*.test.ts", "**/*.test.tsx", "**/*.d.ts"] + "include": ["**/*.spec.ts", "**/*.spec.tsx", "**/*.test.ts", "**/*.test.tsx", "**/*.d.ts"], + "exclude": ["**/__fixtures__/**"] } From faa5f14267d07cd8d174b59cb06def4864b1c7e7 Mon Sep 17 00:00:00 2001 From: Martin Hochel Date: Thu, 16 Jul 2026 11:27:25 +0200 Subject: [PATCH 09/14] fix(babel-preset-storybook-full-source): preserve import type modifier in merged imports MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - modifyImports now tracks type-only specifiers (import type / inline type) and re-emits them as a separate 'import type { … }' from the mapped package, instead of merging them into a value import (which breaks under verbatimModuleSyntax/isolatedModules). - Per-story test parses TS via parserOpts (jsx + typescript) instead of @babel/preset-typescript, so the emitted module retains its types — demonstrating the preset is pass-through and does not strip types from source. --- .../multi-story-typescript/output.tsx | 15 ++++++++-- .../src/fullsource.test.ts | 5 +++- .../src/modifyImports.ts | 28 +++++++++++++------ 3 files changed, 35 insertions(+), 13 deletions(-) diff --git a/packages/react-components/babel-preset-storybook-full-source/src/__fixtures__/storybook-stories-fullsource-per-story/multi-story-typescript/output.tsx b/packages/react-components/babel-preset-storybook-full-source/src/__fixtures__/storybook-stories-fullsource-per-story/multi-story-typescript/output.tsx index 76c36d526d65c7..653c2c72c5deaa 100644 --- a/packages/react-components/babel-preset-storybook-full-source/src/__fixtures__/storybook-stories-fullsource-per-story/multi-story-typescript/output.tsx +++ b/packages/react-components/babel-preset-storybook-full-source/src/__fixtures__/storybook-stories-fullsource-per-story/multi-story-typescript/output.tsx @@ -1,5 +1,6 @@ import * as React from 'react'; import { Button } from '@fluentui/react-button'; +import type { ButtonProps } from '@fluentui/react-button'; const meta = { title: 'Card', component: Button, @@ -8,9 +9,17 @@ export default meta; // --- Custom components with TypeScript types --- -const Card = ({ title, children }) => +type CardProps = { + title: string; + children: React.ReactNode; +}; +const Card: React.FC = ({ title, children }) => /*#__PURE__*/ React.createElement('section', null, /*#__PURE__*/ React.createElement('h3', null, title), children); -const Action = ({ label, appearance }) => +interface ActionProps { + label: string; + appearance?: ButtonProps['appearance']; +} +const Action: React.FC = ({ label, appearance }) => /*#__PURE__*/ React.createElement( Button, { @@ -44,4 +53,4 @@ Simple.parameters.fullSource = 'import * as React from "react";\n\n// --- Custom components with TypeScript types ---\n\ntype CardProps = {\n title: string;\n children: React.ReactNode;\n};\n\nconst Card: React.FC = ({ title, children }) => (\n
\n

{title}

\n {children}\n
\n);\n\n// Uses `Card` (+ `CardProps`) only \u2014 must NOT include `Action`/`ActionProps`/`ButtonProps`.\nexport const Simple = () => (\n \n

Content

\n
\n);\n'; WithAction.parameters = {}; WithAction.parameters.fullSource = - 'import { Button, ButtonProps } from "@fluentui/react-components";\nimport * as React from "react";\n\ninterface ActionProps {\n label: string;\n appearance?: ButtonProps["appearance"];\n}\n\nconst Action: React.FC = ({ label, appearance }) => (\n \n);\n\n// Uses `Action` (+ `ActionProps` + `ButtonProps` type import) \u2014 must NOT include `Card`/`CardProps`.\nexport const WithAction = () => ;\n'; + 'import { Button } from "@fluentui/react-components";\nimport type { ButtonProps } from "@fluentui/react-components";\nimport * as React from "react";\n\ninterface ActionProps {\n label: string;\n appearance?: ButtonProps["appearance"];\n}\n\nconst Action: React.FC = ({ label, appearance }) => (\n \n);\n\n// Uses `Action` (+ `ActionProps` + `ButtonProps` type import) \u2014 must NOT include `Card`/`CardProps`.\nexport const WithAction = () => ;\n'; diff --git a/packages/react-components/babel-preset-storybook-full-source/src/fullsource.test.ts b/packages/react-components/babel-preset-storybook-full-source/src/fullsource.test.ts index a3958961de9435..c102b7d31aca35 100644 --- a/packages/react-components/babel-preset-storybook-full-source/src/fullsource.test.ts +++ b/packages/react-components/babel-preset-storybook-full-source/src/fullsource.test.ts @@ -45,7 +45,10 @@ pluginTester({ // Per-story granularity: each story export gets its own sliced `fullSource`. pluginTester({ babelOptions: { - presets: ['@babel/preset-react', '@babel/preset-typescript'], + presets: ['@babel/preset-react'], + // Parse TS syntax without stripping types — the preset is pass-through and + // must not remove types from the emitted module (only JSX is compiled here). + parserOpts: { plugins: ['jsx', 'typescript'] }, }, fixtures: path.join(__dirname, '__fixtures__/storybook-stories-fullsource-per-story'), pluginOptions: { diff --git a/packages/react-components/babel-preset-storybook-full-source/src/modifyImports.ts b/packages/react-components/babel-preset-storybook-full-source/src/modifyImports.ts index 03f8348eb73726..61675d0bce72aa 100644 --- a/packages/react-components/babel-preset-storybook-full-source/src/modifyImports.ts +++ b/packages/react-components/babel-preset-storybook-full-source/src/modifyImports.ts @@ -4,7 +4,7 @@ import * as fs from 'fs'; import { BabelPluginOptions } from './types'; interface PluginState extends Babel.PluginPass { - imports: Record; + imports: Record; } export const PLUGIN_NAME = 'storybook-stories-modifyImports'; @@ -30,20 +30,26 @@ export function modifyImportsPlugin(babel: typeof Babel, options: BabelPluginOpt }, pre() { this.imports = Object.keys(importMappings).reduce((acc, cur) => { - acc[importMappings[cur].replace] = []; + acc[importMappings[cur].replace] = { value: [], type: [] }; return acc; }, {} as PluginState['imports']); }, visitor: { Program: { exit(path, pluginState) { - Object.entries(pluginState.imports).forEach(([depName, importSpecifiers]) => { - const specifiers = importSpecifiers.map(importSpecifier => - t.importSpecifier(t.identifier(importSpecifier), t.identifier(importSpecifier)), - ); + Object.entries(pluginState.imports).forEach(([depName, { value, type }]) => { + // Emit a `import type { … }` declaration first so type-only imports + // keep their `type` modifier (required under `verbatimModuleSyntax`). + if (type.length) { + const typeSpecifiers = type.map(name => t.importSpecifier(t.identifier(name), t.identifier(name))); + const typeDeclaration = t.importDeclaration(typeSpecifiers, t.stringLiteral(depName)); + typeDeclaration.importKind = 'type'; + path.node.body.unshift(typeDeclaration); + } - if (specifiers.length) { - path.node.body.unshift(t.importDeclaration(specifiers, t.stringLiteral(depName))); + if (value.length) { + const valueSpecifiers = value.map(name => t.importSpecifier(t.identifier(name), t.identifier(name))); + path.node.body.unshift(t.importDeclaration(valueSpecifiers, t.stringLiteral(depName))); } }); }, @@ -91,13 +97,17 @@ export function modifyImportsPlugin(babel: typeof Babel, options: BabelPluginOpt } if (t.isLiteral(path.node.source) && importMappings[importSource.value]) { + const declarationIsTypeOnly = path.node.importKind === 'type'; + const target = pluginState.imports[importMappings[importSource.value].replace]; + path.node.specifiers.forEach(specifier => { if ( t.isImportSpecifier(specifier) && t.isIdentifier(specifier.imported) && t.isIdentifier(specifier.local) ) { - pluginState.imports[importMappings[importSource.value].replace].push(specifier.imported.name); + const isTypeOnly = declarationIsTypeOnly || specifier.importKind === 'type'; + (isTypeOnly ? target.type : target.value).push(specifier.imported.name); } }); From 7ca21c624589240463a2af548ee5d9174281f957 Mon Sep 17 00:00:00 2001 From: Martin Hochel Date: Thu, 16 Jul 2026 13:00:48 +0200 Subject: [PATCH 10/14] test(babel-preset-storybook-full-source): cover import type handling in modifyImports MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add type-import-declaration fixture asserting that import type { X } and inline import { type X } from mapped packages are merged into a single 'import type { … }' (value and type imports kept separate). Refresh multi-story-typescript output. --- .../type-import-declaration/code.js | 5 +++++ .../type-import-declaration/output.js | 6 ++++++ 2 files changed, 11 insertions(+) create mode 100644 packages/react-components/babel-preset-storybook-full-source/src/__fixtures__/storybook-stories-modifyImports/type-import-declaration/code.js create mode 100644 packages/react-components/babel-preset-storybook-full-source/src/__fixtures__/storybook-stories-modifyImports/type-import-declaration/output.js diff --git a/packages/react-components/babel-preset-storybook-full-source/src/__fixtures__/storybook-stories-modifyImports/type-import-declaration/code.js b/packages/react-components/babel-preset-storybook-full-source/src/__fixtures__/storybook-stories-modifyImports/type-import-declaration/code.js new file mode 100644 index 00000000000000..a7e2ecd7a9696e --- /dev/null +++ b/packages/react-components/babel-preset-storybook-full-source/src/__fixtures__/storybook-stories-modifyImports/type-import-declaration/code.js @@ -0,0 +1,5 @@ +import { Button } from '@fluentui/react-button'; +import type { ButtonProps } from '@fluentui/react-button'; +import { Menu, type MenuProps } from '@fluentui/react-menu'; + +export const config: ButtonProps & MenuProps = { component: Button, menu: Menu }; diff --git a/packages/react-components/babel-preset-storybook-full-source/src/__fixtures__/storybook-stories-modifyImports/type-import-declaration/output.js b/packages/react-components/babel-preset-storybook-full-source/src/__fixtures__/storybook-stories-modifyImports/type-import-declaration/output.js new file mode 100644 index 00000000000000..3b8344ad4216e0 --- /dev/null +++ b/packages/react-components/babel-preset-storybook-full-source/src/__fixtures__/storybook-stories-modifyImports/type-import-declaration/output.js @@ -0,0 +1,6 @@ +import { Button, Menu } from '@fluentui/react-components'; +import type { ButtonProps, MenuProps } from '@fluentui/react-components'; +export const config: ButtonProps & MenuProps = { + component: Button, + menu: Menu, +}; From 237a1a495927462d2155213801f57875fe1943c6 Mon Sep 17 00:00:00 2001 From: Martin Hochel Date: Thu, 16 Jul 2026 13:04:30 +0200 Subject: [PATCH 11/14] test(babel-preset-storybook-full-source): make multi-story-typescript deterministic The outer-compiled fixture section printed TS type-member separators non-deterministically (, vs ;) when parsing-without-stripping types. Compile the fixture with @babel/preset-typescript (types stripped in the module, as in a real Storybook build); type preservation remains asserted on the deterministic, prettier-normalized fullSource strings. --- .../multi-story-typescript/output.tsx | 13 ++----------- .../src/fullsource.test.ts | 8 ++++---- 2 files changed, 6 insertions(+), 15 deletions(-) diff --git a/packages/react-components/babel-preset-storybook-full-source/src/__fixtures__/storybook-stories-fullsource-per-story/multi-story-typescript/output.tsx b/packages/react-components/babel-preset-storybook-full-source/src/__fixtures__/storybook-stories-fullsource-per-story/multi-story-typescript/output.tsx index 653c2c72c5deaa..76bab1ae9498ba 100644 --- a/packages/react-components/babel-preset-storybook-full-source/src/__fixtures__/storybook-stories-fullsource-per-story/multi-story-typescript/output.tsx +++ b/packages/react-components/babel-preset-storybook-full-source/src/__fixtures__/storybook-stories-fullsource-per-story/multi-story-typescript/output.tsx @@ -1,6 +1,5 @@ import * as React from 'react'; import { Button } from '@fluentui/react-button'; -import type { ButtonProps } from '@fluentui/react-button'; const meta = { title: 'Card', component: Button, @@ -9,17 +8,9 @@ export default meta; // --- Custom components with TypeScript types --- -type CardProps = { - title: string; - children: React.ReactNode; -}; -const Card: React.FC = ({ title, children }) => +const Card = ({ title, children }) => /*#__PURE__*/ React.createElement('section', null, /*#__PURE__*/ React.createElement('h3', null, title), children); -interface ActionProps { - label: string; - appearance?: ButtonProps['appearance']; -} -const Action: React.FC = ({ label, appearance }) => +const Action = ({ label, appearance }) => /*#__PURE__*/ React.createElement( Button, { diff --git a/packages/react-components/babel-preset-storybook-full-source/src/fullsource.test.ts b/packages/react-components/babel-preset-storybook-full-source/src/fullsource.test.ts index c102b7d31aca35..9e5da840b779f9 100644 --- a/packages/react-components/babel-preset-storybook-full-source/src/fullsource.test.ts +++ b/packages/react-components/babel-preset-storybook-full-source/src/fullsource.test.ts @@ -45,10 +45,10 @@ pluginTester({ // Per-story granularity: each story export gets its own sliced `fullSource`. pluginTester({ babelOptions: { - presets: ['@babel/preset-react'], - // Parse TS syntax without stripping types — the preset is pass-through and - // must not remove types from the emitted module (only JSX is compiled here). - parserOpts: { plugins: ['jsx', 'typescript'] }, + // Compile the fixtures like a real Storybook build (JSX + TS stripped). Type + // preservation is asserted on the emitted `fullSource` strings, which come + // from the plugin's own transform and keep types. + presets: ['@babel/preset-react', '@babel/preset-typescript'], }, fixtures: path.join(__dirname, '__fixtures__/storybook-stories-fullsource-per-story'), pluginOptions: { From a4fc841231d2b3668ceef55dcd5f65b9fa1329ed Mon Sep 17 00:00:00 2001 From: Martin Hochel Date: Thu, 16 Jul 2026 13:12:30 +0200 Subject: [PATCH 12/14] revert(babel-preset-storybook-full-source): drop out-of-scope import type modifier change MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Revert the modifyImports import-type preservation change and its fixture — out of scope for the per-story fullSource branch. The multi-story-typescript fixture stays (tests per-story TS type reachability) with the pre-existing merged-import behavior. --- .../multi-story-typescript/output.tsx | 2 +- .../type-import-declaration/code.js | 5 ---- .../type-import-declaration/output.js | 6 ---- .../src/modifyImports.ts | 28 ++++++------------- 4 files changed, 10 insertions(+), 31 deletions(-) delete mode 100644 packages/react-components/babel-preset-storybook-full-source/src/__fixtures__/storybook-stories-modifyImports/type-import-declaration/code.js delete mode 100644 packages/react-components/babel-preset-storybook-full-source/src/__fixtures__/storybook-stories-modifyImports/type-import-declaration/output.js diff --git a/packages/react-components/babel-preset-storybook-full-source/src/__fixtures__/storybook-stories-fullsource-per-story/multi-story-typescript/output.tsx b/packages/react-components/babel-preset-storybook-full-source/src/__fixtures__/storybook-stories-fullsource-per-story/multi-story-typescript/output.tsx index 76bab1ae9498ba..76c36d526d65c7 100644 --- a/packages/react-components/babel-preset-storybook-full-source/src/__fixtures__/storybook-stories-fullsource-per-story/multi-story-typescript/output.tsx +++ b/packages/react-components/babel-preset-storybook-full-source/src/__fixtures__/storybook-stories-fullsource-per-story/multi-story-typescript/output.tsx @@ -44,4 +44,4 @@ Simple.parameters.fullSource = 'import * as React from "react";\n\n// --- Custom components with TypeScript types ---\n\ntype CardProps = {\n title: string;\n children: React.ReactNode;\n};\n\nconst Card: React.FC = ({ title, children }) => (\n
\n

{title}

\n {children}\n
\n);\n\n// Uses `Card` (+ `CardProps`) only \u2014 must NOT include `Action`/`ActionProps`/`ButtonProps`.\nexport const Simple = () => (\n \n

Content

\n
\n);\n'; WithAction.parameters = {}; WithAction.parameters.fullSource = - 'import { Button } from "@fluentui/react-components";\nimport type { ButtonProps } from "@fluentui/react-components";\nimport * as React from "react";\n\ninterface ActionProps {\n label: string;\n appearance?: ButtonProps["appearance"];\n}\n\nconst Action: React.FC = ({ label, appearance }) => (\n \n);\n\n// Uses `Action` (+ `ActionProps` + `ButtonProps` type import) \u2014 must NOT include `Card`/`CardProps`.\nexport const WithAction = () => ;\n'; + 'import { Button, ButtonProps } from "@fluentui/react-components";\nimport * as React from "react";\n\ninterface ActionProps {\n label: string;\n appearance?: ButtonProps["appearance"];\n}\n\nconst Action: React.FC = ({ label, appearance }) => (\n \n);\n\n// Uses `Action` (+ `ActionProps` + `ButtonProps` type import) \u2014 must NOT include `Card`/`CardProps`.\nexport const WithAction = () => ;\n'; diff --git a/packages/react-components/babel-preset-storybook-full-source/src/__fixtures__/storybook-stories-modifyImports/type-import-declaration/code.js b/packages/react-components/babel-preset-storybook-full-source/src/__fixtures__/storybook-stories-modifyImports/type-import-declaration/code.js deleted file mode 100644 index a7e2ecd7a9696e..00000000000000 --- a/packages/react-components/babel-preset-storybook-full-source/src/__fixtures__/storybook-stories-modifyImports/type-import-declaration/code.js +++ /dev/null @@ -1,5 +0,0 @@ -import { Button } from '@fluentui/react-button'; -import type { ButtonProps } from '@fluentui/react-button'; -import { Menu, type MenuProps } from '@fluentui/react-menu'; - -export const config: ButtonProps & MenuProps = { component: Button, menu: Menu }; diff --git a/packages/react-components/babel-preset-storybook-full-source/src/__fixtures__/storybook-stories-modifyImports/type-import-declaration/output.js b/packages/react-components/babel-preset-storybook-full-source/src/__fixtures__/storybook-stories-modifyImports/type-import-declaration/output.js deleted file mode 100644 index 3b8344ad4216e0..00000000000000 --- a/packages/react-components/babel-preset-storybook-full-source/src/__fixtures__/storybook-stories-modifyImports/type-import-declaration/output.js +++ /dev/null @@ -1,6 +0,0 @@ -import { Button, Menu } from '@fluentui/react-components'; -import type { ButtonProps, MenuProps } from '@fluentui/react-components'; -export const config: ButtonProps & MenuProps = { - component: Button, - menu: Menu, -}; diff --git a/packages/react-components/babel-preset-storybook-full-source/src/modifyImports.ts b/packages/react-components/babel-preset-storybook-full-source/src/modifyImports.ts index 61675d0bce72aa..03f8348eb73726 100644 --- a/packages/react-components/babel-preset-storybook-full-source/src/modifyImports.ts +++ b/packages/react-components/babel-preset-storybook-full-source/src/modifyImports.ts @@ -4,7 +4,7 @@ import * as fs from 'fs'; import { BabelPluginOptions } from './types'; interface PluginState extends Babel.PluginPass { - imports: Record; + imports: Record; } export const PLUGIN_NAME = 'storybook-stories-modifyImports'; @@ -30,26 +30,20 @@ export function modifyImportsPlugin(babel: typeof Babel, options: BabelPluginOpt }, pre() { this.imports = Object.keys(importMappings).reduce((acc, cur) => { - acc[importMappings[cur].replace] = { value: [], type: [] }; + acc[importMappings[cur].replace] = []; return acc; }, {} as PluginState['imports']); }, visitor: { Program: { exit(path, pluginState) { - Object.entries(pluginState.imports).forEach(([depName, { value, type }]) => { - // Emit a `import type { … }` declaration first so type-only imports - // keep their `type` modifier (required under `verbatimModuleSyntax`). - if (type.length) { - const typeSpecifiers = type.map(name => t.importSpecifier(t.identifier(name), t.identifier(name))); - const typeDeclaration = t.importDeclaration(typeSpecifiers, t.stringLiteral(depName)); - typeDeclaration.importKind = 'type'; - path.node.body.unshift(typeDeclaration); - } + Object.entries(pluginState.imports).forEach(([depName, importSpecifiers]) => { + const specifiers = importSpecifiers.map(importSpecifier => + t.importSpecifier(t.identifier(importSpecifier), t.identifier(importSpecifier)), + ); - if (value.length) { - const valueSpecifiers = value.map(name => t.importSpecifier(t.identifier(name), t.identifier(name))); - path.node.body.unshift(t.importDeclaration(valueSpecifiers, t.stringLiteral(depName))); + if (specifiers.length) { + path.node.body.unshift(t.importDeclaration(specifiers, t.stringLiteral(depName))); } }); }, @@ -97,17 +91,13 @@ export function modifyImportsPlugin(babel: typeof Babel, options: BabelPluginOpt } if (t.isLiteral(path.node.source) && importMappings[importSource.value]) { - const declarationIsTypeOnly = path.node.importKind === 'type'; - const target = pluginState.imports[importMappings[importSource.value].replace]; - path.node.specifiers.forEach(specifier => { if ( t.isImportSpecifier(specifier) && t.isIdentifier(specifier.imported) && t.isIdentifier(specifier.local) ) { - const isTypeOnly = declarationIsTypeOnly || specifier.importKind === 'type'; - (isTypeOnly ? target.type : target.value).push(specifier.imported.name); + pluginState.imports[importMappings[importSource.value].replace].push(specifier.imported.name); } }); From c7ba34fbf5209cf2758ab2f27f9ee65251e91d42 Mon Sep 17 00:00:00 2001 From: Martin Hochel Date: Thu, 16 Jul 2026 13:30:17 +0200 Subject: [PATCH 13/14] refactor(babel-preset-storybook-full-source): DRY Program.exit emit logic Extract buildFullSource() (transform + prettier) and emitStorySource() (parameters + fullSource + cssModuleSources) helpers shared by both granularity branches. Collect CSS module imports once (file-level, was re-scanned per story). Unify the has-parameters check on storiesWithParameters and drop the redundant parametersAssignment tracking. No output change (all fixtures unchanged). --- .../src/fullsource.ts | 89 +++++++------------ 1 file changed, 33 insertions(+), 56 deletions(-) diff --git a/packages/react-components/babel-preset-storybook-full-source/src/fullsource.ts b/packages/react-components/babel-preset-storybook-full-source/src/fullsource.ts index 817367ea1028f1..e0221d52b16fb0 100644 --- a/packages/react-components/babel-preset-storybook-full-source/src/fullsource.ts +++ b/packages/react-components/babel-preset-storybook-full-source/src/fullsource.ts @@ -30,7 +30,6 @@ export function fullSourcePlugin(babel: typeof Babel, options: BabelPluginOption const granularity = options.storyGranularity ?? 'file'; let storyName: string; - let parametersAssignment: Babel.NodePath | undefined; // All component-like story exports in document order (used by 'story' mode). let storyNames: string[] = []; // Story names that already have a `.parameters = …` assignment. @@ -146,15 +145,11 @@ export function fullSourcePlugin(babel: typeof Babel, options: BabelPluginOption path.node.left.property.name === 'parameters' ) { storiesWithParameters.add(path.node.left.object.name); - if (path.node.left.object.name === storyName) { - parametersAssignment = path; - } } }, Program: { enter() { storyName = ''; - parametersAssignment = undefined; storyNames = []; storiesWithParameters.clear(); }, @@ -168,6 +163,37 @@ export function fullSourcePlugin(babel: typeof Babel, options: BabelPluginOption cssModulesEnabled && cssModulesConfig?.tokensFilePath ? fs.readFileSync(cssModulesConfig.tokensFilePath, 'utf-8') : undefined; + // Auto-detected CSS module imports are file-level (identical for every + // story), so collect them once and reuse for each emitted story. + const cssModules = cssModulesEnabled ? collectCssModuleImports(path, t, state.filename) : []; + + // Runs the shared modify-imports + prettier pipeline over a source string. + const buildFullSource = (source: string): string => { + const transformed = babel.transformSync(source, { + ...state.file.opts, + compact: false, + retainLines: true, + comments: false, + plugins: [[modifyImportsPlugin, options], removeStorybookParameters], + })?.code; + + return prettier.format(transformed ?? '', { parser: 'babel-ts' }); + }; + + // Emits `.parameters` (when missing), `.fullSource` and, when + // enabled, `.cssModuleSources` for a single story. + const emitStorySource = (currentStory: string, code: string): void => { + if (!storiesWithParameters.has(currentStory)) { + path.pushContainer('body', createStoryParametersAssignmentExpression(currentStory)); + storiesWithParameters.add(currentStory); + } + + path.pushContainer('body', createFullSourceAssignmentExpression(currentStory, code)); + + if (cssModulesEnabled && (cssModules.length > 0 || tokensSource)) { + path.pushContainer('body', createCssModuleSourcesAssignment(currentStory, { cssModules, tokensSource })); + } + }; if (granularity === 'story') { for (const currentStory of storyNames) { @@ -180,32 +206,7 @@ export function fullSourcePlugin(babel: typeof Babel, options: BabelPluginOption continue; } - const transformedStory = babel.transformSync(sliced, { - ...state.file.opts, - compact: false, - retainLines: true, - comments: false, - plugins: [[modifyImportsPlugin, options], removeStorybookParameters], - })?.code; - - const storyCode = prettier.format(transformedStory ?? '', { parser: 'babel-ts' }); - - if (!storiesWithParameters.has(currentStory)) { - path.pushContainer('body', createStoryParametersAssignmentExpression(currentStory)); - storiesWithParameters.add(currentStory); - } - - path.pushContainer('body', createFullSourceAssignmentExpression(currentStory, storyCode)); - - if (cssModulesEnabled) { - const cssModules = collectCssModuleImports(path, t, state.filename); - if (cssModules.length > 0 || tokensSource) { - path.pushContainer( - 'body', - createCssModuleSourcesAssignment(currentStory, { cssModules, tokensSource }), - ); - } - } + emitStorySource(currentStory, buildFullSource(sliced)); } return; @@ -216,31 +217,7 @@ export function fullSourcePlugin(babel: typeof Babel, options: BabelPluginOption return; } - const transformedCode = babel.transformSync(fileContents, { - ...state.file.opts, - compact: false, - retainLines: true, - comments: false, - plugins: [[modifyImportsPlugin, options], removeStorybookParameters], - })?.code; - - const code = prettier.format(transformedCode ?? '', { parser: 'babel-ts' }); - - if (!parametersAssignment) { - path.pushContainer('body', createStoryParametersAssignmentExpression(storyName)); - } - - path.pushContainer('body', createFullSourceAssignmentExpression(storyName, code)); - - // Auto-detect CSS module imports and inject their source as parameters. - // This removes the need for manual `?raw` imports + `withCssModuleSource()` calls. - if (cssModulesEnabled) { - const cssModules = collectCssModuleImports(path, t, state.filename); - - if (cssModules.length > 0 || tokensSource) { - path.pushContainer('body', createCssModuleSourcesAssignment(storyName, { cssModules, tokensSource })); - } - } + emitStorySource(storyName, buildFullSource(fileContents)); }, }, }, From 76b3d672c0b4af0c5a3b0c9087fbb45feb7f5e66 Mon Sep 17 00:00:00 2001 From: Martin Hochel Date: Thu, 16 Jul 2026 13:35:56 +0200 Subject: [PATCH 14/14] test(babel-preset-storybook-full-source): emit multi-story-typescript output as .js The fixture input is code.tsx but the compiled module output is JS (as in a real Storybook build). Set fixtureOutputExt to '.js' via options.json so the emitted output file is output.js. --- .../multi-story-typescript/options.json | 3 +++ .../multi-story-typescript/{output.tsx => output.js} | 0 2 files changed, 3 insertions(+) create mode 100644 packages/react-components/babel-preset-storybook-full-source/src/__fixtures__/storybook-stories-fullsource-per-story/multi-story-typescript/options.json rename packages/react-components/babel-preset-storybook-full-source/src/__fixtures__/storybook-stories-fullsource-per-story/multi-story-typescript/{output.tsx => output.js} (100%) diff --git a/packages/react-components/babel-preset-storybook-full-source/src/__fixtures__/storybook-stories-fullsource-per-story/multi-story-typescript/options.json b/packages/react-components/babel-preset-storybook-full-source/src/__fixtures__/storybook-stories-fullsource-per-story/multi-story-typescript/options.json new file mode 100644 index 00000000000000..d3628b60918878 --- /dev/null +++ b/packages/react-components/babel-preset-storybook-full-source/src/__fixtures__/storybook-stories-fullsource-per-story/multi-story-typescript/options.json @@ -0,0 +1,3 @@ +{ + "fixtureOutputExt": ".js" +} diff --git a/packages/react-components/babel-preset-storybook-full-source/src/__fixtures__/storybook-stories-fullsource-per-story/multi-story-typescript/output.tsx b/packages/react-components/babel-preset-storybook-full-source/src/__fixtures__/storybook-stories-fullsource-per-story/multi-story-typescript/output.js similarity index 100% rename from packages/react-components/babel-preset-storybook-full-source/src/__fixtures__/storybook-stories-fullsource-per-story/multi-story-typescript/output.tsx rename to packages/react-components/babel-preset-storybook-full-source/src/__fixtures__/storybook-stories-fullsource-per-story/multi-story-typescript/output.js