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..8ec3e8db9d8b23 --- /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";\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-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..f5c9c1582049d9 --- /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 = { 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 = { appearance: "primary", children: "Args only" };\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..2f1eaa0bc295de --- /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";\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";\n\nexport const Secondary = () => (\n \n);\n'; diff --git a/packages/react-components/babel-preset-storybook-full-source/src/__fixtures__/storybook-stories-fullsource-per-story/multi-story-csf3/code.js b/packages/react-components/babel-preset-storybook-full-source/src/__fixtures__/storybook-stories-fullsource-per-story/multi-story-csf3/code.js new file mode 100644 index 00000000000000..5cb7bce5e812f5 --- /dev/null +++ b/packages/react-components/babel-preset-storybook-full-source/src/__fixtures__/storybook-stories-fullsource-per-story/multi-story-csf3/code.js @@ -0,0 +1,48 @@ +import * as React from 'react'; +import { Button } from '@fluentui/react-button'; + +const meta = { + title: 'Card', + component: Button, +}; + +export default meta; + +// --- Custom React components defined in the module --- +const Card = ({ children }) =>
{children}
; + +const CardHeader = ({ title }) =>

{title}

; + +const CardMedia = ({ src }) => ; + +const CardFooter = () => ; + +// Uses Card + CardHeader + Button. +export const Basic = { + render: () => ( + + + + + ), +}; + +// Uses Card + CardMedia only. +export const WithMedia = { + render: () => ( + + + + ), +}; + +// 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/multi-story-csf3/output.js b/packages/react-components/babel-preset-storybook-full-source/src/__fixtures__/storybook-stories-fullsource-per-story/multi-story-csf3/output.js new file mode 100644 index 00000000000000..00b1ef7e2d08ce --- /dev/null +++ b/packages/react-components/babel-preset-storybook-full-source/src/__fixtures__/storybook-stories-fullsource-per-story/multi-story-csf3/output.js @@ -0,0 +1,89 @@ +import * as React from 'react'; +import { Button } from '@fluentui/react-button'; +const meta = { + title: 'Card', + component: Button, +}; +export default meta; + +// --- Custom React components defined in the module --- +const Card = ({ children }) => + /*#__PURE__*/ React.createElement( + 'div', + { + className: 'card', + }, + children, + ); +const CardHeader = ({ title }) => + /*#__PURE__*/ React.createElement( + 'h3', + { + className: 'card-header', + }, + 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'), + ), +}; + +// Uses Card + CardMedia only. +export const WithMedia = { + render: () => + /*#__PURE__*/ React.createElement( + Card, + null, + /*#__PURE__*/ React.createElement(CardMedia, { + src: 'cat.png', + }), + ), +}; + +// 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), + ), +}; +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 = () => ;\n\n// Uses Card + CardHeader + CardMedia + CardFooter.\nexport const Full = () => (\n \n \n \n \n \n);\n'; 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/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.js b/packages/react-components/babel-preset-storybook-full-source/src/__fixtures__/storybook-stories-fullsource-per-story/multi-story-typescript/output.js 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.js @@ -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 43af7003784c60..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 @@ -41,3 +41,23 @@ pluginTester({ pluginName: PLUGIN_NAME, plugin, }); + +// Per-story granularity: each story export gets its own sliced `fullSource`. +pluginTester({ + babelOptions: { + // 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: { + 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..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 @@ -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,30 @@ 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 +67,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 +120,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 +132,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,52 +141,83 @@ 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); } }, 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 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()); - } - - path.pushContainer('body', createFullSourceAssignmentExpression(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 + const tokensSource = + 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; - if (cssModules.length > 0 || tokensSource) { - path.pushContainer('body', createCssModuleSourcesAssignment({ cssModules, tokensSource })); + 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) { + const sliced = sliceStorySource(babel, fileContents, { + targetStory: currentStory, + filename: state.filename, + }); + + if (!sliced) { + continue; + } + + emitStorySource(currentStory, buildFullSource(sliced)); + } + + return; } + + // 'file' granularity (default, legacy): the whole file on the last story. + if (!storyName) { + return; + } + + emitStorySource(storyName, buildFullSource(fileContents)); }, }, }, 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..586e4a093511f4 --- /dev/null +++ b/packages/react-components/babel-preset-storybook-full-source/src/sliceStory.ts @@ -0,0 +1,731 @@ +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 { + const context = { handled: false }; + + const result = babel.transformSync(source, { + configFile: false, + babelrc: false, + filename: options.filename, + 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, context)], + }); + + return context.handled ? result?.code ?? null : null; +} + +function createSliceStoryPlugin( + babel: typeof Babel, + options: SliceStoryOptions, + context: { handled: boolean }, +): 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(); + // 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); + 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; + } + + // 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, 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) { + continue; + } + + if (statementPath.isImportDeclaration()) { + pruneImportDeclaration(t, statementPath, neededBindings); + continue; + } + + if (statementPath.isExportDefaultDeclaration()) { + remove(statementPath); + continue; + } + + // Remove the `const meta = {…}` declaration backing the default export. + if (metaLocalName && isDeclarationOf(t, statementPath, metaLocalName)) { + remove(statementPath); + continue; + } + + // Remove sibling story exports. + if (statementPath.isExportNamedDeclaration()) { + remove(statementPath); + continue; + } + + // Remove CSF2 story annotations (`Story.parameters = …`, `Story.args = …`). + if (isStoryAnnotationAssignment(t, statementPath)) { + remove(statementPath); + continue; + } + + // Keep only declarations reachable from the target story. + if (isModuleLevelDeclaration(t, statementPath)) { + if (!isDeclarationReachable(statementPath, neededBindings)) { + 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; + }, + }, + }; +} + +/** + * 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 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)) { + if (id.typeAnnotation) { + id.typeAnnotation = null; + } + return node; + } + + // 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)); + // 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. */ +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); + } + } + } +} + +/** 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 + * 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, + 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) { + 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); + } + }, + // 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); + } + }, + }); + } + + 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) + ); +} + +/** 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/`) 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__/**"] }