diff --git a/code/renderers/react/src/componentManifest/generateCodeSnippet.ts b/code/renderers/react/src/componentManifest/generateCodeSnippet.ts index 3afb9a1d62d9..f89e21868681 100644 --- a/code/renderers/react/src/componentManifest/generateCodeSnippet.ts +++ b/code/renderers/react/src/componentManifest/generateCodeSnippet.ts @@ -118,17 +118,26 @@ export function getCodeSnippet( ? metaPath.get('properties').filter((p) => p.isObjectProperty()) : []; - const getRenderPath = (object: NodePath[]) => - object - .filter((p) => keyOf(p.node) === 'render') - .map((p) => p.get('value')) - .find( - (v): v is NodePath => - v.isArrowFunctionExpression() || v.isFunctionExpression() + const getRenderPath = (object: NodePath[]) => { + const renderPath = object.find((p) => keyOf(p.node) === 'render')?.get('value'); + + if (renderPath?.isIdentifier()) { + componentName = renderPath.node.name; + } + if ( + renderPath && + !(renderPath.isArrowFunctionExpression() || renderPath.isFunctionExpression()) + ) { + throw renderPath.buildCodeFrameError( + 'Expected render to be an arrow function or function expression' ); + } + + return renderPath; + }; - const renderPath = getRenderPath(storyProps); const metaRenderPath = getRenderPath(metaProps); + const renderPath = getRenderPath(storyProps); storyFn ??= renderPath ?? metaRenderPath; diff --git a/code/renderers/react/src/componentManifest/generator.ts b/code/renderers/react/src/componentManifest/generator.ts index fee51c167004..9d9da2ce7cf8 100644 --- a/code/renderers/react/src/componentManifest/generator.ts +++ b/code/renderers/react/src/componentManifest/generator.ts @@ -132,21 +132,23 @@ export const componentManifestGenerator: PresetPropertyFn< } satisfies Partial; if (!component?.reactDocgen) { - const error = !component + const error = !csf._meta?.component ? { - name: 'No meta.component specified', - message: 'Specify meta.component for the component to be included in the manifest.', + name: 'No component found', + message: + 'We could not detect the component from your story file. Specify meta.component.', } : { name: 'No component import found', - message: `No component file found for the "${component.componentName}" component.`, + message: `No component file found for the "${csf.meta.component}" component.`, }; return { ...base, error: { name: error.name, message: - csf._metaStatementPath?.buildCodeFrameError(error.message).message ?? error.message, + (csf._metaStatementPath?.buildCodeFrameError(error.message).message ?? + error.message) + `\n\n${entry.importPath}:\n${storyFile}`, }, }; } diff --git a/code/renderers/react/src/componentManifest/getComponentImports.ts b/code/renderers/react/src/componentManifest/getComponentImports.ts index 259c810d159a..defe9e8b17dd 100644 --- a/code/renderers/react/src/componentManifest/getComponentImports.ts +++ b/code/renderers/react/src/componentManifest/getComponentImports.ts @@ -198,7 +198,7 @@ export const getComponents = ({ }); } } catch (e) { - logger.error(e); + logger.debug(e); } if (path) { const reactDocgen = getReactDocgen(path, component); diff --git a/code/renderers/react/src/componentManifest/reactDocgen.ts b/code/renderers/react/src/componentManifest/reactDocgen.ts index 45c457b26aa6..7ade97fade6a 100644 --- a/code/renderers/react/src/componentManifest/reactDocgen.ts +++ b/code/renderers/react/src/componentManifest/reactDocgen.ts @@ -3,6 +3,7 @@ import { dirname, sep } from 'node:path'; import { babelParse, types as t } from 'storybook/internal/babel'; import { getProjectRoot, supportedExtensions } from 'storybook/internal/common'; +import { logger } from 'storybook/internal/node-logger'; import * as find from 'empathic/find'; import { @@ -12,6 +13,7 @@ import { makeFsImporter, parse, } from 'react-docgen'; +import { dedent } from 'ts-dedent'; import * as TsconfigPaths from 'tsconfig-paths'; import { type ComponentRef } from './getComponentImports'; @@ -33,6 +35,9 @@ const defaultResolver = new docgenResolver.FindExportedDefinitionsResolver(); const handlers = [...defaultHandlers, actualNameHandler, exportNameHandler]; export function getMatchingDocgen(docgens: DocObj[], component: ComponentRef) { + if (docgens.length === 0) { + return; + } if (docgens.length === 1) { return docgens[0]; } @@ -91,75 +96,141 @@ export const parseWithReactDocgen = cached( const getExportPaths = cached( (code: string, filePath: string) => { - const ast = (() => { - try { - return babelParse(code); - } catch (_) { - return undefined; - } - })(); - - if (!ast) { - return [] as string[]; + let ast; + try { + ast = babelParse(code); + } catch (_) { + return []; } + const basedir = dirname(filePath); const body = ast.program.body; return body - .flatMap((n) => - t.isExportAllDeclaration(n) - ? [n.source.value] - : t.isExportNamedDeclaration(n) && !!n.source && !n.declaration - ? [n.source.value] + .flatMap((statement) => + t.isExportAllDeclaration(statement) + ? [statement.source.value] + : t.isExportNamedDeclaration(statement) && !!statement.source && !statement.declaration + ? [statement.source.value] : [] ) - .map((s) => matchPath(s, basedir)) - .map((s) => { + .map((id) => matchPath(id, basedir)) + .flatMap((id) => { try { - return cachedResolveImport(s, { basedir }); - } catch { - return undefined; + return [cachedResolveImport(id, { basedir })]; + } catch (e) { + logger.debug(e); + return []; } - }) - .filter((p): p is string => !!p && !p.includes('node_modules')); + }); }, { name: 'getExportPaths' } ); const gatherDocgensForPath = cached( ( - filePath: string, + path: string, depth: number - ): { docgens: DocObj[]; analyzed: { path: string; code: string }[] } => { - if (depth > 5 || filePath.includes('node_modules')) { - return { docgens: [], analyzed: [] }; + ): { + docgens: DocObj[]; + errors: { path: string; code: string; name: string; message: string }[]; + } => { + if (path.includes('node_modules')) { + return { + docgens: [], + errors: [ + { + path, + code: '/* File in node_modules */', + name: 'Component file in node_modules', + message: dedent` + Component files in node_modules are not supported. + The distributed files in node_modules usually don't contain the necessary comments or types needed to analyze component information. + Configure TypeScript path aliases to map your package name to the source file instead. + + Example (tsconfig.json): + { + "compilerOptions": { + "baseUrl": ".", + "paths": { + "@design-system/button": ["src/components/Button.tsx"], + "@design-system/*": ["src/components/*"] + } + } + } + + Then import using: + import { Button } from '@design-system/button' + + Storybook resolves tsconfig paths automatically. + `, + }, + ], + }; } - let code: string | undefined; + let code; try { - code = cachedReadFileSync(filePath, 'utf-8') as string; - } catch {} + code = cachedReadFileSync(path, 'utf-8') as string; + } catch { + return { + docgens: [], + errors: [ + { + path, + code: '/* File not found or unreadable */', + name: 'Component file could not be read', + message: `Could not read the component file located at "${path}".\nPrefer relative imports if possible.`, + }, + ], + }; + } - if (!code) { - return { docgens: [], analyzed: [{ path: filePath, code: '/* File not found */' }] }; + if (depth > 5) { + return { + docgens: [], + errors: [ + { + path, + code, + name: 'Max re-export depth exceeded', + message: dedent` + Traversal stopped after 5 steps while following re-exports starting from this file. + This usually indicates a deep or circular re-export chain. Try one of the following: + - Import the component file directly (e.g., src/components/Button.tsx), + - Reduce the number of re-export hops. + `, + }, + ], + }; } - const reexportResults = getExportPaths(code, filePath).map((p) => - gatherDocgensForPath(p, depth + 1) - ); - const fromReexports = reexportResults.flatMap((r) => r.docgens); - const analyzedChildren = reexportResults.flatMap((r) => r.analyzed); + const exportPaths = getExportPaths(code, path).map((p) => gatherDocgensForPath(p, depth + 1)); + const docgens = exportPaths.flatMap((r) => r.docgens); + const errors = exportPaths.flatMap((r) => r.errors); - let locals: DocObj[]; try { - locals = parseWithReactDocgen(code as string, filePath); - } catch { - locals = []; + return { + docgens: [...parseWithReactDocgen(code, path), ...docgens], + errors, + }; + } catch (e) { + const message = e instanceof Error ? e.message : String(e); + return { + docgens, + errors: [ + { + path, + code, + name: 'No component definition found', + message: dedent` + ${message} + You can debug your component file in this playground: https://react-docgen.dev/playground + `, + }, + ...errors, + ], + }; } - - return { - docgens: [...locals, ...fromReexports], - analyzed: [{ path: filePath, code }, ...analyzedChildren], - }; }, { name: 'gatherDocgensWithTrace', key: (filePath) => filePath } ); @@ -171,39 +242,25 @@ export const getReactDocgen = cached( ): | { type: 'success'; data: DocObj } | { type: 'error'; error: { name: string; message: string } } => { - if (path.includes('node_modules')) { - return { - type: 'error', - error: { - name: 'Component file in node_modules', - message: `Component files in node_modules are not supported. Please import your component file directly.`, - }, - }; - } - - const docgenWithInfo = gatherDocgensForPath(path, 0); - const docgens = docgenWithInfo.docgens; - - const noCompDefError = { - type: 'error' as const, - error: { - name: 'No component definition found', - message: - `Could not find a component definition.\n` + - `Prefer relative imports if possible.\n` + - `Avoid pointing to transpiled files.\n` + - `You can debug your component file in this playground: https://react-docgen.dev/playground\n\n` + - docgenWithInfo.analyzed.map(({ path, code }) => `File: ${path}\n${code}`).join('\n'), - }, - }; - - if (!docgens || docgens.length === 0) { - return noCompDefError; - } + const { docgens, errors } = gatherDocgensForPath(path, 0); const docgen = getMatchingDocgen(docgens, component); + if (!docgen) { - return noCompDefError; + const error = { + name: errors.at(-1)?.name ?? 'No component definition found', + message: errors + .map( + (e) => dedent` + File: ${e.path} + Error: + ${e.message} + Code: + ${e.code}` + ) + .join('\n\n'), + }; + return { type: 'error', error }; } return { type: 'success', data: docgen }; },