diff --git a/code/addons/docs/src/typings.d.ts b/code/addons/docs/src/typings.d.ts index 48d1127304af..79397caba109 100644 --- a/code/addons/docs/src/typings.d.ts +++ b/code/addons/docs/src/typings.d.ts @@ -1,7 +1,6 @@ declare module '@egoist/vue-to-react'; declare module 'remark-slug'; declare module 'remark-external-links'; -declare module 'babel-plugin-react-docgen'; declare module 'acorn-jsx'; declare module 'vue/dist/vue'; declare module '@storybook/mdx1-csf'; diff --git a/code/jest.config.js b/code/jest.config.js index a3527914bcb8..671e41cb139d 100644 --- a/code/jest.config.js +++ b/code/jest.config.js @@ -5,6 +5,7 @@ module.exports = { '/lib/*', '/builders/*', '/renderers/*', + '/presets/*', '/ui/!(node_modules)*', ], collectCoverage: false, diff --git a/code/lib/docs-tools/package.json b/code/lib/docs-tools/package.json index eccf6a7a2bd5..1e3f8704c2d1 100644 --- a/code/lib/docs-tools/package.json +++ b/code/lib/docs-tools/package.json @@ -54,6 +54,7 @@ }, "devDependencies": { "@babel/core": "^7.23.2", + "babel-plugin-react-docgen": "4.2.1", "jest-specific-snapshot": "^8.0.0", "require-from-string": "^2.0.2", "typescript": "~4.9.3" diff --git a/code/presets/create-react-app/package.json b/code/presets/create-react-app/package.json index 836f66bead47..8fc74208c661 100644 --- a/code/presets/create-react-app/package.json +++ b/code/presets/create-react-app/package.json @@ -53,7 +53,6 @@ "@storybook/react-docgen-typescript-plugin": "1.0.6--canary.9.0c3f3b7.0", "@storybook/types": "workspace:*", "@types/babel__core": "^7.1.7", - "babel-plugin-react-docgen": "^4.1.0", "pnp-webpack-plugin": "^1.7.0", "semver": "^7.3.5" }, diff --git a/code/presets/react-webpack/jest.config.js b/code/presets/react-webpack/jest.config.js new file mode 100644 index 000000000000..343e4c7a7f32 --- /dev/null +++ b/code/presets/react-webpack/jest.config.js @@ -0,0 +1,7 @@ +const path = require('path'); +const baseConfig = require('../../jest.config.node'); + +module.exports = { + ...baseConfig, + displayName: __dirname.split(path.sep).slice(-2).join(path.posix.sep), +}; diff --git a/code/presets/react-webpack/package.json b/code/presets/react-webpack/package.json index 45ff00764144..c7be54baa76e 100644 --- a/code/presets/react-webpack/package.json +++ b/code/presets/react-webpack/package.json @@ -47,6 +47,11 @@ "require": "./dist/framework-preset-react.js", "import": "./dist/framework-preset-react.mjs" }, + "./dist/loaders/react-docgen-loader": { + "types": "./dist/loaders/react-docgen-loader.d.ts", + "require": "./dist/loaders/react-docgen-loader.js", + "import": "./dist/loaders/react-docgen-loader.mjs" + }, "./package.json": "./package.json" }, "main": "dist/index.js", @@ -75,8 +80,9 @@ "@types/node": "^18.0.0", "@types/semver": "^7.3.4", "babel-plugin-add-react-displayname": "^0.0.5", - "babel-plugin-react-docgen": "^4.2.1", "fs-extra": "^11.1.0", + "magic-string": "^0.30.5", + "react-docgen": "^7.0.0", "react-refresh": "^0.11.0", "semver": "^7.3.7", "webpack": "5" @@ -108,7 +114,8 @@ "./src/index.ts", "./src/framework-preset-cra.ts", "./src/framework-preset-react-docs.ts", - "./src/framework-preset-react.ts" + "./src/framework-preset-react.ts", + "./src/loaders/react-docgen-loader.ts" ], "platform": "node" }, diff --git a/code/presets/react-webpack/src/framework-preset-react-docs.test.ts b/code/presets/react-webpack/src/framework-preset-react-docs.test.ts index 991f084746ba..9ec5fd3bd029 100644 --- a/code/presets/react-webpack/src/framework-preset-react-docs.test.ts +++ b/code/presets/react-webpack/src/framework-preset-react-docs.test.ts @@ -1,20 +1,22 @@ import ReactDocgenTypescriptPlugin from '@storybook/react-docgen-typescript-plugin'; import type { TypescriptOptions } from '@storybook/core-webpack'; +import type { Configuration } from 'webpack'; import * as preset from './framework-preset-react-docs'; +jest.mock('./requirer', () => ({ + requirer: (resolver: any, path: string) => path, +})); + describe('framework-preset-react-docgen', () => { - const babelPluginReactDocgenPath = require.resolve('babel-plugin-react-docgen'); const presetsListWithDocs = [{ name: '@storybook/addon-docs', options: {}, preset: null }]; + // mock requirer + describe('react-docgen', () => { - it('should return the babel config with the extra plugin', async () => { - const babelConfig = { - babelrc: false, - presets: ['env', 'foo-preset'], - plugins: ['foo-plugin'], - }; + it('should return the webpack config with the extra webpack loader', async () => { + const webpackConfig: Configuration = {}; - const config = await preset.babel?.(babelConfig, { + const config = await preset.webpackFinal?.(webpackConfig, { presets: { apply: async () => ({ @@ -26,15 +28,15 @@ describe('framework-preset-react-docgen', () => { } as any); expect(config).toEqual({ - babelrc: false, - plugins: ['foo-plugin'], - presets: ['env', 'foo-preset'], - overrides: [ - { - test: /\.(cjs|mjs|tsx?|jsx?)$/, - plugins: [[babelPluginReactDocgenPath]], - }, - ], + module: { + rules: [ + { + exclude: /node_modules\/.*/, + loader: '@storybook/preset-react-webpack/dist/loaders/react-docgen-loader', + test: /\.(cjs|mjs|tsx?|jsx?)$/, + }, + ], + }, }); }); }); @@ -58,6 +60,15 @@ describe('framework-preset-react-docgen', () => { }); expect(config).toEqual({ + module: { + rules: [ + { + exclude: /node_modules\/.*/, + loader: '@storybook/preset-react-webpack/dist/loaders/react-docgen-loader', + test: /\.(cjs|mjs|jsx?)$/, + }, + ], + }, plugins: [expect.any(ReactDocgenTypescriptPlugin)], }); }); @@ -65,27 +76,10 @@ describe('framework-preset-react-docgen', () => { describe('no docgen', () => { it('should not add any extra plugins', async () => { - const babelConfig = { - babelrc: false, - presets: ['env', 'foo-preset'], - plugins: ['foo-plugin'], - }; - const webpackConfig = { plugins: [], }; - const outputBabelconfig = await preset.babel?.(babelConfig, { - presets: { - // @ts-expect-error (Converted from ts-ignore) - apply: async () => - ({ - check: false, - reactDocgen: false, - } as Partial), - }, - presetsList: presetsListWithDocs, - }); const outputWebpackconfig = await preset.webpackFinal?.(webpackConfig, { presets: { // @ts-expect-error (Converted from ts-ignore) @@ -98,40 +92,16 @@ describe('framework-preset-react-docgen', () => { presetsList: presetsListWithDocs, }); - expect(outputBabelconfig).toEqual({ - babelrc: false, - presets: ['env', 'foo-preset'], - plugins: ['foo-plugin'], - }); - expect(outputWebpackconfig).toEqual({ - plugins: [], - }); + expect(outputWebpackconfig).toEqual({ plugins: [] }); }); }); describe('no docs or controls addon used', () => { it('should not add any extra plugins', async () => { - const babelConfig = { - babelrc: false, - presets: ['env', 'foo-preset'], - plugins: ['foo-plugin'], - }; - const webpackConfig = { plugins: [], }; - const outputBabelconfig = await preset.babel?.(babelConfig, { - presets: { - // @ts-expect-error (Converted from ts-ignore) - apply: async () => - ({ - check: false, - reactDocgen: 'react-docgen-typescript', - } as Partial), - }, - presetsList: [], - }); const outputWebpackconfig = await preset.webpackFinal?.(webpackConfig, { presets: { // @ts-expect-error (Converted from ts-ignore) @@ -144,11 +114,6 @@ describe('framework-preset-react-docgen', () => { presetsList: [], }); - expect(outputBabelconfig).toEqual({ - babelrc: false, - presets: ['env', 'foo-preset'], - plugins: ['foo-plugin'], - }); expect(outputWebpackconfig).toEqual({ plugins: [], }); diff --git a/code/presets/react-webpack/src/framework-preset-react-docs.ts b/code/presets/react-webpack/src/framework-preset-react-docs.ts index eb0d3ed5f9e3..3538c2c8dbad 100644 --- a/code/presets/react-webpack/src/framework-preset-react-docs.ts +++ b/code/presets/react-webpack/src/framework-preset-react-docs.ts @@ -1,8 +1,13 @@ import { hasDocsOrControls } from '@storybook/docs-tools'; +import type { Configuration } from 'webpack'; import type { StorybookConfig } from './types'; +import { requirer } from './requirer'; -export const babel: StorybookConfig['babel'] = async (config, options) => { +export const webpackFinal: StorybookConfig['webpackFinal'] = async ( + config, + options +): Promise => { if (!hasDocsOrControls(options)) return config; const typescriptOptions = await options.presets.apply( @@ -10,42 +15,50 @@ export const babel: StorybookConfig['babel'] = async (config, options) => { {} as any ); - const { reactDocgen } = typescriptOptions || {}; + const { reactDocgen, reactDocgenTypescriptOptions } = typescriptOptions || {}; if (typeof reactDocgen !== 'string') { return config; } - return { - ...config, - overrides: [ - ...(config?.overrides || []), - { - test: reactDocgen === 'react-docgen' ? /\.(cjs|mjs|tsx?|jsx?)$/ : /\.(cjs|mjs|jsx?)$/, - plugins: [[require.resolve('babel-plugin-react-docgen')]], - }, - ], - }; -}; - -export const webpackFinal: StorybookConfig['webpackFinal'] = async (config, options) => { - if (!hasDocsOrControls(options)) return config; - - const typescriptOptions = await options.presets.apply( - 'typescript', - {} as any - ); - - const { reactDocgen, reactDocgenTypescriptOptions } = typescriptOptions || {}; - if (reactDocgen !== 'react-docgen-typescript') { - return config; + return { + ...config, + module: { + ...(config.module ?? {}), + rules: [ + ...(config.module?.rules ?? []), + { + test: /\.(cjs|mjs|tsx?|jsx?)$/, + loader: requirer( + require.resolve, + '@storybook/preset-react-webpack/dist/loaders/react-docgen-loader' + ), + exclude: /node_modules\/.*/, + }, + ], + }, + }; } const { ReactDocgenTypeScriptPlugin } = await import('@storybook/react-docgen-typescript-plugin'); return { ...config, + module: { + ...(config.module ?? {}), + rules: [ + ...(config.module?.rules ?? []), + { + test: /\.(cjs|mjs|jsx?)$/, + loader: requirer( + require.resolve, + '@storybook/preset-react-webpack/dist/loaders/react-docgen-loader' + ), + exclude: /node_modules\/.*/, + }, + ], + }, plugins: [ ...(config.plugins || []), new ReactDocgenTypeScriptPlugin({ diff --git a/code/presets/react-webpack/src/loaders/react-docgen-loader.ts b/code/presets/react-webpack/src/loaders/react-docgen-loader.ts new file mode 100644 index 000000000000..279840739014 --- /dev/null +++ b/code/presets/react-webpack/src/loaders/react-docgen-loader.ts @@ -0,0 +1,89 @@ +import { + parse, + builtinResolvers as docgenResolver, + builtinHandlers as docgenHandlers, + builtinImporters as docgenImporters, + ERROR_CODES, + utils, +} from 'react-docgen'; +import MagicString from 'magic-string'; +import type { LoaderContext } from 'webpack'; +import type { Handler, NodePath, babelTypes as t, Documentation } from 'react-docgen'; + +const { getNameOrValue, isReactForwardRefCall } = utils; + +const actualNameHandler: Handler = function actualNameHandler(documentation, componentDefinition) { + if ( + (componentDefinition.isClassDeclaration() || componentDefinition.isFunctionDeclaration()) && + componentDefinition.has('id') + ) { + documentation.set( + 'actualName', + getNameOrValue(componentDefinition.get('id') as NodePath) + ); + } else if ( + componentDefinition.isArrowFunctionExpression() || + componentDefinition.isFunctionExpression() || + isReactForwardRefCall(componentDefinition) + ) { + let currentPath: NodePath = componentDefinition; + + while (currentPath.parentPath) { + if (currentPath.parentPath.isVariableDeclarator()) { + documentation.set('actualName', getNameOrValue(currentPath.parentPath.get('id'))); + return; + } + if (currentPath.parentPath.isAssignmentExpression()) { + const leftPath = currentPath.parentPath.get('left'); + + if (leftPath.isIdentifier() || leftPath.isLiteral()) { + documentation.set('actualName', getNameOrValue(leftPath)); + return; + } + } + + currentPath = currentPath.parentPath; + } + // Could not find an actual name + documentation.set('actualName', ''); + } +}; + +type DocObj = Documentation & { actualName: string }; + +const defaultHandlers = Object.values(docgenHandlers).map((handler) => handler); +const defaultResolver = new docgenResolver.FindExportedDefinitionsResolver(); +const defaultImporter = docgenImporters.fsImporter; +const handlers = [...defaultHandlers, actualNameHandler]; + +export default async function reactDocgenLoader(this: LoaderContext, source: string) { + const callback = this.async(); + + try { + const docgenResults = parse(source, { + filename: this.resourcePath, + resolver: defaultResolver, + handlers, + importer: defaultImporter, + }) as DocObj[]; + + const magicString = new MagicString(source); + + docgenResults.forEach((info) => { + const { actualName, ...docgenInfo } = info; + if (actualName) { + const docNode = JSON.stringify(docgenInfo); + magicString.append(`;${actualName}.__docgenInfo=${docNode}`); + } + }); + + const map = magicString.generateMap({ hires: true }); + callback(null, magicString.toString(), map); + } catch (error: any) { + if (error.code === ERROR_CODES.MISSING_DEFINITION) { + callback(null, source); + } else { + callback(error); + } + } +} diff --git a/code/presets/react-webpack/src/requirer.ts b/code/presets/react-webpack/src/requirer.ts new file mode 100644 index 000000000000..64c43cb6da12 --- /dev/null +++ b/code/presets/react-webpack/src/requirer.ts @@ -0,0 +1,4 @@ +// Use it in favour of require.resolve() to be able to mock it in tests. +export function requirer(resolver: (path: string) => string, path: string) { + return resolver(path); +} diff --git a/code/yarn.lock b/code/yarn.lock index 4ea9b0a2dd7b..e6edb2eb9d61 100644 --- a/code/yarn.lock +++ b/code/yarn.lock @@ -2371,9 +2371,9 @@ __metadata: languageName: node linkType: hard -"@babel/traverse@npm:^7.1.6, @babel/traverse@npm:^7.18.9, @babel/traverse@npm:^7.4.5, @babel/traverse@npm:^7.7.0": - version: 7.23.0 - resolution: "@babel/traverse@npm:7.23.0" +"@babel/traverse@npm:^7.1.6, @babel/traverse@npm:^7.23.2": + version: 7.23.2 + resolution: "@babel/traverse@npm:7.23.2" dependencies: "@babel/code-frame": "npm:^7.22.13" "@babel/generator": "npm:^7.23.0" @@ -2385,13 +2385,13 @@ __metadata: "@babel/types": "npm:^7.23.0" debug: "npm:^4.1.0" globals: "npm:^11.1.0" - checksum: 84f93e64179965a0de6109a8b1ce92d66eb52a76e8ba325d27bdec6952cedd8fc98eabf09fe443ef667a051300dc7ed8924e7bf61a87ad456501d1da46657509 + checksum: d096c7c4bab9262a2f658298a3c630ae4a15a10755bb257ae91d5ab3e3b2877438934859c8d34018b7727379fe6b26c4fa2efc81cf4c462a7fe00caf79fa02ff languageName: node linkType: hard -"@babel/traverse@npm:^7.23.2": - version: 7.23.2 - resolution: "@babel/traverse@npm:7.23.2" +"@babel/traverse@npm:^7.18.9, @babel/traverse@npm:^7.4.5, @babel/traverse@npm:^7.7.0": + version: 7.23.0 + resolution: "@babel/traverse@npm:7.23.0" dependencies: "@babel/code-frame": "npm:^7.22.13" "@babel/generator": "npm:^7.23.0" @@ -2403,7 +2403,7 @@ __metadata: "@babel/types": "npm:^7.23.0" debug: "npm:^4.1.0" globals: "npm:^11.1.0" - checksum: d096c7c4bab9262a2f658298a3c630ae4a15a10755bb257ae91d5ab3e3b2877438934859c8d34018b7727379fe6b26c4fa2efc81cf4c462a7fe00caf79fa02ff + checksum: 84f93e64179965a0de6109a8b1ce92d66eb52a76e8ba325d27bdec6952cedd8fc98eabf09fe443ef667a051300dc7ed8924e7bf61a87ad456501d1da46657509 languageName: node linkType: hard @@ -6607,6 +6607,7 @@ __metadata: "@storybook/types": "workspace:*" "@types/doctrine": "npm:^0.0.3" assert: "npm:^2.1.0" + babel-plugin-react-docgen: "npm:4.2.1" doctrine: "npm:^3.0.0" jest-specific-snapshot: "npm:^8.0.0" lodash: "npm:^4.17.21" @@ -7014,7 +7015,6 @@ __metadata: "@storybook/types": "workspace:*" "@types/babel__core": "npm:^7.1.7" "@types/node": "npm:^18.0.0" - babel-plugin-react-docgen: "npm:^4.1.0" pnp-webpack-plugin: "npm:^1.7.0" semver: "npm:^7.3.5" typescript: "npm:~4.9.3" @@ -7069,8 +7069,9 @@ __metadata: "@types/node": "npm:^18.0.0" "@types/semver": "npm:^7.3.4" babel-plugin-add-react-displayname: "npm:^0.0.5" - babel-plugin-react-docgen: "npm:^4.2.1" fs-extra: "npm:^11.1.0" + magic-string: "npm:^0.30.5" + react-docgen: "npm:^7.0.0" react-refresh: "npm:^0.11.0" semver: "npm:^7.3.7" typescript: "npm:~4.9.3" @@ -11548,7 +11549,7 @@ __metadata: languageName: node linkType: hard -"babel-plugin-react-docgen@npm:^4.1.0, babel-plugin-react-docgen@npm:^4.2.1": +"babel-plugin-react-docgen@npm:4.2.1": version: 4.2.1 resolution: "babel-plugin-react-docgen@npm:4.2.1" dependencies: @@ -30096,7 +30097,18 @@ __metadata: languageName: node linkType: hard -"v8-to-istanbul@npm:^9.0.0, v8-to-istanbul@npm:^9.0.1": +"v8-to-istanbul@npm:^9.0.0": + version: 9.1.3 + resolution: "v8-to-istanbul@npm:9.1.3" + dependencies: + "@jridgewell/trace-mapping": "npm:^0.3.12" + "@types/istanbul-lib-coverage": "npm:^2.0.1" + convert-source-map: "npm:^2.0.0" + checksum: 7acfc460731b629a0d547b231e9d510aaa826df67f4deeaeeb991b492f78faf3bb1aa4b54fa0f9b06d815bc69eb0a04a6c2180c16ba43a83cc5e5490fa160a96 + languageName: node + linkType: hard + +"v8-to-istanbul@npm:^9.0.1": version: 9.1.2 resolution: "v8-to-istanbul@npm:9.1.2" dependencies: