├── .husky ├── .gitignore └── pre-commit ├── .prettierignore ├── .vscode └── settings.json ├── src ├── typings.d.ts ├── types.ts ├── jsx.ts ├── index.ts ├── sb-mdx-plugin.ts └── index.test.ts ├── .gitignore ├── babel.config.cjs ├── .prettierrc ├── .storybook ├── preview.js └── main.js ├── stories ├── GFM.stories.mdx ├── header.css ├── Header.stories.tsx ├── button.css ├── Page.stories.tsx ├── Button.tsx ├── assets │ ├── direction.svg │ ├── flow.svg │ ├── code-brackets.svg │ ├── comments.svg │ ├── repo.svg │ ├── plugin.svg │ ├── stackalt.svg │ └── colors.svg ├── Button.stories.tsx ├── page.css ├── Button.stories.mdx ├── Header.tsx ├── Page.tsx └── Introduction.stories.mdx ├── jest.config.js ├── .github ├── PULL_REQUEST_TEMPLATE.md ├── workflows │ ├── test.yml │ ├── release.yml │ └── linear-export.yml └── ISSUE_TEMPLATE │ ├── bug_report.md │ └── feature_request.md ├── CONTRIBUTING.md ├── tsconfig.json ├── tsup.config.ts ├── loader.js ├── LICENSE ├── CHANGELOG.md ├── README.md └── package.json /.husky/.gitignore: -------------------------------------------------------------------------------- 1 | _ 2 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | dist 2 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "deepscan.enable": true 3 | } 4 | -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | . "$(dirname "$0")/_/husky.sh" 3 | 4 | npx lint-staged 5 | -------------------------------------------------------------------------------- /src/typings.d.ts: -------------------------------------------------------------------------------- 1 | declare module 'estree-to-babel'; 2 | declare module 'hast-util-to-estree'; 3 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | dist/ 2 | node_modules/ 3 | storybook-static/ 4 | build-storybook.log 5 | .DS_Store 6 | .env 7 | .cache 8 | yarn-error.log -------------------------------------------------------------------------------- /babel.config.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | presets: [['@babel/preset-env', { targets: { node: 'current' } }], '@babel/preset-typescript'], 3 | }; 4 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "printWidth": 100, 3 | "tabWidth": 2, 4 | "bracketSpacing": true, 5 | "trailingComma": "es5", 6 | "singleQuote": true, 7 | "arrowParens": "always" 8 | } 9 | -------------------------------------------------------------------------------- /.storybook/preview.js: -------------------------------------------------------------------------------- 1 | export const parameters = { 2 | actions: { argTypesRegex: '^on[A-Z].*' }, 3 | controls: { 4 | matchers: { 5 | color: /(background|color)$/i, 6 | date: /Date$/, 7 | }, 8 | }, 9 | }; 10 | -------------------------------------------------------------------------------- /stories/GFM.stories.mdx: -------------------------------------------------------------------------------- 1 | import { Meta } from '@storybook/addon-docs'; 2 | 3 | 4 | 5 | # Github-flavored markdown 6 | 7 | ## Table 8 | 9 | | a | b | c | d | 10 | | --- | :-- | --: | :-: | 11 | 12 | ## Tasklist 13 | 14 | - [ ] to do 15 | - [x] done 16 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | testMatch: ['**/*.test.ts'], 3 | // transform everything including node_modules 4 | transformIgnorePatterns: [], 5 | // deal with missing main fields 6 | moduleNameMapper: { 7 | 'estree-walker': 'estree-walker/src', 8 | 'is-reference': 'is-reference/src', 9 | }, 10 | }; 11 | -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | Issue: # 2 | 3 | ## What Changed 4 | 5 | 6 | 7 | ## How to test 8 | 9 | 10 | 11 | ## Change Type 12 | 13 | 14 | 15 | - [ ] `maintenance` 16 | - [ ] `documentation` 17 | - [ ] `patch` 18 | - [ ] `minor` 19 | - [ ] `major` 20 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Test 2 | 3 | on: [push] 4 | 5 | jobs: 6 | test: 7 | runs-on: ubuntu-latest 8 | steps: 9 | - uses: actions/checkout@v2 10 | 11 | - name: Use Node.js 16.x 12 | uses: actions/setup-node@v3 13 | with: 14 | node-version: 16.x 15 | 16 | - name: Install dependencies 17 | uses: bahmutov/npm-install@v1 18 | 19 | - name: Run tests 20 | run: | 21 | yarn test 22 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | ## Table of Contents 2 | 3 | - [1. About the Project](#about-the-project) 4 | - [2. Getting Started](#getting-started) 5 | - [3. Useful resources](#useful-resources) 6 | 7 | # About the project 8 | 9 | FIXME: add description 10 | 11 | # Getting started 12 | 13 | First of all, thank you so much for taking the time to contribute to this project. 14 | 15 | FIXME: add steps 16 | 17 | ### Useful resources 18 | 19 | - Storybook has a discord community! And we need more people like you. Please [join us](https://discord.gg/storybook) and say hi in the #contributors channel! 👋 20 | -------------------------------------------------------------------------------- /src/types.ts: -------------------------------------------------------------------------------- 1 | import { compile as mdxCompile } from '@mdx-js/mdx'; 2 | import { transformAsync } from '@babel/core'; 3 | 4 | export interface JSXOptions { 5 | pragma?: string; 6 | pragmaFrag?: string; 7 | throwIfNamespace?: false; 8 | runtime?: 'classic' | 'automatic'; 9 | importSource?: string; 10 | } 11 | 12 | export type MdxCompileOptions = Parameters[1]; 13 | export type BabelOptions = Parameters[1]; 14 | 15 | export interface CompileOptions { 16 | skipCsf?: boolean; 17 | mdxCompileOptions?: MdxCompileOptions; 18 | jsxOptions?: JSXOptions; 19 | } 20 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "allowSyntheticDefaultImports": true, 4 | "baseUrl": ".", 5 | "emitDecoratorMetadata": true, 6 | "esModuleInterop": true, 7 | "experimentalDecorators": true, 8 | "incremental": false, 9 | "isolatedModules": true, 10 | "jsx": "react", 11 | "lib": ["es2017", "dom"], 12 | "module": "commonjs", 13 | "noImplicitAny": true, 14 | "rootDir": "./src", 15 | "skipLibCheck": true, 16 | "target": "ES2020", 17 | "types": ["jest", "node"], 18 | "moduleResolution": "node" 19 | }, 20 | "include": ["src/**/*.ts"], 21 | "exclude": ["src/**/*.test.ts"] 22 | } 23 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | --- 8 | 9 | **Describe the bug** 10 | A clear and concise description of what the bug is. 11 | 12 | **To Reproduce** 13 | Steps to reproduce the behavior: 14 | 15 | 1. Go to '...' 16 | 2. Click on '....' 17 | 3. Scroll down to '....' 18 | 4. See error 19 | 20 | **Expected behavior** 21 | A clear and concise description of what you expected to happen. 22 | 23 | **Screenshots** 24 | If applicable, add screenshots to help explain your problem. 25 | 26 | **Additional context** 27 | Add any other context about the problem here. 28 | -------------------------------------------------------------------------------- /stories/header.css: -------------------------------------------------------------------------------- 1 | .wrapper { 2 | font-family: 'Nunito Sans', 'Helvetica Neue', Helvetica, Arial, sans-serif; 3 | border-bottom: 1px solid rgba(0, 0, 0, 0.1); 4 | padding: 15px 20px; 5 | display: flex; 6 | align-items: center; 7 | justify-content: space-between; 8 | } 9 | 10 | svg { 11 | display: inline-block; 12 | vertical-align: top; 13 | } 14 | 15 | h1 { 16 | font-weight: 900; 17 | font-size: 20px; 18 | line-height: 1; 19 | margin: 6px 0 6px 10px; 20 | display: inline-block; 21 | vertical-align: top; 22 | } 23 | 24 | button + button { 25 | margin-left: 10px; 26 | } 27 | 28 | .welcome { 29 | color: #333; 30 | font-size: 14px; 31 | margin-right: 10px; 32 | } 33 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | --- 8 | 9 | **Is your feature request related to a problem? Please describe.** 10 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 11 | 12 | **Describe the solution you'd like** 13 | A clear and concise description of what you want to happen. 14 | 15 | **Describe alternatives you've considered** 16 | A clear and concise description of any alternative solutions or features you've considered. 17 | 18 | **Additional context** 19 | Add any other context or screenshots about the feature request here. 20 | -------------------------------------------------------------------------------- /stories/Header.stories.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { ComponentStory, ComponentMeta } from '@storybook/react'; 3 | 4 | import { Header } from './Header'; 5 | 6 | export default { 7 | title: 'Example/Header', 8 | component: Header, 9 | parameters: { 10 | // More on Story layout: https://storybook.js.org/docs/react/configure/story-layout 11 | layout: 'fullscreen', 12 | }, 13 | } as ComponentMeta; 14 | 15 | const Template: ComponentStory = (args) =>
; 16 | 17 | export const LoggedIn = Template.bind({}); 18 | LoggedIn.args = { 19 | user: { 20 | name: 'Jane Doe', 21 | }, 22 | }; 23 | 24 | export const LoggedOut = Template.bind({}); 25 | LoggedOut.args = {}; 26 | -------------------------------------------------------------------------------- /tsup.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'tsup'; 2 | 3 | export default defineConfig([ 4 | { 5 | entry: ['./src/index.ts'], 6 | format: 'cjs', 7 | esbuildOptions(options, context) { 8 | options.platform = 'node'; 9 | }, 10 | }, 11 | { 12 | entry: ['./src/index.ts'], 13 | format: 'esm', 14 | dts: { 15 | entry: ['./src/index.ts'], 16 | resolve: true, 17 | }, 18 | esbuildOptions(options, context) { 19 | options.platform = 'node'; 20 | options.banner = { 21 | js: 22 | "import { createRequire as topLevelCreateRequire } from 'module';\n const require = topLevelCreateRequire(import" + 23 | '.meta.url);', 24 | }; 25 | }, 26 | }, 27 | ]); 28 | -------------------------------------------------------------------------------- /stories/button.css: -------------------------------------------------------------------------------- 1 | .storybook-button { 2 | font-family: 'Nunito Sans', 'Helvetica Neue', Helvetica, Arial, sans-serif; 3 | font-weight: 700; 4 | border: 0; 5 | border-radius: 3em; 6 | cursor: pointer; 7 | display: inline-block; 8 | line-height: 1; 9 | } 10 | .storybook-button--primary { 11 | color: white; 12 | background-color: #1ea7fd; 13 | } 14 | .storybook-button--secondary { 15 | color: #333; 16 | background-color: transparent; 17 | box-shadow: rgba(0, 0, 0, 0.15) 0px 0px 0px 1px inset; 18 | } 19 | .storybook-button--small { 20 | font-size: 12px; 21 | padding: 10px 16px; 22 | } 23 | .storybook-button--medium { 24 | font-size: 14px; 25 | padding: 11px 20px; 26 | } 27 | .storybook-button--large { 28 | font-size: 16px; 29 | padding: 12px 24px; 30 | } 31 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | on: [push] 4 | 5 | jobs: 6 | release: 7 | runs-on: ubuntu-latest 8 | if: "!contains(github.event.head_commit.message, 'ci skip') && !contains(github.event.head_commit.message, 'skip ci')" 9 | steps: 10 | - uses: actions/checkout@v2 11 | 12 | - name: Prepare repository 13 | run: git fetch --unshallow --tags 14 | 15 | - name: Use Node.js 16.x 16 | uses: actions/setup-node@v3 17 | with: 18 | node-version: 16.x 19 | 20 | - name: Install dependencies 21 | uses: bahmutov/npm-install@v1 22 | 23 | - name: Create Release 24 | env: 25 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 26 | NPM_TOKEN: ${{ secrets.NPM_TOKEN }} 27 | run: | 28 | yarn release 29 | -------------------------------------------------------------------------------- /.github/workflows/linear-export.yml: -------------------------------------------------------------------------------- 1 | name: Export to linear 2 | 3 | on: 4 | issues: 5 | types: [labeled] 6 | pull_request: 7 | types: [labeled] 8 | 9 | jobs: 10 | trigger: 11 | if: github.event.label.name == 'linear' 12 | name: Export to linear 13 | runs-on: ubuntu-latest 14 | steps: 15 | # - uses: hmarr/debug-action@v2 16 | - name: Linear action 17 | uses: shilman/linear-action@v1 18 | with: 19 | ghIssueNumber: ${{ github.event.number || github.event.issue.number }} 20 | ghRepoOwner: ${{ github.event.repository.owner.login }} 21 | ghRepoName: ${{ github.event.repository.name }} 22 | ghToken: ${{ secrets.LINEAR_GH_TOKEN }} 23 | linearIssuePrefix: mdx2 24 | linearLabel: Storybook 25 | linearPRLabel: PR 26 | linearTeam: SB 27 | linearApiKey: ${{ secrets.LINEAR_API_KEY }} 28 | -------------------------------------------------------------------------------- /stories/Page.stories.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { ComponentStory, ComponentMeta } from '@storybook/react'; 3 | import { within, userEvent } from '@storybook/testing-library'; 4 | import { Page } from './Page'; 5 | 6 | export default { 7 | title: 'Example/Page', 8 | component: Page, 9 | parameters: { 10 | // More on Story layout: https://storybook.js.org/docs/react/configure/story-layout 11 | layout: 'fullscreen', 12 | }, 13 | } as ComponentMeta; 14 | 15 | const Template: ComponentStory = (args) => ; 16 | 17 | export const LoggedOut = Template.bind({}); 18 | 19 | export const LoggedIn = Template.bind({}); 20 | 21 | // More on interaction testing: https://storybook.js.org/docs/react/writing-tests/interaction-testing 22 | LoggedIn.play = async ({ canvasElement }) => { 23 | const canvas = within(canvasElement); 24 | const loginButton = await canvas.getByRole('button', { name: /Log in/i }); 25 | await userEvent.click(loginButton); 26 | }; 27 | -------------------------------------------------------------------------------- /loader.js: -------------------------------------------------------------------------------- 1 | const { compile } = require('./dist/index'); 2 | 3 | // FIXME: we shouldn't be doing this, but we need it 4 | // for react MDX story definitions, e.g. 5 | // 6 | //
hi>
7 | // 8 | // Which generates the code: 9 | // 10 | // export const foo = () =>
hi
; 11 | const DEFAULT_RENDERER = ` 12 | import React from 'react'; 13 | `; 14 | 15 | // Lifted from MDXv1 loader 16 | // https://github.com/mdx-js/mdx/blob/v1/packages/loader/index.js 17 | // 18 | // Added 19 | // - webpack5 support 20 | // - MDX compiler built in 21 | const loader = async function (content) { 22 | const callback = this.async(); 23 | const options = Object.assign({}, this.getOptions(), { 24 | filepath: this.resourcePath, 25 | }); 26 | 27 | let result; 28 | try { 29 | result = await compile(content, options); 30 | } catch (err) { 31 | console.error('Error loading:', this.resourcePath) 32 | return callback(err); 33 | } 34 | 35 | const code = `${DEFAULT_RENDERER}\n${result}`; 36 | return callback(null, code); 37 | }; 38 | 39 | module.exports = loader; 40 | -------------------------------------------------------------------------------- /src/jsx.ts: -------------------------------------------------------------------------------- 1 | import { transformAsync, transformSync } from '@babel/core'; 2 | // @ts-expect-error (no types, but perfectly valid) 3 | import presetReact from '@babel/preset-react'; 4 | import type { JSXOptions, BabelOptions } from './types'; 5 | 6 | function getBabelOptions(jsxOptions: JSXOptions): BabelOptions { 7 | return { 8 | filename: 'file.js', 9 | sourceType: 'module', 10 | configFile: false, 11 | babelrc: false, 12 | presets: [ 13 | [ 14 | presetReact, 15 | { 16 | runtime: 'automatic', 17 | ...jsxOptions, 18 | }, 19 | ], 20 | ], 21 | }; 22 | } 23 | 24 | export const transformJSXAsync = async (input: string, jsxOptions: JSXOptions) => { 25 | const babelOptions = getBabelOptions(jsxOptions); 26 | const { code } = await transformAsync(input, babelOptions); 27 | 28 | return code; 29 | }; 30 | 31 | export const transformJSXSync = (input: string, jsxOptions: JSXOptions) => { 32 | const babelOptions = getBabelOptions(jsxOptions); 33 | const { code } = transformSync(input, babelOptions); 34 | 35 | return code; 36 | }; 37 | -------------------------------------------------------------------------------- /stories/Button.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import './button.css'; 3 | 4 | interface ButtonProps { 5 | /** 6 | * Is this the principal call to action on the page? 7 | */ 8 | primary?: boolean; 9 | /** 10 | * What background color to use 11 | */ 12 | backgroundColor?: string; 13 | /** 14 | * How large should the button be? 15 | */ 16 | size?: 'small' | 'medium' | 'large'; 17 | /** 18 | * Button contents 19 | */ 20 | label: string; 21 | /** 22 | * Optional click handler 23 | */ 24 | onClick?: () => void; 25 | } 26 | 27 | /** 28 | * Primary UI component for user interaction 29 | */ 30 | export const Button = ({ 31 | primary = false, 32 | size = 'medium', 33 | backgroundColor, 34 | label, 35 | ...props 36 | }: ButtonProps) => { 37 | const mode = primary ? 'storybook-button--primary' : 'storybook-button--secondary'; 38 | return ( 39 | 47 | ); 48 | }; 49 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 Storybook contributors 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /stories/assets/direction.svg: -------------------------------------------------------------------------------- 1 | illustration/direction -------------------------------------------------------------------------------- /stories/Button.stories.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { ComponentStory, ComponentMeta } from '@storybook/react'; 3 | 4 | import { Button } from './Button'; 5 | 6 | // More on default export: https://storybook.js.org/docs/react/writing-stories/introduction#default-export 7 | export default { 8 | title: 'Example/Button', 9 | component: Button, 10 | // More on argTypes: https://storybook.js.org/docs/react/api/argtypes 11 | argTypes: { 12 | backgroundColor: { control: 'color' }, 13 | }, 14 | } as ComponentMeta; 15 | 16 | // More on component templates: https://storybook.js.org/docs/react/writing-stories/introduction#using-args 17 | const Template: ComponentStory = (args) =>
56 | ); 57 | -------------------------------------------------------------------------------- /stories/assets/plugin.svg: -------------------------------------------------------------------------------- 1 | illustration/plugin -------------------------------------------------------------------------------- /stories/assets/stackalt.svg: -------------------------------------------------------------------------------- 1 | illustration/stackalt -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ## @storybook/mdx2-csf 2 | 3 | Storybook's `mdx2-csf` is a compiler that turns MDXv2 input into CSF output. 4 | 5 | For example, the following input: 6 | 7 | ```mdx 8 | import { Meta, Story } from '@storybook/addon-docs'; 9 | 10 | 11 | 12 | 13 | 14 | 15 | ``` 16 | 17 | Might be transformed into the following CSF (over-simplified): 18 | 19 | ```js 20 | export default { 21 | title: 'atoms/Button', 22 | }; 23 | 24 | export const Bar = () => ; 25 | ``` 26 | 27 | ## API 28 | 29 | This library exports an async function to compile MDX, `compile`. 30 | It does not support a synchronous compiler because it uses asynchronous 31 | imports to bridge the ESM/CJS gap. The underlying MDXv2 libraries only 32 | support pure ESM, but this library is used in CJS environments. 33 | 34 | ### compile 35 | 36 | Asynchronously compile a string: 37 | 38 | ```js 39 | import { compile } from '@storybook/mdx2-csf'; 40 | 41 | const code = '# hello\n\nworld'; 42 | const output = await compile(code); 43 | ``` 44 | 45 | ## Loader 46 | 47 | In addition, this library supports a simple Webpack loader that mirrors MDXv1's loader, but adds Webpack5 support. It doesn't use MDXv2's loader because it is prohibitively complex, and we want this to be interchangeable with the `@storybook/mdx1-csf`'s loader which is also based on the MDXv1 loader. 48 | 49 | The loader takes two options: 50 | 51 | - `skipCsf` don't generate CSF stories for the MDX file 52 | - `mdxCompileOptions` full options for the [MDX compile function](https://mdxjs.com/packages/mdx/#api) 53 | 54 | For example, to add [GFM support](https://mdxjs.com/guides/gfm/): 55 | 56 | ```js 57 | import remarkGfm from 'remark-gfm'; 58 | 59 | module.exports = { 60 | module: { 61 | rules: [ 62 | { 63 | test: /\.(stories|story)\.mdx$/, 64 | use: [ 65 | { 66 | loader: require.resolve('@storybook/mdx2-csf/loader'), 67 | options: { 68 | skipCsf: false, 69 | mdxCompileOptions: { 70 | remarkPlugins: [remarkGfm], 71 | }, 72 | }, 73 | }, 74 | ], 75 | }, 76 | ], 77 | }, 78 | }; 79 | ``` 80 | 81 | ## Contributing 82 | 83 | We welcome contributions to Storybook! 84 | 85 | - 📥 Pull requests and 🌟 Stars are always welcome. 86 | - Read our [contributing guide](CONTRIBUTING.md) to get started, 87 | or find us on [Discord](https://discord.gg/storybook), we will take the time to guide you 88 | 89 | ## License 90 | 91 | [MIT](https://github.com/storybookjs/csf-mdx2/blob/main/LICENSE) 92 | -------------------------------------------------------------------------------- /stories/Page.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import { Header } from './Header'; 4 | import './page.css'; 5 | 6 | type User = { 7 | name: string; 8 | }; 9 | 10 | export const Page: React.VFC = () => { 11 | const [user, setUser] = React.useState(); 12 | 13 | return ( 14 |
15 |
setUser({ name: 'Jane Doe' })} 18 | onLogout={() => setUser(undefined)} 19 | onCreateAccount={() => setUser({ name: 'Jane Doe' })} 20 | /> 21 | 22 |
23 |

Pages in Storybook

24 |

25 | We recommend building UIs with a{' '} 26 | 27 | component-driven 28 | {' '} 29 | process starting with atomic components and ending with pages. 30 |

31 |

32 | Render pages with mock data. This makes it easy to build and review page states without 33 | needing to navigate to them in your app. Here are some handy patterns for managing page 34 | data in Storybook: 35 |

36 |
    37 |
  • 38 | Use a higher-level connected component. Storybook helps you compose such data from the 39 | "args" of child component stories 40 |
  • 41 |
  • 42 | Assemble data in the page component from your services. You can mock these services out 43 | using Storybook. 44 |
  • 45 |
46 |

47 | Get a guided tutorial on component-driven development at{' '} 48 | 49 | Storybook tutorials 50 | 51 | . Read more in the{' '} 52 | 53 | docs 54 | 55 | . 56 |

57 |
58 | Tip Adjust the width of the canvas with the{' '} 59 | 60 | 61 | 66 | 67 | 68 | Viewports addon in the toolbar 69 |
70 |
71 |
72 | ); 73 | }; 74 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@storybook/mdx2-csf", 3 | "version": "0.0.4", 4 | "description": "MDXv2 to CSF webpack compiler and loader", 5 | "repository": { 6 | "type": "git", 7 | "url": "https://github.com/storybookjs/csf-mdx2" 8 | }, 9 | "author": "Michael Shilman ", 10 | "license": "MIT", 11 | "main": "dist/index.js", 12 | "module": "dist/index.mjs", 13 | "types": "dist/index.d.ts", 14 | "files": [ 15 | "dist/**/*", 16 | "README.md", 17 | "*.js", 18 | "*.d.ts" 19 | ], 20 | "scripts": { 21 | "test": "jest", 22 | "build": "tsup", 23 | "start": "yarn build && yarn storybook -- --no-manager-cache --quiet\"", 24 | "release": "yarn build && auto shipit", 25 | "storybook": "storybook dev -p 6006", 26 | "build-storybook": "storybook build", 27 | "prettier": "prettier", 28 | "prepare": "husky install" 29 | }, 30 | "dependencies": {}, 31 | "devDependencies": { 32 | "@babel/cli": "^7.12.1", 33 | "@babel/core": "^7.12.3", 34 | "@babel/generator": "^7.12.11", 35 | "@babel/parser": "^7.12.11", 36 | "@babel/preset-env": "^7.12.1", 37 | "@babel/preset-react": "^7.12.5", 38 | "@babel/preset-typescript": "^7.13.0", 39 | "@babel/template": "^7.14.5", 40 | "@babel/traverse": "^7.12.11", 41 | "@babel/types": "^7.14.8", 42 | "@jest/types": "^27.0.6", 43 | "@mdx-js/mdx": "^2.0.0", 44 | "@storybook/addon-actions": "^7.0.0-beta.0 || ^7.0.0-rc.0 || ^7.0.0", 45 | "@storybook/addon-essentials": "^7.0.0-beta.0 || ^7.0.0-rc.0 || ^7.0.0", 46 | "@storybook/addon-interactions": "^7.0.0-beta.0 || ^7.0.0-rc.0 || ^7.0.0", 47 | "@storybook/addon-links": "^7.0.0-beta.0 || ^7.0.0-rc.0 || ^7.0.0", 48 | "@storybook/react": "^7.0.0-beta.0 || ^7.0.0-rc.0 || ^7.0.0", 49 | "@storybook/react-webpack5": "^7.0.0-beta.0 || ^7.0.0-rc.0 || ^7.0.0", 50 | "@storybook/testing-library": "next", 51 | "@testing-library/dom": "^8.1.0", 52 | "@testing-library/react": "^12.0.0", 53 | "@testing-library/user-event": "^13.2.1", 54 | "@types/jest": "^27.0.3", 55 | "@types/js-string-escape": "^1.0.1", 56 | "@types/lodash": "^4.14.167", 57 | "@types/node": "^16.4.1", 58 | "auto": "^10.3.0", 59 | "babel-jest": "^27.0.6", 60 | "babel-loader": "^8.1.0", 61 | "concurrently": "^7.0.0", 62 | "estree-to-babel": "^4.9.0", 63 | "hast-util-to-estree": "^2.0.2", 64 | "husky": ">=6", 65 | "jest": "^27.0.6", 66 | "jest-environment-jsdom": "^27.0.6", 67 | "js-string-escape": "^1.0.1", 68 | "lint-staged": ">=10", 69 | "lodash": "^4.17.21", 70 | "prettier": "^2.3.1", 71 | "prompts": "^2.4.2", 72 | "react": "^17.0.1", 73 | "react-dom": "^17.0.1", 74 | "remark-gfm": "^3.0.1", 75 | "rimraf": "^3.0.2", 76 | "storybook": "^7.0.0-beta.0 || ^7.0.0-rc.0 || ^7.0.0", 77 | "ts-dedent": "^2.2.0", 78 | "ts-jest": "^27.0.4", 79 | "tsup": "^6.2.2", 80 | "typescript": "^4.2.4" 81 | }, 82 | "resolutions": { 83 | "@types/estree": "1.0.0" 84 | }, 85 | "lint-staged": { 86 | "*.{ts,js,css,md}": "prettier --write" 87 | }, 88 | "publishConfig": { 89 | "access": "public" 90 | }, 91 | "packageManager": "yarn@1.22.9" 92 | } 93 | -------------------------------------------------------------------------------- /stories/Introduction.stories.mdx: -------------------------------------------------------------------------------- 1 | import { Meta } from '@storybook/addon-docs'; 2 | import Code from './assets/code-brackets.svg'; 3 | import Colors from './assets/colors.svg'; 4 | import Comments from './assets/comments.svg'; 5 | import Direction from './assets/direction.svg'; 6 | import Flow from './assets/flow.svg'; 7 | import Plugin from './assets/plugin.svg'; 8 | import Repo from './assets/repo.svg'; 9 | import StackAlt from './assets/stackalt.svg'; 10 | 11 | 12 | 13 | 104 | 105 | # Welcome to Storybook 106 | 107 | Storybook helps you build UI components in isolation from your app's business logic, data, and context. 108 | That makes it easy to develop hard-to-reach states. Save these UI states as **stories** to revisit during development, testing, or QA. 109 | 110 | Browse example stories now by navigating to them in the sidebar. 111 | View their code in the `src/stories` directory to learn how they work. 112 | We recommend building UIs with a [**component-driven**](https://componentdriven.org) process starting with atomic components and ending with pages. 113 | 114 |
Configure
115 | 116 | 162 | 163 |
Learn
164 | 165 | 195 | 196 |
197 | TipEdit the Markdown in{' '} 198 | src/stories/Introduction.stories.mdx 199 |
200 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import { compile as mdxCompile, compileSync as mdxCompileSync } from '@mdx-js/mdx'; 2 | import generate from '@babel/generator'; 3 | import * as t from '@babel/types'; 4 | import cloneDeep from 'lodash/cloneDeep'; 5 | import toBabel from 'estree-to-babel'; 6 | import { toEstree } from 'hast-util-to-estree'; 7 | 8 | // Keeping as much code as possible from the original compiler to avoid breaking changes 9 | import { 10 | genCanvasExports, 11 | genStoryExport, 12 | genMeta, 13 | CompilerOptions, 14 | Context, 15 | MetaExport, 16 | wrapperJs, 17 | stringifyMeta, 18 | } from './sb-mdx-plugin'; 19 | import type { CompileOptions, MdxCompileOptions, JSXOptions } from './types'; 20 | import { transformJSXAsync, transformJSXSync } from './jsx'; 21 | 22 | export type { CompileOptions, MdxCompileOptions, JSXOptions }; 23 | 24 | export const SEPARATOR = '/* ========= */'; 25 | 26 | export { wrapperJs }; 27 | 28 | const hasStoryChild = (node: any) => { 29 | return node.children?.length > 0 && node.children.find((c: any) => c.name === 'Story'); 30 | }; 31 | 32 | const generateMdxSource = (canvas: any) => { 33 | const babel = toBabel(cloneDeep(toEstree(canvas))); 34 | const { code } = generate(babel, {}); 35 | return code.replace(/<\/?Canvas[^>]*>;?/g, ''); 36 | }; 37 | 38 | function extractExports(root: t.File, options: CompilerOptions) { 39 | const context: Context = { 40 | counter: 0, 41 | storyNameToKey: {}, 42 | namedExports: {}, 43 | }; 44 | const storyExports = []; 45 | const includeStories = []; 46 | let metaExport: MetaExport | null = null; 47 | 48 | let contents: t.ExpressionStatement; 49 | root.program.body.forEach((child) => { 50 | if (t.isExpressionStatement(child) && t.isJSXFragment(child.expression)) { 51 | if (contents) throw new Error('duplicate contents'); 52 | contents = child; 53 | } else if ( 54 | t.isExportNamedDeclaration(child) && 55 | t.isVariableDeclaration(child.declaration) && 56 | child.declaration.declarations.length === 1 57 | ) { 58 | const declaration = child.declaration.declarations[0]; 59 | if (t.isVariableDeclarator(declaration) && t.isIdentifier(declaration.id)) { 60 | const { name } = declaration.id; 61 | context.namedExports[name] = declaration.init; 62 | } 63 | } 64 | }); 65 | if (contents) { 66 | const jsx = contents.expression as t.JSXFragment; 67 | jsx.children.forEach((child) => { 68 | if (t.isJSXElement(child)) { 69 | if (t.isJSXIdentifier(child.openingElement.name)) { 70 | const name = child.openingElement.name.name; 71 | let stories; 72 | if (['Canvas', 'Preview'].includes(name)) { 73 | stories = genCanvasExports(child, context); 74 | } else if (name === 'Story') { 75 | stories = genStoryExport(child, context); 76 | } else if (name === 'Meta') { 77 | const meta = genMeta(child, options); 78 | if (meta) { 79 | if (metaExport) { 80 | throw new Error('Meta can only be declared once'); 81 | } 82 | metaExport = meta; 83 | } 84 | } 85 | if (stories) { 86 | Object.entries(stories).forEach(([key, story]) => { 87 | includeStories.push(key); 88 | storyExports.push(story); 89 | }); 90 | } 91 | } 92 | } else if (t.isJSXExpressionContainer(child)) { 93 | // Skip string literals & other JSX expressions 94 | } else { 95 | throw new Error(`Unexpected JSX child: ${child.type}`); 96 | } 97 | }); 98 | } 99 | 100 | if (metaExport) { 101 | if (!storyExports.length) { 102 | storyExports.push('export const __page = () => { throw new Error("Docs-only story"); };'); 103 | storyExports.push('__page.parameters = { docsOnly: true };'); 104 | includeStories.push('__page'); 105 | } 106 | } else { 107 | metaExport = {}; 108 | } 109 | metaExport.includeStories = JSON.stringify(includeStories); 110 | 111 | const fullJsx = [ 112 | ...storyExports, 113 | `const componentMeta = ${stringifyMeta(metaExport)};`, 114 | wrapperJs, 115 | 'export default componentMeta;', 116 | ].join('\n\n'); 117 | 118 | return fullJsx; 119 | } 120 | 121 | export const genBabel = (store: any, root: any) => { 122 | const estree = store.toEstree(root); 123 | // toBabel mutates root, so we need to clone it 124 | const clone = cloneDeep(estree); 125 | const babel = toBabel(clone); 126 | return babel; 127 | }; 128 | 129 | export const plugin = (store: any) => (root: any) => { 130 | const babel = genBabel(store, root); 131 | store.exports = extractExports(babel, {}); 132 | 133 | // insert mdxSource attributes for canvas elements 134 | root.children.forEach((node: any) => { 135 | if (node.type === 'mdxJsxFlowElement' && node.name === 'Canvas') { 136 | if (!hasStoryChild(node)) { 137 | node.attributes = [ 138 | ...(node.attributes || []), 139 | { type: 'mdxJsxAttribute', name: 'mdxSource', value: generateMdxSource(node) }, 140 | ]; 141 | } 142 | } 143 | }); 144 | 145 | return root; 146 | }; 147 | 148 | export const postprocess = (code: string, extractedExports: string) => { 149 | const lines = code.toString().trim().split('\n'); 150 | 151 | // /*@jsxRuntime automatic @jsxImportSource react*/ 152 | const first = lines.shift(); 153 | 154 | return [ 155 | ...lines.filter((line) => !line.match(/^export default/)), 156 | SEPARATOR, 157 | extractedExports, 158 | ].join('\n'); 159 | }; 160 | 161 | export const compile = async ( 162 | input: string, 163 | { skipCsf = false, mdxCompileOptions = {}, jsxOptions = {} }: CompileOptions = {} 164 | ) => { 165 | const { options, context } = getCompilerOptionsAndContext(mdxCompileOptions, skipCsf); 166 | 167 | if (skipCsf) { 168 | const mdxResult = await mdxCompile(input, options); 169 | 170 | return mdxResult.toString(); 171 | } 172 | 173 | const mdxResult = await mdxCompile(input, options); 174 | 175 | return transformJSXAsync(postprocess(mdxResult.toString(), context.exports), jsxOptions); 176 | }; 177 | 178 | export const compileSync = ( 179 | input: string, 180 | { skipCsf = false, mdxCompileOptions = {}, jsxOptions = {} }: CompileOptions = {} 181 | ) => { 182 | const { options, context } = getCompilerOptionsAndContext(mdxCompileOptions, skipCsf); 183 | 184 | if (skipCsf) { 185 | const mdxResult = mdxCompileSync(input, options); 186 | 187 | return mdxResult.toString(); 188 | } 189 | 190 | const mdxResult = mdxCompileSync(input, options); 191 | 192 | return transformJSXSync(postprocess(mdxResult.toString(), context.exports), jsxOptions); 193 | }; 194 | 195 | function getCompilerOptionsAndContext( 196 | mdxCompileOptions: MdxCompileOptions, 197 | skipCsf: boolean = false 198 | ): { options: MdxCompileOptions; context: any } { 199 | if (skipCsf) { 200 | return { 201 | options: { 202 | providerImportSource: '@mdx-js/react', 203 | rehypePlugins: [], 204 | ...mdxCompileOptions, 205 | }, 206 | context: {}, 207 | }; 208 | } 209 | 210 | const context = { exports: '', toEstree }; 211 | 212 | return { 213 | options: { 214 | providerImportSource: '@mdx-js/react', 215 | ...mdxCompileOptions, 216 | rehypePlugins: [...(mdxCompileOptions?.rehypePlugins || []), [plugin, context]], 217 | // preserve the JSX, we'll deal with it using babel 218 | jsx: true, 219 | }, 220 | context, 221 | }; 222 | } 223 | -------------------------------------------------------------------------------- /stories/assets/colors.svg: -------------------------------------------------------------------------------- 1 | illustration/colors -------------------------------------------------------------------------------- /src/sb-mdx-plugin.ts: -------------------------------------------------------------------------------- 1 | import * as t from '@babel/types'; 2 | import generate from '@babel/generator'; 3 | import camelCase from 'lodash/camelCase'; 4 | import jsStringEscape from 'js-string-escape'; 5 | 6 | // Defined in MDX2.0 7 | interface MdxOptions { 8 | filepath?: string; 9 | skipExport?: boolean; 10 | wrapExport?: string; 11 | remarkPlugins?: any[]; 12 | rehypePlugins?: any[]; 13 | } 14 | 15 | export interface CompilerOptions { 16 | filepath?: string; 17 | } 18 | 19 | export interface Context { 20 | counter: number; 21 | namedExports: Record; 22 | storyNameToKey: Record; 23 | } 24 | 25 | interface HastElement { 26 | type: string; 27 | children: HastElement[]; 28 | value: string; 29 | } 30 | 31 | export type MetaExport = Record; 32 | 33 | // Generate the MDX as is, but append named exports for every 34 | // story in the contents 35 | 36 | const RESERVED = 37 | /^(?:do|if|in|for|let|new|try|var|case|else|enum|eval|false|null|this|true|void|with|await|break|catch|class|const|super|throw|while|yield|delete|export|import|public|return|static|switch|typeof|default|extends|finally|package|private|continue|debugger|function|arguments|interface|protected|implements|instanceof)$/; 38 | 39 | function getAttr(elt: t.JSXOpeningElement, what: string): t.JSXAttribute['value'] | undefined { 40 | const attr = (elt.attributes as t.JSXAttribute[]).find((n) => n.name.name === what); 41 | return attr?.value; 42 | } 43 | 44 | const isReserved = (name: string) => RESERVED.exec(name); 45 | const startsWithNumber = (name: string) => /^\d/.exec(name); 46 | 47 | const sanitizeName = (name: string) => { 48 | let key = camelCase(name); 49 | if (startsWithNumber(key)) { 50 | key = `_${key}`; 51 | } else if (isReserved(key)) { 52 | key = `${key}Story`; 53 | } 54 | return key; 55 | }; 56 | 57 | const getStoryKey = (name: string, counter: number) => 58 | name ? sanitizeName(name) : `story${counter}`; 59 | 60 | function genAttribute(key: string, element: t.JSXOpeningElement) { 61 | const value = getAttr(element, key); 62 | if (t.isJSXExpressionContainer(value)) { 63 | const { code } = generate(value.expression, {}); 64 | return code; 65 | } 66 | return undefined; 67 | } 68 | 69 | function genImportStory( 70 | ast: t.JSXElement, 71 | storyDef: t.JSXExpressionContainer, 72 | storyName: string, 73 | context: Context 74 | ) { 75 | const { code: story } = generate(storyDef.expression, {}); 76 | 77 | const storyKey = `_${story.split('.').pop()}_`; 78 | 79 | const statements = [`export const ${storyKey} = ${story};`]; 80 | if (storyName) { 81 | context.storyNameToKey[storyName] = storyKey; 82 | statements.push(`${storyKey}.storyName = '${storyName}';`); 83 | } else { 84 | context.storyNameToKey[storyKey] = storyKey; 85 | ast.openingElement.attributes.push( 86 | t.jsxAttribute(t.jsxIdentifier('name'), t.stringLiteral(storyKey)) 87 | ); 88 | } 89 | return { 90 | [storyKey]: statements.join('\n'), 91 | }; 92 | } 93 | 94 | function getBodyPart(bodyNode: t.Node, context: Context) { 95 | const body = t.isJSXExpressionContainer(bodyNode) ? bodyNode.expression : bodyNode; 96 | let sourceBody = body; 97 | if ( 98 | t.isCallExpression(body) && 99 | t.isMemberExpression(body.callee) && 100 | t.isIdentifier(body.callee.object) && 101 | t.isIdentifier(body.callee.property) && 102 | body.callee.property.name === 'bind' && 103 | (body.arguments.length === 0 || 104 | (body.arguments.length === 1 && 105 | t.isObjectExpression(body.arguments[0]) && 106 | body.arguments[0].properties.length === 0)) 107 | ) { 108 | const bound = body.callee.object.name; 109 | const namedExport = context.namedExports[bound]; 110 | if (namedExport) { 111 | sourceBody = namedExport; 112 | } 113 | } 114 | 115 | const { code: storyCode } = generate(body, {}); 116 | const { code: sourceCode } = generate(sourceBody, {}); 117 | return { storyCode, sourceCode, body }; 118 | } 119 | 120 | const idOrNull = (attr: t.JSXAttribute['value']) => (t.isStringLiteral(attr) ? attr.value : null); 121 | const expressionOrNull = (attr: t.JSXAttribute['value']) => 122 | t.isJSXExpressionContainer(attr) ? attr.expression : null; 123 | 124 | export function genStoryExport(ast: t.JSXElement, context: Context) { 125 | if (getAttr(ast.openingElement, 'of')) { 126 | throw new Error(`The 'of' prop is not supported in .stories.mdx files, only .mdx files. 127 | See https://storybook.js.org/docs/7.0/react/writing-docs/mdx on how to write MDX files and stories separately.`); 128 | } 129 | 130 | const storyName = idOrNull(getAttr(ast.openingElement, 'name')); 131 | const storyId = idOrNull(getAttr(ast.openingElement, 'id')); 132 | const storyRef = getAttr(ast.openingElement, 'story') as t.JSXExpressionContainer; 133 | 134 | if (!storyId && !storyName && !storyRef) { 135 | throw new Error('Expected a Story name, id, or story attribute'); 136 | } 137 | 138 | // We don't generate exports for story references or the smart "current story" 139 | if (storyId) { 140 | return null; 141 | } 142 | 143 | if (storyRef) { 144 | return genImportStory(ast, storyRef, storyName, context); 145 | } 146 | 147 | const statements = []; 148 | const storyKey = getStoryKey(storyName, context.counter); 149 | 150 | const bodyNodes = ast.children.filter((n) => !t.isJSXText(n)); 151 | let storyCode = null; 152 | let sourceCode = null; 153 | let storyVal = null; 154 | if (!bodyNodes.length) { 155 | if (ast.children.length > 0) { 156 | // plain text node 157 | const { code } = generate(ast.children[0], {}); 158 | storyCode = `'${code}'`; 159 | sourceCode = storyCode; 160 | storyVal = `() => ( 161 | ${storyCode} 162 | )`; 163 | } else { 164 | sourceCode = '{}'; 165 | storyVal = '{}'; 166 | } 167 | } else { 168 | const bodyParts = bodyNodes.map((bodyNode) => getBodyPart(bodyNode, context)); 169 | // if we have more than two children 170 | // 1. Add line breaks 171 | // 2. Enclose in <> ... 172 | storyCode = bodyParts.map(({ storyCode: code }) => code).join('\n'); 173 | sourceCode = bodyParts.map(({ sourceCode: code }) => code).join('\n'); 174 | const storyReactCode = bodyParts.length > 1 ? `<>\n${storyCode}\n` : storyCode; 175 | // keep track if an identifier or function call 176 | // avoid breaking change for 5.3 177 | const BIND_REGEX = /\.bind\(.*\)/; 178 | if (bodyParts.length === 1) { 179 | if (BIND_REGEX.test(bodyParts[0].storyCode)) { 180 | storyVal = bodyParts[0].storyCode; 181 | } else if (t.isIdentifier(bodyParts[0].body)) { 182 | storyVal = `assertIsFn(${storyCode})`; 183 | } else if (t.isArrowFunctionExpression(bodyParts[0].body)) { 184 | storyVal = `(${storyCode})`; 185 | } else { 186 | storyVal = `() => ( 187 | ${storyReactCode} 188 | )`; 189 | } 190 | } else { 191 | storyVal = `() => ( 192 | ${storyReactCode} 193 | )`; 194 | } 195 | } 196 | 197 | statements.push(`export const ${storyKey} = ${storyVal};`); 198 | 199 | // always preserve the name, since CSF exports can get modified by displayName 200 | statements.push(`${storyKey}.storyName = '${storyName}';`); 201 | 202 | const argTypes = genAttribute('argTypes', ast.openingElement); 203 | if (argTypes) statements.push(`${storyKey}.argTypes = ${argTypes};`); 204 | 205 | const args = genAttribute('args', ast.openingElement); 206 | if (args) statements.push(`${storyKey}.args = ${args};`); 207 | 208 | const parameters = expressionOrNull(getAttr(ast.openingElement, 'parameters')); 209 | const source = jsStringEscape(sourceCode); 210 | const sourceParam = `storySource: { source: '${source}' }`; 211 | if (parameters) { 212 | const { code: params } = generate(parameters, {}); 213 | statements.push(`${storyKey}.parameters = { ${sourceParam}, ...${params} };`); 214 | } else { 215 | statements.push(`${storyKey}.parameters = { ${sourceParam} };`); 216 | } 217 | 218 | const decorators = expressionOrNull(getAttr(ast.openingElement, 'decorators')); 219 | if (decorators) { 220 | const { code: decos } = generate(decorators, {}); 221 | statements.push(`${storyKey}.decorators = ${decos};`); 222 | } 223 | 224 | const loaders = expressionOrNull(getAttr(ast.openingElement, 'loaders')); 225 | if (loaders) { 226 | const { code: loaderCode } = generate(loaders, {}); 227 | statements.push(`${storyKey}.loaders = ${loaderCode};`); 228 | } 229 | 230 | const play = expressionOrNull(getAttr(ast.openingElement, 'play')); 231 | if (play) { 232 | const { code: playCode } = generate(play, {}); 233 | statements.push(`${storyKey}.play = ${playCode};`); 234 | } 235 | 236 | const render = expressionOrNull(getAttr(ast.openingElement, 'render')); 237 | if (render) { 238 | const { code: renderCode } = generate(render, {}); 239 | statements.push(`${storyKey}.render = ${renderCode};`); 240 | } 241 | 242 | context.storyNameToKey[storyName] = storyKey; 243 | 244 | return { 245 | [storyKey]: statements.join('\n'), 246 | }; 247 | } 248 | 249 | export function genCanvasExports(ast: t.JSXElement, context: Context) { 250 | const canvasExports = {}; 251 | for (let i = 0; i < ast.children.length; i += 1) { 252 | const child = ast.children[i]; 253 | if ( 254 | t.isJSXElement(child) && 255 | t.isJSXIdentifier(child.openingElement.name) && 256 | child.openingElement.name.name === 'Story' 257 | ) { 258 | const storyExport = genStoryExport(child, context); 259 | const { code } = generate(child, {}); 260 | // @ts-ignore 261 | child.value = code; 262 | if (storyExport) { 263 | Object.assign(canvasExports, storyExport); 264 | context.counter += 1; 265 | } 266 | } 267 | } 268 | return canvasExports; 269 | } 270 | 271 | export function genMeta(ast: t.JSXElement, options: CompilerOptions) { 272 | if (getAttr(ast.openingElement, 'of')) { 273 | throw new Error(`The 'of' prop is not supported in .stories.mdx files, only .mdx files. 274 | See https://storybook.js.org/docs/7.0/react/writing-docs/mdx on how to write MDX files and stories separately.`); 275 | } 276 | 277 | const titleAttr = getAttr(ast.openingElement, 'title'); 278 | const idAttr = getAttr(ast.openingElement, 'id'); 279 | let title = null; 280 | if (titleAttr) { 281 | if (t.isStringLiteral(titleAttr)) { 282 | title = "'".concat(jsStringEscape(titleAttr.value), "'"); 283 | } else if (t.isJSXExpressionContainer(titleAttr)) { 284 | try { 285 | // generate code, so the expression is evaluated by the CSF compiler 286 | const { code } = generate(titleAttr.expression, {}); 287 | // remove the curly brackets at start and end of code 288 | title = code.replace(/^\{(.+)\}$/, '$1'); 289 | } catch (e) { 290 | // eat exception if title parsing didn't go well 291 | // eslint-disable-next-line no-console 292 | console.warn('Invalid title:', options.filepath); 293 | title = undefined; 294 | } 295 | } else { 296 | console.warn(`Unknown title attr: ${titleAttr.type}`); 297 | } 298 | } 299 | const id = t.isStringLiteral(idAttr) ? `'${idAttr.value}'` : null; 300 | const parameters = genAttribute('parameters', ast.openingElement); 301 | const decorators = genAttribute('decorators', ast.openingElement); 302 | const loaders = genAttribute('loaders', ast.openingElement); 303 | const component = genAttribute('component', ast.openingElement); 304 | const subcomponents = genAttribute('subcomponents', ast.openingElement); 305 | const args = genAttribute('args', ast.openingElement); 306 | const argTypes = genAttribute('argTypes', ast.openingElement); 307 | const render = genAttribute('render', ast.openingElement); 308 | 309 | return { 310 | title, 311 | id, 312 | parameters, 313 | decorators, 314 | loaders, 315 | component, 316 | subcomponents, 317 | args, 318 | argTypes, 319 | render, 320 | tags: "['stories-mdx']", 321 | }; 322 | } 323 | 324 | export const wrapperJs = ` 325 | componentMeta.parameters = componentMeta.parameters || {}; 326 | componentMeta.parameters.docs = { 327 | ...(componentMeta.parameters.docs || {}), 328 | page: MDXContent, 329 | }; 330 | `.trim(); 331 | 332 | // Use this rather than JSON.stringify because `Meta`'s attributes 333 | // are already valid code strings, so we want to insert them raw 334 | // rather than add an extra set of quotes 335 | export function stringifyMeta(meta: object) { 336 | let result = '{ '; 337 | Object.entries(meta).forEach(([key, val]) => { 338 | if (val) { 339 | result += `${key}: ${val}, `; 340 | } 341 | }); 342 | result += ' }'; 343 | return result; 344 | } 345 | -------------------------------------------------------------------------------- /src/index.test.ts: -------------------------------------------------------------------------------- 1 | import { dedent } from 'ts-dedent'; 2 | import prettier from 'prettier'; 3 | import { compileSync, compile, SEPARATOR, wrapperJs } from './index'; 4 | 5 | // @ts-ignore 6 | expect.addSnapshotSerializer({ 7 | print: (val: any) => val, 8 | test: (val) => true, 9 | }); 10 | 11 | const clean = (mdx: string) => { 12 | const code = compileSync(mdx); 13 | const trimmed = code.split(SEPARATOR)[1].split(wrapperJs)[0]; 14 | 15 | return prettier 16 | .format(trimmed, { 17 | parser: 'babel', 18 | printWidth: 100, 19 | tabWidth: 2, 20 | bracketSpacing: true, 21 | trailingComma: 'es5', 22 | singleQuote: true, 23 | }) 24 | .trim(); 25 | }; 26 | 27 | describe('mdx2', () => { 28 | it('works', () => { 29 | const input = dedent` 30 | # hello 31 | 32 | 33 | 34 | world {2 + 1} 35 | 36 | bar 37 | `; 38 | // @ts-ignore 39 | expect(clean(input)).toMatchInlineSnapshot(` 40 | export const foo = () => 'bar'; 41 | foo.storyName = 'foo'; 42 | foo.parameters = { 43 | storySource: { 44 | source: '"bar"', 45 | }, 46 | }; 47 | const componentMeta = { 48 | title: 'foobar', 49 | tags: ['stories-mdx'], 50 | includeStories: ['foo'], 51 | }; 52 | componentMeta.parameters = componentMeta.parameters || {}; 53 | componentMeta.parameters.docs = { 54 | ...(componentMeta.parameters.docs || {}), 55 | page: MDXContent, 56 | }; 57 | export default componentMeta; 58 | `); 59 | }); 60 | 61 | it('standalone jsx expressions', () => { 62 | expect( 63 | clean(dedent` 64 | # Standalone JSX expressions 65 | 66 | {3 + 3} 67 | `) 68 | ).toMatchInlineSnapshot(` 69 | const componentMeta = { 70 | includeStories: [], 71 | }; 72 | componentMeta.parameters = componentMeta.parameters || {}; 73 | componentMeta.parameters.docs = { 74 | ...(componentMeta.parameters.docs || {}), 75 | page: MDXContent, 76 | }; 77 | export default componentMeta; 78 | `); 79 | }); 80 | }); 81 | 82 | describe('full snapshots', () => { 83 | it('compileSync', () => { 84 | const input = dedent` 85 | # hello 86 | 87 | 88 | 89 | world {2 + 1} 90 | 91 | bar 92 | `; 93 | // @ts-ignore 94 | expect(compileSync(input)).toMatchInlineSnapshot(` 95 | import { useMDXComponents as _provideComponents } from "@mdx-js/react"; 96 | import { jsx as _jsx } from "react/jsx-runtime"; 97 | import { jsxs as _jsxs } from "react/jsx-runtime"; 98 | import { Fragment as _Fragment } from "react/jsx-runtime"; 99 | function _createMdxContent(props) { 100 | const _components = Object.assign({ 101 | h1: "h1", 102 | p: "p" 103 | }, _provideComponents(), props.components), 104 | { 105 | Meta, 106 | Story 107 | } = _components; 108 | if (!Meta) _missingMdxReference("Meta", true); 109 | if (!Story) _missingMdxReference("Story", true); 110 | return /*#__PURE__*/_jsxs(_Fragment, { 111 | children: [/*#__PURE__*/_jsx(_components.h1, { 112 | children: "hello" 113 | }), "\\n", /*#__PURE__*/_jsx(Meta, { 114 | title: "foobar" 115 | }), "\\n", /*#__PURE__*/_jsxs(_components.p, { 116 | children: ["world ", 2 + 1] 117 | }), "\\n", /*#__PURE__*/_jsx(Story, { 118 | name: "foo", 119 | children: "bar" 120 | })] 121 | }); 122 | } 123 | function MDXContent(props = {}) { 124 | const { 125 | wrapper: MDXLayout 126 | } = Object.assign({}, _provideComponents(), props.components); 127 | return MDXLayout ? /*#__PURE__*/_jsx(MDXLayout, { 128 | ...props, 129 | children: /*#__PURE__*/_jsx(_createMdxContent, { 130 | ...props 131 | }) 132 | }) : _createMdxContent(props); 133 | } 134 | function _missingMdxReference(id, component) { 135 | throw new Error("Expected " + (component ? "component" : "object") + " \`" + id + "\` to be defined: you likely forgot to import, pass, or provide it."); 136 | } 137 | /* ========= */ 138 | export const foo = () => "bar"; 139 | foo.storyName = 'foo'; 140 | foo.parameters = { 141 | storySource: { 142 | source: '\\"bar\\"' 143 | } 144 | }; 145 | const componentMeta = { 146 | title: 'foobar', 147 | tags: ['stories-mdx'], 148 | includeStories: ["foo"] 149 | }; 150 | componentMeta.parameters = componentMeta.parameters || {}; 151 | componentMeta.parameters.docs = { 152 | ...(componentMeta.parameters.docs || {}), 153 | page: MDXContent 154 | }; 155 | export default componentMeta; 156 | `); 157 | }); 158 | it('compile', async () => { 159 | const input = dedent` 160 | # hello 161 | 162 | 163 | 164 | world {2 + 1} 165 | 166 | bar 167 | `; 168 | // @ts-ignore 169 | expect(await compile(input)).toMatchInlineSnapshot(` 170 | import { useMDXComponents as _provideComponents } from "@mdx-js/react"; 171 | import { jsx as _jsx } from "react/jsx-runtime"; 172 | import { jsxs as _jsxs } from "react/jsx-runtime"; 173 | import { Fragment as _Fragment } from "react/jsx-runtime"; 174 | function _createMdxContent(props) { 175 | const _components = Object.assign({ 176 | h1: "h1", 177 | p: "p" 178 | }, _provideComponents(), props.components), 179 | { 180 | Meta, 181 | Story 182 | } = _components; 183 | if (!Meta) _missingMdxReference("Meta", true); 184 | if (!Story) _missingMdxReference("Story", true); 185 | return /*#__PURE__*/_jsxs(_Fragment, { 186 | children: [/*#__PURE__*/_jsx(_components.h1, { 187 | children: "hello" 188 | }), "\\n", /*#__PURE__*/_jsx(Meta, { 189 | title: "foobar" 190 | }), "\\n", /*#__PURE__*/_jsxs(_components.p, { 191 | children: ["world ", 2 + 1] 192 | }), "\\n", /*#__PURE__*/_jsx(Story, { 193 | name: "foo", 194 | children: "bar" 195 | })] 196 | }); 197 | } 198 | function MDXContent(props = {}) { 199 | const { 200 | wrapper: MDXLayout 201 | } = Object.assign({}, _provideComponents(), props.components); 202 | return MDXLayout ? /*#__PURE__*/_jsx(MDXLayout, { 203 | ...props, 204 | children: /*#__PURE__*/_jsx(_createMdxContent, { 205 | ...props 206 | }) 207 | }) : _createMdxContent(props); 208 | } 209 | function _missingMdxReference(id, component) { 210 | throw new Error("Expected " + (component ? "component" : "object") + " \`" + id + "\` to be defined: you likely forgot to import, pass, or provide it."); 211 | } 212 | /* ========= */ 213 | export const foo = () => "bar"; 214 | foo.storyName = 'foo'; 215 | foo.parameters = { 216 | storySource: { 217 | source: '\\"bar\\"' 218 | } 219 | }; 220 | const componentMeta = { 221 | title: 'foobar', 222 | tags: ['stories-mdx'], 223 | includeStories: ["foo"] 224 | }; 225 | componentMeta.parameters = componentMeta.parameters || {}; 226 | componentMeta.parameters.docs = { 227 | ...(componentMeta.parameters.docs || {}), 228 | page: MDXContent 229 | }; 230 | export default componentMeta; 231 | `); 232 | }); 233 | it('sync & async should match', async () => { 234 | const input = dedent` 235 | # hello 236 | 237 | 238 | 239 | world {2 + 1} 240 | 241 | bar 242 | `; 243 | // @ts-ignore 244 | const ou1 = compileSync(input); 245 | const ou2 = await compile(input); 246 | 247 | expect(ou1).toEqual(ou2); 248 | }); 249 | 250 | it('canvas with story', () => { 251 | const input = dedent` 252 | import { Canvas, Meta, Story } from '@storybook/addon-docs'; 253 | 254 | 255 | 256 | 257 | 258 |
I'm a story
259 |
260 |
261 | `; 262 | expect(compileSync(input)).toMatchInlineSnapshot(` 263 | import { useMDXComponents as _provideComponents } from "@mdx-js/react"; 264 | import { Canvas, Meta, Story } from '@storybook/addon-docs'; 265 | import { jsx as _jsx } from "react/jsx-runtime"; 266 | import { Fragment as _Fragment } from "react/jsx-runtime"; 267 | import { jsxs as _jsxs } from "react/jsx-runtime"; 268 | function _createMdxContent(props) { 269 | return /*#__PURE__*/_jsxs(_Fragment, { 270 | children: [/*#__PURE__*/_jsx(Meta, { 271 | title: "MDX/Badge" 272 | }), "\\n", /*#__PURE__*/_jsx(Canvas, { 273 | children: /*#__PURE__*/_jsx(Story, { 274 | name: "foo", 275 | children: /*#__PURE__*/_jsx("div", { 276 | children: "I'm a story" 277 | }) 278 | }) 279 | })] 280 | }); 281 | } 282 | function MDXContent(props = {}) { 283 | const { 284 | wrapper: MDXLayout 285 | } = Object.assign({}, _provideComponents(), props.components); 286 | return MDXLayout ? /*#__PURE__*/_jsx(MDXLayout, { 287 | ...props, 288 | children: /*#__PURE__*/_jsx(_createMdxContent, { 289 | ...props 290 | }) 291 | }) : _createMdxContent(props); 292 | } 293 | /* ========= */ 294 | export const foo = () => /*#__PURE__*/_jsx("div", { 295 | children: "I'm a story" 296 | }); 297 | foo.storyName = 'foo'; 298 | foo.parameters = { 299 | storySource: { 300 | source: '
{\\"I\\'m a story\\"}
' 301 | } 302 | }; 303 | const componentMeta = { 304 | title: 'MDX/Badge', 305 | tags: ['stories-mdx'], 306 | includeStories: ["foo"] 307 | }; 308 | componentMeta.parameters = componentMeta.parameters || {}; 309 | componentMeta.parameters.docs = { 310 | ...(componentMeta.parameters.docs || {}), 311 | page: MDXContent 312 | }; 313 | export default componentMeta; 314 | `); 315 | }); 316 | 317 | it('canvas without story children', () => { 318 | const input = dedent` 319 | import { Canvas } from '@storybook/addon-docs'; 320 | 321 | 322 |

Some here

323 |
324 | `; 325 | expect(compileSync(input)).toMatchInlineSnapshot(` 326 | import { useMDXComponents as _provideComponents } from "@mdx-js/react"; 327 | import { Canvas } from '@storybook/addon-docs'; 328 | import { jsx as _jsx } from "react/jsx-runtime"; 329 | function _createMdxContent(props) { 330 | return /*#__PURE__*/_jsx(Canvas, { 331 | mdxSource: "

{\\"Some here\\"}

", 332 | children: /*#__PURE__*/_jsx("h2", { 333 | children: "Some here" 334 | }) 335 | }); 336 | } 337 | function MDXContent(props = {}) { 338 | const { 339 | wrapper: MDXLayout 340 | } = Object.assign({}, _provideComponents(), props.components); 341 | return MDXLayout ? /*#__PURE__*/_jsx(MDXLayout, { 342 | ...props, 343 | children: /*#__PURE__*/_jsx(_createMdxContent, { 344 | ...props 345 | }) 346 | }) : _createMdxContent(props); 347 | } 348 | /* ========= */ 349 | const componentMeta = { 350 | includeStories: [] 351 | }; 352 | componentMeta.parameters = componentMeta.parameters || {}; 353 | componentMeta.parameters.docs = { 354 | ...(componentMeta.parameters.docs || {}), 355 | page: MDXContent 356 | }; 357 | export default componentMeta; 358 | `); 359 | }); 360 | }); 361 | 362 | describe('docs-mdx-compiler-plugin', () => { 363 | it('component-args.mdx', () => { 364 | expect( 365 | clean(dedent` 366 | import { Button } from '@storybook/react/demo'; 367 | import { Story, Meta } from '@storybook/addon-docs'; 368 | 369 | 370 | 371 | # Args 372 | 373 | 374 | 375 | 376 | `) 377 | ).toMatchInlineSnapshot(` 378 | export const componentNotes = () => 379 | /*#__PURE__*/ _jsx(Button, { 380 | children: 'Component notes', 381 | }); 382 | componentNotes.storyName = 'component notes'; 383 | componentNotes.parameters = { 384 | storySource: { 385 | source: '', 386 | }, 387 | }; 388 | const componentMeta = { 389 | title: 'Button', 390 | args: { 391 | a: 1, 392 | b: 2, 393 | }, 394 | argTypes: { 395 | a: { 396 | name: 'A', 397 | }, 398 | b: { 399 | name: 'B', 400 | }, 401 | }, 402 | tags: ['stories-mdx'], 403 | includeStories: ['componentNotes'], 404 | }; 405 | componentMeta.parameters = componentMeta.parameters || {}; 406 | componentMeta.parameters.docs = { 407 | ...(componentMeta.parameters.docs || {}), 408 | page: MDXContent, 409 | }; 410 | export default componentMeta; 411 | `); 412 | }); 413 | 414 | it('component-id.mdx', () => { 415 | expect( 416 | clean(dedent` 417 | import { Button } from '@storybook/react/demo'; 418 | import { Story, Meta } from '@storybook/addon-docs'; 419 | 420 | 421 | 422 | 423 | 424 | 425 | `) 426 | ).toMatchInlineSnapshot(` 427 | export const componentNotes = () => 428 | /*#__PURE__*/ _jsx(Button, { 429 | children: 'Component notes', 430 | }); 431 | componentNotes.storyName = 'component notes'; 432 | componentNotes.parameters = { 433 | storySource: { 434 | source: '', 435 | }, 436 | }; 437 | const componentMeta = { 438 | title: 'Button', 439 | id: 'button-id', 440 | component: Button, 441 | tags: ['stories-mdx'], 442 | includeStories: ['componentNotes'], 443 | }; 444 | componentMeta.parameters = componentMeta.parameters || {}; 445 | componentMeta.parameters.docs = { 446 | ...(componentMeta.parameters.docs || {}), 447 | page: MDXContent, 448 | }; 449 | export default componentMeta; 450 | `); 451 | }); 452 | 453 | it('csf-imports.mdx', () => { 454 | expect( 455 | clean(dedent` 456 | import { Story, Meta, Canvas } from '@storybook/addon-docs'; 457 | import { Welcome, Button } from '@storybook/angular/demo'; 458 | import * as MyStories from './My.stories'; 459 | import { Other } from './Other.stories'; 460 | 461 | 462 | 463 | # Stories from CSF imports 464 | 465 | 466 | 467 | 468 | 469 | 470 | 471 | 472 | `) 473 | ).toMatchInlineSnapshot(` 474 | export const _Basic_ = MyStories.Basic; 475 | export const _Other_ = Other; 476 | export const _Foo_ = MyStories.Foo; 477 | _Foo_.storyName = 'renamed'; 478 | const componentMeta = { 479 | title: 'MDX/CSF imports', 480 | tags: ['stories-mdx'], 481 | includeStories: ['_Basic_', '_Other_', '_Foo_'], 482 | }; 483 | componentMeta.parameters = componentMeta.parameters || {}; 484 | componentMeta.parameters.docs = { 485 | ...(componentMeta.parameters.docs || {}), 486 | page: MDXContent, 487 | }; 488 | export default componentMeta; 489 | `); 490 | }); 491 | 492 | it('decorators.mdx', () => { 493 | expect( 494 | clean(dedent` 495 | import { Button } from '@storybook/react/demo'; 496 | import { Story, Meta } from '@storybook/addon-docs'; 497 | 498 |
{storyFn()}
]} 501 | /> 502 | 503 | # Decorated story 504 | 505 |
{storyFn()}
]}> 506 | 507 |
508 | `) 509 | ).toMatchInlineSnapshot(` 510 | export const one = () => 511 | /*#__PURE__*/ _jsx(Button, { 512 | children: 'One', 513 | }); 514 | one.storyName = 'one'; 515 | one.parameters = { 516 | storySource: { 517 | source: '', 518 | }, 519 | }; 520 | one.decorators = [ 521 | (storyFn) => 522 | /*#__PURE__*/ _jsx('div', { 523 | className: 'local', 524 | children: storyFn(), 525 | }), 526 | ]; 527 | const componentMeta = { 528 | title: 'Button', 529 | decorators: [ 530 | (storyFn) => 531 | /*#__PURE__*/ _jsx('div', { 532 | style: { 533 | backgroundColor: 'yellow', 534 | }, 535 | children: storyFn(), 536 | }), 537 | ], 538 | tags: ['stories-mdx'], 539 | includeStories: ['one'], 540 | }; 541 | componentMeta.parameters = componentMeta.parameters || {}; 542 | componentMeta.parameters.docs = { 543 | ...(componentMeta.parameters.docs || {}), 544 | page: MDXContent, 545 | }; 546 | export default componentMeta; 547 | `); 548 | }); 549 | 550 | it('docs-only.mdx', () => { 551 | expect( 552 | clean(dedent` 553 | import { Meta } from '@storybook/addon-docs'; 554 | 555 | 556 | 557 | # Documentation only 558 | 559 | This is a documentation-only MDX file which cleans a dummy 'docsOnly: true' story. 560 | `) 561 | ).toMatchInlineSnapshot(` 562 | export const __page = () => { 563 | throw new Error('Docs-only story'); 564 | }; 565 | __page.parameters = { 566 | docsOnly: true, 567 | }; 568 | const componentMeta = { 569 | title: 'docs-only', 570 | tags: ['stories-mdx'], 571 | includeStories: ['__page'], 572 | }; 573 | componentMeta.parameters = componentMeta.parameters || {}; 574 | componentMeta.parameters.docs = { 575 | ...(componentMeta.parameters.docs || {}), 576 | page: MDXContent, 577 | }; 578 | export default componentMeta; 579 | `); 580 | }); 581 | 582 | it('loaders.mdx', () => { 583 | expect( 584 | clean(dedent` 585 | import { Button } from '@storybook/react/demo'; 586 | import { Story, Meta } from '@storybook/addon-docs'; 587 | 588 | ({ foo: 1 })]} /> 589 | 590 | # Story with loader 591 | 592 | ({ bar: 2 })]}> 593 | 594 | 595 | `) 596 | ).toMatchInlineSnapshot(` 597 | export const one = () => 598 | /*#__PURE__*/ _jsx(Button, { 599 | children: 'One', 600 | }); 601 | one.storyName = 'one'; 602 | one.parameters = { 603 | storySource: { 604 | source: '', 605 | }, 606 | }; 607 | one.loaders = [ 608 | async () => ({ 609 | bar: 2, 610 | }), 611 | ]; 612 | const componentMeta = { 613 | title: 'Button', 614 | loaders: [ 615 | async () => ({ 616 | foo: 1, 617 | }), 618 | ], 619 | tags: ['stories-mdx'], 620 | includeStories: ['one'], 621 | }; 622 | componentMeta.parameters = componentMeta.parameters || {}; 623 | componentMeta.parameters.docs = { 624 | ...(componentMeta.parameters.docs || {}), 625 | page: MDXContent, 626 | }; 627 | export default componentMeta; 628 | `); 629 | }); 630 | 631 | it('meta-quotes-in-title.mdx', () => { 632 | expect( 633 | clean(dedent` 634 | import { Meta } from '@storybook/addon-docs'; 635 | 636 | 637 | `) 638 | ).toMatchInlineSnapshot(` 639 | export const __page = () => { 640 | throw new Error('Docs-only story'); 641 | }; 642 | __page.parameters = { 643 | docsOnly: true, 644 | }; 645 | const componentMeta = { 646 | title: "Addons/Docs/what's in a title?", 647 | tags: ['stories-mdx'], 648 | includeStories: ['__page'], 649 | }; 650 | componentMeta.parameters = componentMeta.parameters || {}; 651 | componentMeta.parameters.docs = { 652 | ...(componentMeta.parameters.docs || {}), 653 | page: MDXContent, 654 | }; 655 | export default componentMeta; 656 | `); 657 | }); 658 | 659 | it('non-story-exports.mdx', () => { 660 | expect( 661 | clean(dedent` 662 | import { Button } from '@storybook/react/demo'; 663 | import { Story, Meta } from '@storybook/addon-docs'; 664 | 665 | 666 | 667 | # Story definition 668 | 669 | 670 | 671 | 672 | 673 | export const two = 2; 674 | 675 | 676 | 677 | 678 | `) 679 | ).toMatchInlineSnapshot(` 680 | export const one = () => 681 | /*#__PURE__*/ _jsx(Button, { 682 | children: 'One', 683 | }); 684 | one.storyName = 'one'; 685 | one.parameters = { 686 | storySource: { 687 | source: '', 688 | }, 689 | }; 690 | export const helloStory = () => 691 | /*#__PURE__*/ _jsx(Button, { 692 | children: 'Hello button', 693 | }); 694 | helloStory.storyName = 'hello story'; 695 | helloStory.parameters = { 696 | storySource: { 697 | source: '', 698 | }, 699 | }; 700 | const componentMeta = { 701 | title: 'Button', 702 | tags: ['stories-mdx'], 703 | includeStories: ['one', 'helloStory'], 704 | }; 705 | componentMeta.parameters = componentMeta.parameters || {}; 706 | componentMeta.parameters.docs = { 707 | ...(componentMeta.parameters.docs || {}), 708 | page: MDXContent, 709 | }; 710 | export default componentMeta; 711 | `); 712 | }); 713 | 714 | it('parameters.mdx', () => { 715 | expect( 716 | clean(dedent` 717 | import { Button } from '@storybook/react/demo'; 718 | import { Story, Meta } from '@storybook/addon-docs'; 719 | 720 | 721 | 722 | 723 | 724 | 725 | 726 | 727 | 728 | 729 | `) 730 | ).toMatchInlineSnapshot(` 731 | export const componentNotes = () => 732 | /*#__PURE__*/ _jsx(Button, { 733 | children: 'Component notes', 734 | }); 735 | componentNotes.storyName = 'component notes'; 736 | componentNotes.parameters = { 737 | storySource: { 738 | source: '', 739 | }, 740 | }; 741 | export const storyNotes = () => 742 | /*#__PURE__*/ _jsx(Button, { 743 | children: 'Story notes', 744 | }); 745 | storyNotes.storyName = 'story notes'; 746 | storyNotes.parameters = { 747 | storySource: { 748 | source: '', 749 | }, 750 | ...{ 751 | notes: 'story notes', 752 | }, 753 | }; 754 | const componentMeta = { 755 | title: 'Button', 756 | parameters: { 757 | notes: 'component notes', 758 | }, 759 | component: Button, 760 | tags: ['stories-mdx'], 761 | includeStories: ['componentNotes', 'storyNotes'], 762 | }; 763 | componentMeta.parameters = componentMeta.parameters || {}; 764 | componentMeta.parameters.docs = { 765 | ...(componentMeta.parameters.docs || {}), 766 | page: MDXContent, 767 | }; 768 | export default componentMeta; 769 | `); 770 | }); 771 | 772 | it('previews.mdx', () => { 773 | expect( 774 | clean(dedent` 775 | import { Button } from '@storybook/react/demo'; 776 | import { Canvas, Story, Meta } from '@storybook/addon-docs'; 777 | 778 | 779 | 780 | # Canvas 781 | 782 | Canvases can contain normal components, stories, and story references 783 | 784 | 785 | 786 | 787 | 788 | 789 | 790 | 791 | 792 | 793 | 794 | 795 | Canvas without a story 796 | 797 | 798 | 799 | 800 | `) 801 | ).toMatchInlineSnapshot(` 802 | export const helloButton = () => 803 | /*#__PURE__*/ _jsx(Button, { 804 | children: 'Hello button', 805 | }); 806 | helloButton.storyName = 'hello button'; 807 | helloButton.parameters = { 808 | storySource: { 809 | source: '', 810 | }, 811 | }; 812 | export const two = () => 813 | /*#__PURE__*/ _jsx(Button, { 814 | children: 'Two', 815 | }); 816 | two.storyName = 'two'; 817 | two.parameters = { 818 | storySource: { 819 | source: '', 820 | }, 821 | }; 822 | const componentMeta = { 823 | title: 'Button', 824 | parameters: { 825 | notes: 'component notes', 826 | }, 827 | component: Button, 828 | tags: ['stories-mdx'], 829 | includeStories: ['helloButton', 'two'], 830 | }; 831 | componentMeta.parameters = componentMeta.parameters || {}; 832 | componentMeta.parameters.docs = { 833 | ...(componentMeta.parameters.docs || {}), 834 | page: MDXContent, 835 | }; 836 | export default componentMeta; 837 | `); 838 | }); 839 | 840 | it('story-args.mdx', () => { 841 | expect( 842 | clean(dedent` 843 | import { Button } from '@storybook/react/demo'; 844 | import { Story, Meta } from '@storybook/addon-docs'; 845 | 846 | 847 | 848 | # Args 849 | 850 | export const Template = (args) => ; 851 | 852 | 857 | {Template.bind({})} 858 | 859 | `) 860 | ).toMatchInlineSnapshot(` 861 | export const componentNotes = Template.bind({}); 862 | componentNotes.storyName = 'component notes'; 863 | componentNotes.argTypes = { 864 | a: { 865 | name: 'A', 866 | }, 867 | b: { 868 | name: 'B', 869 | }, 870 | }; 871 | componentNotes.args = { 872 | a: 1, 873 | b: 2, 874 | }; 875 | componentNotes.parameters = { 876 | storySource: { 877 | source: 'args => ', 878 | }, 879 | }; 880 | const componentMeta = { 881 | title: 'Button', 882 | tags: ['stories-mdx'], 883 | includeStories: ['componentNotes'], 884 | }; 885 | componentMeta.parameters = componentMeta.parameters || {}; 886 | componentMeta.parameters.docs = { 887 | ...(componentMeta.parameters.docs || {}), 888 | page: MDXContent, 889 | }; 890 | export default componentMeta; 891 | `); 892 | }); 893 | 894 | it('story-current.mdx', () => { 895 | expect( 896 | clean(dedent` 897 | import { Story } from '@storybook/addon-docs'; 898 | 899 | # Current story 900 | 901 | 902 | `) 903 | ).toMatchInlineSnapshot(` 904 | const componentMeta = { 905 | includeStories: [], 906 | }; 907 | componentMeta.parameters = componentMeta.parameters || {}; 908 | componentMeta.parameters.docs = { 909 | ...(componentMeta.parameters.docs || {}), 910 | page: MDXContent, 911 | }; 912 | export default componentMeta; 913 | `); 914 | }); 915 | 916 | it('story-def-text-only.mdx', () => { 917 | expect( 918 | clean(dedent` 919 | import { Story, Meta } from '@storybook/addon-docs'; 920 | 921 | 922 | 923 | # Story definition 924 | 925 | Plain text 926 | `) 927 | ).toMatchInlineSnapshot(` 928 | export const text = () => 'Plain text'; 929 | text.storyName = 'text'; 930 | text.parameters = { 931 | storySource: { 932 | source: '"Plain text"', 933 | }, 934 | }; 935 | const componentMeta = { 936 | title: 'Text', 937 | tags: ['stories-mdx'], 938 | includeStories: ['text'], 939 | }; 940 | componentMeta.parameters = componentMeta.parameters || {}; 941 | componentMeta.parameters.docs = { 942 | ...(componentMeta.parameters.docs || {}), 943 | page: MDXContent, 944 | }; 945 | export default componentMeta; 946 | `); 947 | }); 948 | 949 | it('story-definitions.mdx', () => { 950 | expect( 951 | clean(dedent` 952 | import { Button } from '@storybook/react/demo'; 953 | import { Story, Meta } from '@storybook/addon-docs'; 954 | 955 | 956 | 957 | # Story definition 958 | 959 | 960 | 961 | 962 | 963 | 964 | 965 | 966 | 967 | 968 | 969 | 970 | 971 | 972 | 973 | 974 | `) 975 | ).toMatchInlineSnapshot(` 976 | export const one = () => 977 | /*#__PURE__*/ _jsx(Button, { 978 | children: 'One', 979 | }); 980 | one.storyName = 'one'; 981 | one.parameters = { 982 | storySource: { 983 | source: '', 984 | }, 985 | }; 986 | export const helloStory = () => 987 | /*#__PURE__*/ _jsx(Button, { 988 | children: 'Hello button', 989 | }); 990 | helloStory.storyName = 'hello story'; 991 | helloStory.parameters = { 992 | storySource: { 993 | source: '', 994 | }, 995 | }; 996 | export const wPunctuation = () => 997 | /*#__PURE__*/ _jsx(Button, { 998 | children: 'with punctuation', 999 | }); 1000 | wPunctuation.storyName = 'w/punctuation'; 1001 | wPunctuation.parameters = { 1002 | storySource: { 1003 | source: '', 1004 | }, 1005 | }; 1006 | export const _1FineDay = () => 1007 | /*#__PURE__*/ _jsx(Button, { 1008 | children: 'starts with number', 1009 | }); 1010 | _1FineDay.storyName = '1 fine day'; 1011 | _1FineDay.parameters = { 1012 | storySource: { 1013 | source: '', 1014 | }, 1015 | }; 1016 | const componentMeta = { 1017 | title: 'Button', 1018 | tags: ['stories-mdx'], 1019 | includeStories: ['one', 'helloStory', 'wPunctuation', '_1FineDay'], 1020 | }; 1021 | componentMeta.parameters = componentMeta.parameters || {}; 1022 | componentMeta.parameters.docs = { 1023 | ...(componentMeta.parameters.docs || {}), 1024 | page: MDXContent, 1025 | }; 1026 | export default componentMeta; 1027 | `); 1028 | }); 1029 | 1030 | it('story-function-var.mdx', () => { 1031 | expect( 1032 | clean(dedent` 1033 | import { Meta, Story } from '@storybook/addon-docs'; 1034 | 1035 | 1036 | 1037 | export const basicFn = () => 1269 | `) 1270 | ).toMatchInlineSnapshot(` 1271 | const componentMeta = { 1272 | includeStories: [], 1273 | }; 1274 | componentMeta.parameters = componentMeta.parameters || {}; 1275 | componentMeta.parameters.docs = { 1276 | ...(componentMeta.parameters.docs || {}), 1277 | page: MDXContent, 1278 | }; 1279 | export default componentMeta; 1280 | `); 1281 | }); 1282 | 1283 | it('errors on missing story props', async () => { 1284 | await expect(async () => 1285 | clean(dedent` 1286 | import { Button } from '@storybook/react/demo'; 1287 | import { Story, Meta } from '@storybook/addon-docs'; 1288 | 1289 | 1290 | 1291 | # Bad story 1292 | 1293 | 1294 | 1295 | 1296 | `) 1297 | ).rejects.toThrow('Expected a Story name, id, or story attribute'); 1298 | }); 1299 | 1300 | it("errors on story 'of' prop", async () => { 1301 | await expect(async () => 1302 | clean(dedent` 1303 | import * as MyStories from './My.stories'; 1304 | import { Story, Meta } from '@storybook/addon-docs'; 1305 | 1306 | 1307 | 1308 | # Bad story 1309 | 1310 | 1311 | `) 1312 | ).rejects.toThrow(`The 'of' prop is not supported in .stories.mdx files, only .mdx files. 1313 | See https://storybook.js.org/docs/7.0/react/writing-docs/mdx on how to write MDX files and stories separately.`); 1314 | }); 1315 | 1316 | it("errors on meta 'of' prop", async () => { 1317 | await expect(async () => 1318 | clean(dedent` 1319 | import * as MyStories from './My.stories'; 1320 | import { Meta } from '@storybook/addon-docs'; 1321 | 1322 | 1323 | `) 1324 | ).rejects.toThrow(`The 'of' prop is not supported in .stories.mdx files, only .mdx files. 1325 | See https://storybook.js.org/docs/7.0/react/writing-docs/mdx on how to write MDX files and stories separately.`); 1326 | }); 1327 | 1328 | describe('csf3', () => { 1329 | it('auto-title-docs-only.mdx', () => { 1330 | expect( 1331 | clean(dedent` 1332 | import { Meta } from '@storybook/addon-docs'; 1333 | 1334 | 1335 | 1336 | # Auto-title Docs Only 1337 | 1338 | Spme **markdown** here! 1339 | `) 1340 | ).toMatchInlineSnapshot(` 1341 | export const __page = () => { 1342 | throw new Error('Docs-only story'); 1343 | }; 1344 | __page.parameters = { 1345 | docsOnly: true, 1346 | }; 1347 | const componentMeta = { 1348 | tags: ['stories-mdx'], 1349 | includeStories: ['__page'], 1350 | }; 1351 | componentMeta.parameters = componentMeta.parameters || {}; 1352 | componentMeta.parameters.docs = { 1353 | ...(componentMeta.parameters.docs || {}), 1354 | page: MDXContent, 1355 | }; 1356 | export default componentMeta; 1357 | `); 1358 | }); 1359 | 1360 | it('auto-title.mdx', () => { 1361 | expect( 1362 | clean(dedent` 1363 | import { Button } from '@storybook/react/demo'; 1364 | import { Story, Meta } from '@storybook/addon-docs'; 1365 | 1366 | 1367 | 1368 | 1369 | 1370 | 1371 | `) 1372 | ).toMatchInlineSnapshot(` 1373 | export const basic = () => 1374 | /*#__PURE__*/ _jsx(Button, { 1375 | children: 'Basic', 1376 | }); 1377 | basic.storyName = 'Basic'; 1378 | basic.parameters = { 1379 | storySource: { 1380 | source: '', 1381 | }, 1382 | }; 1383 | const componentMeta = { 1384 | component: Button, 1385 | tags: ['stories-mdx'], 1386 | includeStories: ['basic'], 1387 | }; 1388 | componentMeta.parameters = componentMeta.parameters || {}; 1389 | componentMeta.parameters.docs = { 1390 | ...(componentMeta.parameters.docs || {}), 1391 | page: MDXContent, 1392 | }; 1393 | export default componentMeta; 1394 | `); 1395 | }); 1396 | 1397 | it('default-render.mdx', () => { 1398 | expect( 1399 | clean(dedent` 1400 | import { Button } from '@storybook/react/demo'; 1401 | import { Story, Meta } from '@storybook/addon-docs'; 1402 | 1403 | 1404 | 1405 | 1406 | `) 1407 | ).toMatchInlineSnapshot(` 1408 | export const basic = {}; 1409 | basic.storyName = 'Basic'; 1410 | basic.parameters = { 1411 | storySource: { 1412 | source: '{}', 1413 | }, 1414 | }; 1415 | const componentMeta = { 1416 | title: 'Button', 1417 | component: Button, 1418 | tags: ['stories-mdx'], 1419 | includeStories: ['basic'], 1420 | }; 1421 | componentMeta.parameters = componentMeta.parameters || {}; 1422 | componentMeta.parameters.docs = { 1423 | ...(componentMeta.parameters.docs || {}), 1424 | page: MDXContent, 1425 | }; 1426 | export default componentMeta; 1427 | `); 1428 | }); 1429 | 1430 | it('component-render.mdx', () => { 1431 | expect( 1432 | clean(dedent` 1433 | import { Button } from '@storybook/react/demo'; 1434 | import { Story, Meta } from '@storybook/addon-docs'; 1435 | 1436 |