From 1cd8b5bde20741af01873d68dd64ec158de56a6c Mon Sep 17 00:00:00 2001 From: fi3ework Date: Mon, 29 Jul 2024 23:28:50 +0800 Subject: [PATCH] feat: support react-docgen-typescript in another way --- README.md | 2 +- packages/builder-rsbuild/tests/dummy.test.ts | 32 ---- packages/react-rsbuild/package.json | 1 + .../src/loaders/docgen-resolver.ts | 4 + .../src/loaders/react-docgen-loader.test.ts | 60 ++++++++ .../src/loaders/react-docgen-loader.ts | 4 + .../docgen-handlers/actualNameHandler.ts | 67 +++++++++ .../src/plugins/docgen-resolver.ts | 78 ++++++++++ .../src/plugins/react-docgen.test.ts | 60 ++++++++ .../react-rsbuild/src/plugins/react-docgen.ts | 138 ++++++++++++++++++ packages/react-rsbuild/src/react-docs.ts | 22 ++- pnpm-lock.yaml | 26 +++- sandboxes/react-18/.storybook/main.ts | 2 +- 13 files changed, 457 insertions(+), 39 deletions(-) delete mode 100644 packages/builder-rsbuild/tests/dummy.test.ts create mode 100644 packages/react-rsbuild/src/loaders/react-docgen-loader.test.ts create mode 100644 packages/react-rsbuild/src/plugins/docgen-handlers/actualNameHandler.ts create mode 100644 packages/react-rsbuild/src/plugins/docgen-resolver.ts create mode 100644 packages/react-rsbuild/src/plugins/react-docgen.test.ts create mode 100644 packages/react-rsbuild/src/plugins/react-docgen.ts diff --git a/README.md b/README.md index 2256891..d32b02d 100644 --- a/README.md +++ b/README.md @@ -235,7 +235,7 @@ export default { - [x] Support lazy compilation - [ ] Support virtual modules - [ ] Support `module.unknownContextCritical` -- [ ] Support `compilation.dependencyTemplates.set` for react-docgen-typescript 🌟 +- [x] Support `compilation.dependencyTemplates.set` for react-docgen-typescript (supported in a workaround) ## Credits diff --git a/packages/builder-rsbuild/tests/dummy.test.ts b/packages/builder-rsbuild/tests/dummy.test.ts deleted file mode 100644 index dd05323..0000000 --- a/packages/builder-rsbuild/tests/dummy.test.ts +++ /dev/null @@ -1,32 +0,0 @@ -import type { Presets } from 'storybook/internal/types' -import { describe, expect } from 'vitest' -import type { RsbuildBuilderOptions } from '../src/preview/iframe-rsbuild.config' - -// @ts-expect-error TODO -const dummyOptions: RsbuildBuilderOptions = { - configType: 'DEVELOPMENT', - configDir: '', - packageJson: {}, - presets: { - apply: async (key: string) => - ({ - framework: { - name: '', - }, - addons: [], - core: { - builder: {}, - }, - options: {}, - })[key], - } as Presets, - presetsList: [], - typescriptOptions: { - check: false, - skipCompiler: true, - }, -} - -describe('dummy', () => { - expect(1).toBe(1) -}) diff --git a/packages/react-rsbuild/package.json b/packages/react-rsbuild/package.json index 6df3ddd..9a177fa 100644 --- a/packages/react-rsbuild/package.json +++ b/packages/react-rsbuild/package.json @@ -44,6 +44,7 @@ "prepare": "pnpm run build" }, "dependencies": { + "@rollup/pluginutils": "^5.1.0", "@storybook/react": "^8.2.1", "@storybook/react-docgen-typescript-plugin": "^1.0.1", "@types/node": "^18.0.0", diff --git a/packages/react-rsbuild/src/loaders/docgen-resolver.ts b/packages/react-rsbuild/src/loaders/docgen-resolver.ts index bb1d837..6d5df9f 100644 --- a/packages/react-rsbuild/src/loaders/docgen-resolver.ts +++ b/packages/react-rsbuild/src/loaders/docgen-resolver.ts @@ -1,3 +1,7 @@ +/** + * Code taken from https://github.com/storybookjs/storybook/tree/next/code/presets/react-webpack/src/loaders + */ + import { extname } from 'node:path' import resolve from 'resolve' diff --git a/packages/react-rsbuild/src/loaders/react-docgen-loader.test.ts b/packages/react-rsbuild/src/loaders/react-docgen-loader.test.ts new file mode 100644 index 0000000..b078c7b --- /dev/null +++ b/packages/react-rsbuild/src/loaders/react-docgen-loader.test.ts @@ -0,0 +1,60 @@ +/** + * Code taken from https://github.com/storybookjs/storybook/tree/next/code/presets/react-webpack/src/loaders + */ + +import { describe, expect, it, vi } from 'vitest' +import { getReactDocgenImporter } from './react-docgen-loader' + +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/packages/react-rsbuild/src/loaders/react-docgen-loader.ts b/packages/react-rsbuild/src/loaders/react-docgen-loader.ts index 0824bd1..21013d6 100644 --- a/packages/react-rsbuild/src/loaders/react-docgen-loader.ts +++ b/packages/react-rsbuild/src/loaders/react-docgen-loader.ts @@ -1,3 +1,7 @@ +/** + * Code taken from https://github.com/storybookjs/storybook/tree/next/code/presets/react-webpack/src/loaders + */ + import findUp from 'find-up' import MagicString from 'magic-string' import { diff --git a/packages/react-rsbuild/src/plugins/docgen-handlers/actualNameHandler.ts b/packages/react-rsbuild/src/plugins/docgen-handlers/actualNameHandler.ts new file mode 100644 index 0000000..58c0935 --- /dev/null +++ b/packages/react-rsbuild/src/plugins/docgen-handlers/actualNameHandler.ts @@ -0,0 +1,67 @@ +/** + * Code taken from https://github.com/storybookjs/storybook/tree/next/code/frameworks/react-vite/src/plugins + */ + +/** + * This is heavily based on the react-docgen `displayNameHandler` + * (https://github.com/reactjs/react-docgen/blob/26c90c0dd105bf83499a83826f2a6ff7a724620d/src/handlers/displayNameHandler.ts) + * but instead defines an `actualName` property on the generated docs that is taken first from the component's actual name. + * This addresses an issue where the name that the generated docs are stored under is incorrectly named with the `displayName` + * and not the component's actual name. + * + * This is inspired by `actualNameHandler` from https://github.com/storybookjs/babel-plugin-react-docgen, but is modified + * directly from displayNameHandler, using the same approach as babel-plugin-react-docgen. + */ + +import type { Handler, NodePath, babelTypes as t } from 'react-docgen' +import { utils } from 'react-docgen' + +const { getNameOrValue, isReactForwardRefCall } = utils + +const actualNameHandler: Handler = function actualNameHandler( + documentation, + componentDefinition, +) { + documentation.set('definedInFile', componentDefinition.hub.file.opts.filename) + + 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', '') + } +} + +export default actualNameHandler diff --git a/packages/react-rsbuild/src/plugins/docgen-resolver.ts b/packages/react-rsbuild/src/plugins/docgen-resolver.ts new file mode 100644 index 0000000..83a023f --- /dev/null +++ b/packages/react-rsbuild/src/plugins/docgen-resolver.ts @@ -0,0 +1,78 @@ +/** + * Code taken from https://github.com/storybookjs/storybook/tree/next/code/frameworks/react-vite/src/plugins + */ + +import { extname } from 'node:path' +import resolve from 'resolve' + +export class ReactDocgenResolveError extends Error { + // the magic string that react-docgen uses to check if a module is ignored + readonly code = 'MODULE_NOT_FOUND' + + constructor(filename: string) { + super(`'${filename}' was ignored by react-docgen.`) + } +} + +/* The below code was copied from: + * https://github.com/reactjs/react-docgen/blob/df2daa8b6f0af693ecc3c4dc49f2246f60552bcb/packages/react-docgen/src/importer/makeFsImporter.ts#L14-L63 + * because it wasn't exported from the react-docgen package. + * watch out: when updating this code, also update the code in code/presets/react-webpack/src/loaders/docgen-resolver.ts + */ + +// These extensions are sorted by priority +// resolve() will check for files in the order these extensions are sorted +export const RESOLVE_EXTENSIONS = [ + '.js', + '.cts', // These were originally not in the code, I added them + '.mts', // These were originally not in the code, I added them + '.ctsx', // These were originally not in the code, I added them + '.mtsx', // These were originally not in the code, I added them + '.ts', + '.tsx', + '.mjs', + '.cjs', + '.mts', + '.cts', + '.jsx', +] + +export function defaultLookupModule(filename: string, basedir: string): string { + const resolveOptions = { + basedir, + extensions: RESOLVE_EXTENSIONS, + // we do not need to check core modules as we cannot import them anyway + includeCoreModules: false, + } + + try { + return resolve.sync(filename, resolveOptions) + } catch (error) { + const ext = extname(filename) + let newFilename: string + + // if we try to import a JavaScript file it might be that we are actually pointing to + // a TypeScript file. This can happen in ES modules as TypeScript requires to import other + // TypeScript files with .js extensions + // https://www.typescriptlang.org/docs/handbook/esm-node.html#type-in-packagejson-and-new-extensions + switch (ext) { + case '.js': + case '.mjs': + case '.cjs': + newFilename = `${filename.slice(0, -2)}ts` + break + + case '.jsx': + newFilename = `${filename.slice(0, -3)}tsx` + break + default: + throw error + } + + return resolve.sync(newFilename, { + ...resolveOptions, + // we already know that there is an extension at this point, so no need to check other extensions + extensions: [], + }) + } +} diff --git a/packages/react-rsbuild/src/plugins/react-docgen.test.ts b/packages/react-rsbuild/src/plugins/react-docgen.test.ts new file mode 100644 index 0000000..9e081ea --- /dev/null +++ b/packages/react-rsbuild/src/plugins/react-docgen.test.ts @@ -0,0 +1,60 @@ +/** + * Code taken from https://github.com/storybookjs/storybook/tree/next/code/frameworks/react-vite/src/plugins + */ + +import { describe, expect, it, vi } from 'vitest' +import { getReactDocgenImporter } from './react-docgen' + +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/packages/react-rsbuild/src/plugins/react-docgen.ts b/packages/react-rsbuild/src/plugins/react-docgen.ts new file mode 100644 index 0000000..9e5ebda --- /dev/null +++ b/packages/react-rsbuild/src/plugins/react-docgen.ts @@ -0,0 +1,138 @@ +/** + * Code taken from https://github.com/storybookjs/storybook/tree/next/code/frameworks/react-vite/src/plugins + */ + +import path from 'node:path' +import { createFilter } from '@rollup/pluginutils' +import type { RsbuildPlugin } from '@rsbuild/core' +import findUp from 'find-up' +import MagicString from 'magic-string' +import type { Documentation } from 'react-docgen' +import { + ERROR_CODES, + builtinHandlers as docgenHandlers, + builtinResolvers as docgenResolver, + makeFsImporter, + parse, +} from 'react-docgen' +import { logger } from 'storybook/internal/node-logger' +import * as TsconfigPaths from 'tsconfig-paths' +import actualNameHandler from './docgen-handlers/actualNameHandler' +import { + RESOLVE_EXTENSIONS, + ReactDocgenResolveError, + defaultLookupModule, +} from './docgen-resolver' + +type DocObj = Documentation & { actualName: string; definedInFile: string } + +// TODO: None of these are able to be overridden, so `default` is aspirational here. +const defaultHandlers = Object.values(docgenHandlers).map((handler) => handler) +const defaultResolver = new docgenResolver.FindExportedDefinitionsResolver() +const handlers = [...defaultHandlers, actualNameHandler] + +type Options = { + include?: string | RegExp | (string | RegExp)[] + exclude?: string | RegExp | (string | RegExp)[] +} + +export async function reactDocgen({ + include = /\.(mjs|tsx?|jsx?)$/, + exclude = [/node_modules\/.*/], +}: 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', + setup(api) { + api.transform( + { + test: (id) => { + if (!filter(path.relative(cwd, id))) { + return false + } + + return true + }, + }, + async ({ code: src, resource: id }) => { + // return '' + // transform(src: string, id: string) { + // if (!filter(path.relative(cwd, id))) { + // return + // } + try { + const docgenResults = parse(src, { + resolver: defaultResolver, + handlers, + importer: getReactDocgenImporter(matchPath), + filename: id, + }) as DocObj[] + const s = new MagicString(src) + // biome-ignore lint/complexity/noForEach: + docgenResults.forEach((info) => { + const { actualName, definedInFile, ...docgenInfo } = info + // biome-ignore lint/suspicious/noDoubleEquals: + if (actualName && definedInFile == id) { + const docNode = JSON.stringify(docgenInfo) + s.append(`;${actualName}.__docgenInfo=${docNode}`) + } + }) + + return { + code: s.toString(), + map: s.generateMap({ hires: true, source: id }).toString(), + } + } catch (e: any) { + // Ignore the error when react-docgen cannot find a react component + if (e.code === ERROR_CODES.MISSING_DEFINITION) { + return src + } + throw e + } + // } + }, + ) + }, + } +} + +export function getReactDocgenImporter( + matchPath: TsconfigPaths.MatchPath | undefined, +) { + return makeFsImporter((filename, basedir) => { + const mappedFilenameByPaths = (() => { + if (matchPath) { + const match = matchPath(filename) + return match || filename + // biome-ignore lint/style/noUselessElse: + } 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/packages/react-rsbuild/src/react-docs.ts b/packages/react-rsbuild/src/react-docs.ts index e2d9dba..74347b1 100644 --- a/packages/react-rsbuild/src/react-docs.ts +++ b/packages/react-rsbuild/src/react-docs.ts @@ -44,11 +44,27 @@ export const rsbuildFinalDocs: NonNullable< }) } - // TODO: Rspack doesn't support the hooks `react-docgen-typescript`' required - throw new Error( - "Rspack didn't support the hooks `react-docgen-typescript`' required", + const { reactDocgen: reactDocGenPlugin } = await import( + './plugins/react-docgen' ) + // TODO: Rspack doesn't support the hooks `react-docgen-typescript`' required. + // Currently, using `transform` hook to implement the same behavior. + return mergeRsbuildConfig(config, { + plugins: [ + await reactDocGenPlugin({ + include: + reactDocgen === 'react-docgen-typescript' + ? /\.(mjs|tsx?|jsx?)$/ + : /\.(mjs|jsx?)$/, + }), + ], + }) + + // throw new Error( + // "Rspack didn't support the hooks `react-docgen-typescript`' required", + // ) + // const { ReactDocgenTypeScriptPlugin } = await import( // '@storybook/react-docgen-typescript-plugin' // ) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b10c3ea..19cbb70 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -138,6 +138,9 @@ importers: packages/react-rsbuild: dependencies: + '@rollup/pluginutils': + specifier: ^5.1.0 + version: 5.1.0(rollup@4.17.2) '@storybook/react': specifier: ^8.2.1 version: 8.2.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(storybook@8.2.1(@babel/preset-env@7.24.5))(typescript@5.4.5) @@ -266,7 +269,7 @@ importers: version: 8.2.1(@babel/preset-env@7.24.5) storybook-react-rsbuild: specifier: workspace:* - version: file:packages/react-rsbuild(@rsbuild/core@1.0.1-beta.0)(react-dom@16.14.0(react@16.14.0))(react@16.14.0)(storybook@8.2.1(@babel/preset-env@7.24.5))(typescript@5.4.5)(webpack@5.93.0) + version: file:packages/react-rsbuild(@rsbuild/core@1.0.1-beta.0)(react-dom@16.14.0(react@16.14.0))(react@16.14.0)(rollup@4.17.2)(storybook@8.2.1(@babel/preset-env@7.24.5))(typescript@5.4.5)(webpack@5.93.0) typescript: specifier: ^5.3.2 version: 5.4.5 @@ -1601,6 +1604,15 @@ packages: resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==} engines: {node: '>=14'} + '@rollup/pluginutils@5.1.0': + resolution: {integrity: sha512-XTIWOPPcpvyKI6L1NHo0lFlCyznUEyPmPY1mc3KpPVDYulHSTvyeLNVW00QTLIAFNhR3kYnJTQHeGqU4M3n09g==} + engines: {node: '>=14.0.0'} + peerDependencies: + rollup: ^1.20.0||^2.0.0||^3.0.0||^4.0.0 + peerDependenciesMeta: + rollup: + optional: true + '@rollup/rollup-android-arm-eabi@4.17.2': resolution: {integrity: sha512-NM0jFxY8bB8QLkoKxIQeObCaDlJKewVlIEkuyYKm5An1tdVZ966w2+MPQ2l8LBZLjR+SgyV+nRkTIunzOYBMLQ==} cpu: [arm] @@ -7495,6 +7507,14 @@ snapshots: '@pkgjs/parseargs@0.11.0': optional: true + '@rollup/pluginutils@5.1.0(rollup@4.17.2)': + dependencies: + '@types/estree': 1.0.5 + estree-walker: 2.0.2 + picomatch: 2.3.1 + optionalDependencies: + rollup: 4.17.2 + '@rollup/rollup-android-arm-eabi@4.17.2': optional: true @@ -12135,8 +12155,9 @@ snapshots: graceful-fs: 4.2.11 optional: true - storybook-react-rsbuild@file:packages/react-rsbuild(@rsbuild/core@1.0.1-beta.0)(react-dom@16.14.0(react@16.14.0))(react@16.14.0)(storybook@8.2.1(@babel/preset-env@7.24.5))(typescript@5.4.5)(webpack@5.93.0): + storybook-react-rsbuild@file:packages/react-rsbuild(@rsbuild/core@1.0.1-beta.0)(react-dom@16.14.0(react@16.14.0))(react@16.14.0)(rollup@4.17.2)(storybook@8.2.1(@babel/preset-env@7.24.5))(typescript@5.4.5)(webpack@5.93.0): dependencies: + '@rollup/pluginutils': 5.1.0(rollup@4.17.2) '@rsbuild/core': 1.0.1-beta.0 '@storybook/react': 8.2.1(react-dom@16.14.0(react@16.14.0))(react@16.14.0)(storybook@8.2.1(@babel/preset-env@7.24.5))(typescript@5.4.5) '@storybook/react-docgen-typescript-plugin': 1.0.1(typescript@5.4.5)(webpack@5.93.0) @@ -12153,6 +12174,7 @@ snapshots: optionalDependencies: typescript: 5.4.5 transitivePeerDependencies: + - rollup - supports-color - webpack diff --git a/sandboxes/react-18/.storybook/main.ts b/sandboxes/react-18/.storybook/main.ts index a2ef965..8a0ea69 100644 --- a/sandboxes/react-18/.storybook/main.ts +++ b/sandboxes/react-18/.storybook/main.ts @@ -26,7 +26,7 @@ const config: StorybookConfig = { autodocs: 'tag', }, typescript: { - reactDocgen: 'react-docgen', + reactDocgen: 'react-docgen-typescript', check: true, }, }