Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/stories-jiti-check.md
Original file line number Diff line number Diff line change
@@ -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
111 changes: 84 additions & 27 deletions packages/core/src/stories/importer.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,17 @@
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'

/**
* 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<readonly [Error, null] | readonly [null, StoryEntry]>
}

/**
Expand All @@ -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<string, unknown>
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<readonly [Error, null] | readonly [null, StoryEntry]> => {
try {
const mod = (await jiti.import(filePath)) as Record<string, unknown>
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.
*
Expand Down
10 changes: 9 additions & 1 deletion packages/core/src/stories/viewer/stories-check.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
8 changes: 7 additions & 1 deletion packages/core/src/stories/viewer/stories-output.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
40 changes: 30 additions & 10 deletions packages/core/src/stories/viewer/stories-screen.tsx
Original file line number Diff line number Diff line change
@@ -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'
Expand Down Expand Up @@ -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' }

// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -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)

Expand All @@ -96,15 +104,15 @@ 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
}

setState({ phase: 'ready' })
}

run().catch(() => {
setState({ phase: 'empty', warningCount: 0 })
setState({ phase: 'empty', errors: [] })
})

const [watchError, watcher] = createStoryWatcher({
Expand All @@ -126,11 +134,23 @@ function StoriesViewer({ include }: { readonly include?: string }): ReactElement

return match(state)
.with({ phase: 'loading' }, () => <Text>Discovering stories...</Text>)
.with({ phase: 'empty' }, ({ warningCount }) => (
<Text>
No stories found ({warningCount} warnings). Create a .stories.tsx file in your src/
directory to get started.
</Text>
.with({ phase: 'error' }, ({ message }) => <Text color="red">{message}</Text>)
.with({ phase: 'empty' }, ({ errors }) => (
<Box flexDirection="column">
<Text>
No stories found. Create a .stories.tsx file in your src/ directory to get started.
</Text>
{errors.length > 0 && (
<Box flexDirection="column" marginTop={1}>
<Text color="yellow">{errors.length} file(s) failed to import:</Text>
{errors.map((e) => (
<Text key={e.filePath} dimColor>
{' '}{e.filePath}: {e.message}
</Text>
))}
</Box>
)}
</Box>
))
.with({ phase: 'ready' }, () => <StoriesApp registry={registry} isReloading={isReloading} />)
.exhaustive()
Expand Down
Loading