diff --git a/code/frameworks/react-vite/package.json b/code/frameworks/react-vite/package.json index 1d3cfc67a144..5673d4caf9dd 100644 --- a/code/frameworks/react-vite/package.json +++ b/code/frameworks/react-vite/package.json @@ -50,10 +50,13 @@ "@joshwooding/vite-plugin-react-docgen-typescript": "0.3.0", "@rollup/pluginutils": "^5.0.2", "@storybook/builder-vite": "workspace:*", + "@storybook/node-logger": "workspace:*", "@storybook/react": "workspace:*", + "find-up": "^5.0.0", "magic-string": "^0.30.0", "react-docgen": "^7.0.0", - "resolve": "^1.22.8" + "resolve": "^1.22.8", + "tsconfig-paths": "^4.2.0" }, "devDependencies": { "@types/node": "^18.0.0", diff --git a/code/frameworks/react-vite/src/plugins/react-docgen.test.ts b/code/frameworks/react-vite/src/plugins/react-docgen.test.ts new file mode 100644 index 000000000000..1fed686198b9 --- /dev/null +++ b/code/frameworks/react-vite/src/plugins/react-docgen.test.ts @@ -0,0 +1,52 @@ +import { getReactDocgenImporter } from './react-docgen'; +import { describe, it, expect, vi } from 'vitest'; + +const reactDocgenMock = vi.hoisted(() => { + return { + makeFsImporter: vi.fn().mockImplementation((fn) => fn), + }; +}); + +const reactDocgenResolverMock = vi.hoisted(() => { + return { + defaultLookupModule: vi.fn(), + }; +}); + +vi.mock('./docgen-resolver', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + defaultLookupModule: reactDocgenResolverMock.defaultLookupModule, + }; +}); + +vi.mock('react-docgen', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + makeFsImporter: reactDocgenMock.makeFsImporter, + }; +}); + +describe('getReactDocgenImporter function', () => { + it('should not map the request if a tsconfig path mapping is not available', () => { + const filename = './src/components/Button.tsx'; + const basedir = '/src'; + const imported = getReactDocgenImporter(undefined); + reactDocgenResolverMock.defaultLookupModule.mockImplementation((filen: string) => filen); + const result = (imported as any)(filename, basedir); + expect(result).toBe(filename); + }); + + it('should map the request', () => { + const mappedFile = './mapped-file.tsx'; + const matchPath = vi.fn().mockReturnValue(mappedFile); + const filename = './src/components/Button.tsx'; + const basedir = '/src'; + const imported = getReactDocgenImporter(matchPath); + reactDocgenResolverMock.defaultLookupModule.mockImplementation((filen: string) => filen); + const result = (imported as any)(filename, basedir); + expect(result).toBe(mappedFile); + }); +}); diff --git a/code/frameworks/react-vite/src/plugins/react-docgen.ts b/code/frameworks/react-vite/src/plugins/react-docgen.ts index 9d2242ce2654..c59861e4ff43 100644 --- a/code/frameworks/react-vite/src/plugins/react-docgen.ts +++ b/code/frameworks/react-vite/src/plugins/react-docgen.ts @@ -10,12 +10,15 @@ import { } from 'react-docgen'; import MagicString from 'magic-string'; import type { PluginOption } from 'vite'; +import * as TsconfigPaths from 'tsconfig-paths'; +import findUp from 'find-up'; import actualNameHandler from './docgen-handlers/actualNameHandler'; import { RESOLVE_EXTENSIONS, ReactDocgenResolveError, defaultLookupModule, } from './docgen-resolver'; +import { logger } from '@storybook/node-logger'; type DocObj = Documentation & { actualName: string }; @@ -29,13 +32,27 @@ type Options = { exclude?: string | RegExp | (string | RegExp)[]; }; -export function reactDocgen({ +export async function reactDocgen({ include = /\.(mjs|tsx?|jsx?)$/, exclude = [/node_modules\/.*/], -}: Options = {}): PluginOption { +}: Options = {}): Promise { const cwd = process.cwd(); const filter = createFilter(include, exclude); + const tsconfigPath = await findUp('tsconfig.json', { cwd }); + const tsconfig = TsconfigPaths.loadConfig(tsconfigPath); + + let matchPath: TsconfigPaths.MatchPath | undefined; + + if (tsconfig.resultType === 'success') { + logger.info('Using tsconfig paths for react-docgen'); + matchPath = TsconfigPaths.createMatchPath(tsconfig.absoluteBaseUrl, tsconfig.paths, [ + 'browser', + 'module', + 'main', + ]); + } + return { name: 'storybook:react-docgen-plugin', enforce: 'pre', @@ -48,15 +65,7 @@ export function reactDocgen({ const docgenResults = parse(src, { resolver: defaultResolver, handlers, - importer: makeFsImporter((filename, basedir) => { - const result = defaultLookupModule(filename, basedir); - - if (RESOLVE_EXTENSIONS.find((ext) => result.endsWith(ext))) { - return result; - } - - throw new ReactDocgenResolveError(filename); - }), + importer: getReactDocgenImporter(matchPath), filename: id, }) as DocObj[]; const s = new MagicString(src); @@ -83,3 +92,24 @@ export function reactDocgen({ }, }; } + +export function getReactDocgenImporter(matchPath: TsconfigPaths.MatchPath | undefined) { + return makeFsImporter((filename, basedir) => { + const mappedFilenameByPaths = (() => { + if (matchPath) { + const match = matchPath(filename); + return match || filename; + } else { + return filename; + } + })(); + + const result = defaultLookupModule(mappedFilenameByPaths, basedir); + + if (RESOLVE_EXTENSIONS.find((ext) => result.endsWith(ext))) { + return result; + } + + throw new ReactDocgenResolveError(filename); + }); +} diff --git a/code/frameworks/react-vite/src/preset.ts b/code/frameworks/react-vite/src/preset.ts index 35a83a306ce0..cbdde0f61a07 100644 --- a/code/frameworks/react-vite/src/preset.ts +++ b/code/frameworks/react-vite/src/preset.ts @@ -43,7 +43,7 @@ export const viteFinal: StorybookConfig['viteFinal'] = async (config, { presets // Needs to run before the react plugin, so add to the front plugins.unshift( // If react-docgen is specified, use it for everything, otherwise only use it for non-typescript files - reactDocgen({ + await reactDocgen({ include: reactDocgenOption === 'react-docgen' ? /\.(mjs|tsx?|jsx?)$/ : /\.(mjs|jsx?)$/, }) ); diff --git a/code/presets/react-webpack/package.json b/code/presets/react-webpack/package.json index bb7d68782f68..b3f4377f9da2 100644 --- a/code/presets/react-webpack/package.json +++ b/code/presets/react-webpack/package.json @@ -71,11 +71,13 @@ "@storybook/react-docgen-typescript-plugin": "1.0.6--canary.9.0c3f3b7.0", "@types/node": "^18.0.0", "@types/semver": "^7.3.4", + "find-up": "^5.0.0", "fs-extra": "^11.1.0", "magic-string": "^0.30.5", "react-docgen": "^7.0.0", "resolve": "^1.22.8", "semver": "^7.3.7", + "tsconfig-paths": "^4.2.0", "webpack": "5" }, "devDependencies": { diff --git a/code/presets/react-webpack/src/loaders/react-docgen-loader.test.ts b/code/presets/react-webpack/src/loaders/react-docgen-loader.test.ts new file mode 100644 index 000000000000..cb017a7469b7 --- /dev/null +++ b/code/presets/react-webpack/src/loaders/react-docgen-loader.test.ts @@ -0,0 +1,52 @@ +import { getReactDocgenImporter } from './react-docgen-loader'; +import { describe, it, expect, vi } from 'vitest'; + +const reactDocgenMock = vi.hoisted(() => { + return { + makeFsImporter: vi.fn().mockImplementation((fn) => fn), + }; +}); + +const reactDocgenResolverMock = vi.hoisted(() => { + return { + defaultLookupModule: vi.fn(), + }; +}); + +vi.mock('./docgen-resolver', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + defaultLookupModule: reactDocgenResolverMock.defaultLookupModule, + }; +}); + +vi.mock('react-docgen', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + makeFsImporter: reactDocgenMock.makeFsImporter, + }; +}); + +describe('getReactDocgenImporter function', () => { + it('should not map the request if a tsconfig path mapping is not available', () => { + const filename = './src/components/Button.tsx'; + const basedir = '/src'; + const imported = getReactDocgenImporter(undefined); + reactDocgenResolverMock.defaultLookupModule.mockImplementation((filen: string) => filen); + const result = (imported as any)(filename, basedir); + expect(result).toBe(filename); + }); + + it('should map the request', () => { + const mappedFile = './mapped-file.tsx'; + const matchPath = vi.fn().mockReturnValue(mappedFile); + const filename = './src/components/Button.tsx'; + const basedir = '/src'; + const imported = getReactDocgenImporter(matchPath); + reactDocgenResolverMock.defaultLookupModule.mockImplementation((filen: string) => filen); + const result = (imported as any)(filename, basedir); + expect(result).toBe(mappedFile); + }); +}); diff --git a/code/presets/react-webpack/src/loaders/react-docgen-loader.ts b/code/presets/react-webpack/src/loaders/react-docgen-loader.ts index 12ab911fd546..15b71f19bfd5 100644 --- a/code/presets/react-webpack/src/loaders/react-docgen-loader.ts +++ b/code/presets/react-webpack/src/loaders/react-docgen-loader.ts @@ -6,6 +6,8 @@ import { ERROR_CODES, utils, } from 'react-docgen'; +import * as TsconfigPaths from 'tsconfig-paths'; +import findUp from 'find-up'; import MagicString from 'magic-string'; import type { LoaderContext } from 'webpack'; import type { Handler, NodePath, babelTypes as t, Documentation } from 'react-docgen'; @@ -62,6 +64,9 @@ const defaultHandlers = Object.values(docgenHandlers).map((handler) => handler); const defaultResolver = new docgenResolver.FindExportedDefinitionsResolver(); const handlers = [...defaultHandlers, actualNameHandler]; +let tsconfigPathsInitialized = false; +let matchPath: TsconfigPaths.MatchPath | undefined; + export default async function reactDocgenLoader( this: LoaderContext<{ debug: boolean }>, source: string @@ -71,20 +76,28 @@ export default async function reactDocgenLoader( const options = this.getOptions() || {}; const { debug = false } = options; + if (!tsconfigPathsInitialized) { + const tsconfigPath = await findUp('tsconfig.json', { cwd: process.cwd() }); + const tsconfig = TsconfigPaths.loadConfig(tsconfigPath); + + if (tsconfig.resultType === 'success') { + logger.info('Using tsconfig paths for react-docgen'); + matchPath = TsconfigPaths.createMatchPath(tsconfig.absoluteBaseUrl, tsconfig.paths, [ + 'browser', + 'module', + 'main', + ]); + } + + tsconfigPathsInitialized = true; + } + try { const docgenResults = parse(source, { filename: this.resourcePath, resolver: defaultResolver, handlers, - importer: makeFsImporter((filename, basedir) => { - const result = defaultLookupModule(filename, basedir); - - if (RESOLVE_EXTENSIONS.find((ext) => result.endsWith(ext))) { - return result; - } - - throw new ReactDocgenResolveError(filename); - }), + importer: getReactDocgenImporter(matchPath), babelOptions: { babelrc: false, configFile: false, @@ -122,3 +135,24 @@ export default async function reactDocgenLoader( } } } + +export function getReactDocgenImporter(matchingPath: TsconfigPaths.MatchPath | undefined) { + return makeFsImporter((filename, basedir) => { + const mappedFilenameByPaths = (() => { + if (matchingPath) { + const match = matchingPath(filename); + return match || filename; + } else { + return filename; + } + })(); + + const result = defaultLookupModule(mappedFilenameByPaths, basedir); + + if (RESOLVE_EXTENSIONS.find((ext) => result.endsWith(ext))) { + return result; + } + + throw new ReactDocgenResolveError(filename); + }); +} diff --git a/code/yarn.lock b/code/yarn.lock index 039c28a0c940..dfbdf6fca037 100644 --- a/code/yarn.lock +++ b/code/yarn.lock @@ -6340,11 +6340,13 @@ __metadata: "@storybook/react-docgen-typescript-plugin": "npm:1.0.6--canary.9.0c3f3b7.0" "@types/node": "npm:^18.0.0" "@types/semver": "npm:^7.3.4" + find-up: "npm:^5.0.0" fs-extra: "npm:^11.1.0" magic-string: "npm:^0.30.5" react-docgen: "npm:^7.0.0" resolve: "npm:^1.22.8" semver: "npm:^7.3.7" + tsconfig-paths: "npm:^4.2.0" typescript: "npm:^5.3.2" webpack: "npm:5" peerDependencies: @@ -6488,11 +6490,14 @@ __metadata: "@joshwooding/vite-plugin-react-docgen-typescript": "npm:0.3.0" "@rollup/pluginutils": "npm:^5.0.2" "@storybook/builder-vite": "workspace:*" + "@storybook/node-logger": "workspace:*" "@storybook/react": "workspace:*" "@types/node": "npm:^18.0.0" + find-up: "npm:^5.0.0" magic-string: "npm:^0.30.0" react-docgen: "npm:^7.0.0" resolve: "npm:^1.22.8" + tsconfig-paths: "npm:^4.2.0" typescript: "npm:^5.3.2" vite: "npm:^4.0.0" peerDependencies: @@ -28381,7 +28386,7 @@ __metadata: languageName: node linkType: hard -"tsconfig-paths@npm:^4.0.0, tsconfig-paths@npm:^4.1.2": +"tsconfig-paths@npm:^4.0.0, tsconfig-paths@npm:^4.1.2, tsconfig-paths@npm:^4.2.0": version: 4.2.0 resolution: "tsconfig-paths@npm:4.2.0" dependencies: