diff --git a/.changeset/stories-jiti-check.md b/.changeset/stories-jiti-check.md new file mode 100644 index 00000000..e9bf39a9 --- /dev/null +++ b/.changeset/stories-jiti-check.md @@ -0,0 +1,5 @@ +--- +'@kidd-cli/core': patch +--- + +Surface helpful error when `jiti` peer dependency is missing for stories, and display import errors instead of silent warning count when story discovery fails diff --git a/packages/core/src/stories/importer.ts b/packages/core/src/stories/importer.ts index 25a74eb7..612fa3c5 100644 --- a/packages/core/src/stories/importer.ts +++ b/packages/core/src/stories/importer.ts @@ -1,8 +1,9 @@ import Module from 'node:module' +import type { createJiti } from 'jiti' + import { toError } from '@kidd-cli/utils/fp' import { hasTag } from '@kidd-cli/utils/tag' -import { createJiti } from 'jiti' import type { StoryEntry } from './types.js' @@ -10,7 +11,7 @@ import type { StoryEntry } from './types.js' * A story importer that can load `.stories.{tsx,ts,jsx,js}` files. */ export interface StoryImporter { - readonly importStory: (filePath: string) => Promise<[Error, null] | [null, StoryEntry]> + readonly importStory: (filePath: string) => Promise } /** @@ -19,38 +20,94 @@ export interface StoryImporter { * Installs a TypeScript extension resolution hook so that ESM-style `.js` * imports (e.g. `from './alert.js'`) resolve to `.ts` / `.tsx` source files. * - * @returns A frozen {@link StoryImporter} instance. + * Returns an error Result when the `jiti` peer dependency is not installed. + * + * @returns A Result with a frozen {@link StoryImporter} or an {@link Error}. */ -export function createStoryImporter(): StoryImporter { - installTsExtensionResolution() - - const jiti = createJiti(import.meta.url, { - fsCache: false, - moduleCache: false, - interopDefault: true, - jsx: { runtime: 'automatic' }, - }) - - return Object.freeze({ - importStory: async (filePath: string): Promise<[Error, null] | [null, StoryEntry]> => { - try { - const mod = (await jiti.import(filePath)) as Record - const entry = (mod.default ?? mod) as unknown +export function createStoryImporter(): readonly [Error, null] | readonly [null, StoryImporter] { + const [jitiError, jitiCreateFn] = resolveJiti() - if (!isStoryEntry(entry)) { - return [new Error(`File ${filePath} does not export a valid Story or StoryGroup`), null] - } + if (jitiError) { + return [jitiError, null] + } - return [null, entry] - } catch (error) { - return [toError(error), null] - } - }, - }) + try { + installTsExtensionResolution() + + const jiti = jitiCreateFn(import.meta.url, { + fsCache: false, + moduleCache: false, + interopDefault: true, + jsx: { runtime: 'automatic' }, + }) + + return [ + null, + Object.freeze({ + importStory: async ( + filePath: string + ): Promise => { + try { + const mod = (await jiti.import(filePath)) as Record + const entry = (mod.default ?? mod) as unknown + + if (!isStoryEntry(entry)) { + return [new Error(`File ${filePath} does not export a valid Story or StoryGroup`), null] + } + + return [null, entry] + } catch (error) { + return [toError(error), null] + } + }, + }), + ] + } catch (error) { + return [toError(error), null] + } } // --------------------------------------------------------------------------- +/** + * Attempt to resolve the `jiti` package at runtime. + * + * `jiti` is an optional peer dependency of `@kidd-cli/core`, but the stories + * subsystem cannot function without it. Returns a Result tuple so callers can + * surface a helpful message instead of crashing with a cryptic import error. + * + * @private + * @returns A Result with the `createJiti` factory or an {@link Error}. + */ +function resolveJiti(): readonly [Error, null] | readonly [null, typeof createJiti] { + try { + const esmRequire = Module.createRequire(import.meta.url) + const mod = esmRequire('jiti') as { readonly createJiti: typeof createJiti } + return [null, mod.createJiti] + } catch (error) { + const isModuleNotFound = + error instanceof Error && + (error as NodeJS.ErrnoException).code === 'MODULE_NOT_FOUND' && + ((error as { requireStack?: readonly string[] }).requireStack ?? []).length === 0 + + if (isModuleNotFound) { + return [ + new Error( + [ + 'The "jiti" package is required to run stories but was not found.', + '', + 'Install it with:', + ' pnpm add jiti', + ].join('\n') + ), + null, + ] + } + + return [toError(error), null] + } +} + /** * TypeScript extensions to try when a `.js` import fails to resolve. * diff --git a/packages/core/src/stories/viewer/stories-check.tsx b/packages/core/src/stories/viewer/stories-check.tsx index 814cb478..d6db5b16 100644 --- a/packages/core/src/stories/viewer/stories-check.tsx +++ b/packages/core/src/stories/viewer/stories-check.tsx @@ -46,7 +46,15 @@ export function StoriesCheck({ include }: StoriesCheckProps): ReactElement { } started.current = true - const importer = createStoryImporter() + const [importerError, importer] = createStoryImporter() + + if (importerError) { + process.exitCode = 1 + ctx.log.error(importerError.message) + exit() + return + } + const cwd = process.cwd() const includePatterns = buildIncludePatterns(include) diff --git a/packages/core/src/stories/viewer/stories-output.tsx b/packages/core/src/stories/viewer/stories-output.tsx index 4ff905a5..24d07971 100644 --- a/packages/core/src/stories/viewer/stories-output.tsx +++ b/packages/core/src/stories/viewer/stories-output.tsx @@ -64,7 +64,13 @@ export function StoriesOutput({ filter, include }: StoriesOutputProps): ReactEle const { exit } = useApp() useEffect(() => { - const importer = createStoryImporter() + const [importerError, importer] = createStoryImporter() + + if (importerError) { + setState({ phase: 'error', message: importerError.message }) + return + } + const cwd = process.cwd() const includePatterns = buildIncludePatterns(include) diff --git a/packages/core/src/stories/viewer/stories-screen.tsx b/packages/core/src/stories/viewer/stories-screen.tsx index 50419932..edeac80b 100644 --- a/packages/core/src/stories/viewer/stories-screen.tsx +++ b/packages/core/src/stories/viewer/stories-screen.tsx @@ -1,10 +1,11 @@ import process from 'node:process' -import { Text } from 'ink' +import { Box, Text } from 'ink' import type { ReactElement } from 'react' import { useEffect, useState } from 'react' import { P, match } from 'ts-pattern' +import type { DiscoverError } from '../discover.js' import { discoverStories } from '../discover.js' import { createStoryImporter } from '../importer.js' import { createStoryRegistry } from '../registry.js' @@ -36,7 +37,8 @@ interface StoriesScreenProps { */ type DiscoveryState = | { readonly phase: 'loading' } - | { readonly phase: 'empty'; readonly warningCount: number } + | { readonly phase: 'error'; readonly message: string } + | { readonly phase: 'empty'; readonly errors: readonly DiscoverError[] } | { readonly phase: 'ready' } // --------------------------------------------------------------------------- @@ -81,7 +83,13 @@ function StoriesViewer({ include }: { readonly include?: string }): ReactElement const { isReloading, onReloadStart, onReloadEnd } = useReloadState() useEffect(() => { - const importer = createStoryImporter() + const [importerError, importer] = createStoryImporter() + + if (importerError) { + setState({ phase: 'error', message: importerError.message }) + return () => {} + } + const cwd = process.cwd() const includePatterns = buildIncludePatterns(include) @@ -96,7 +104,7 @@ function StoriesViewer({ include }: { readonly include?: string }): ReactElement entries.map(([name, entry]) => registry.set(name, entry)) if (entries.length === 0) { - setState({ phase: 'empty', warningCount: result.errors.length }) + setState({ phase: 'empty', errors: result.errors }) return } @@ -104,7 +112,7 @@ function StoriesViewer({ include }: { readonly include?: string }): ReactElement } run().catch(() => { - setState({ phase: 'empty', warningCount: 0 }) + setState({ phase: 'empty', errors: [] }) }) const [watchError, watcher] = createStoryWatcher({ @@ -126,11 +134,23 @@ function StoriesViewer({ include }: { readonly include?: string }): ReactElement return match(state) .with({ phase: 'loading' }, () => Discovering stories...) - .with({ phase: 'empty' }, ({ warningCount }) => ( - - No stories found ({warningCount} warnings). Create a .stories.tsx file in your src/ - directory to get started. - + .with({ phase: 'error' }, ({ message }) => {message}) + .with({ phase: 'empty' }, ({ errors }) => ( + + + No stories found. Create a .stories.tsx file in your src/ directory to get started. + + {errors.length > 0 && ( + + {errors.length} file(s) failed to import: + {errors.map((e) => ( + + {' '}{e.filePath}: {e.message} + + ))} + + )} + )) .with({ phase: 'ready' }, () => ) .exhaustive()