Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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
76 changes: 59 additions & 17 deletions packages/core/src/stories/importer.ts
Original file line number Diff line number Diff line change
@@ -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'

Expand All @@ -19,9 +20,17 @@ 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 {
export function createStoryImporter(): [Error, null] | [null, StoryImporter] {
const [jitiError, createJiti] = resolveJiti()

if (jitiError) {
return [jitiError, null]
}

installTsExtensionResolution()

const jiti = createJiti(import.meta.url, {
Expand All @@ -31,26 +40,59 @@ export function createStoryImporter(): StoryImporter {
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
return [
null,
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

if (!isStoryEntry(entry)) {
return [new Error(`File ${filePath} does not export a valid Story or StoryGroup`), null]
}
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]
}
},
})
return [null, entry]
} 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(): [Error, null] | [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 {
return [
new Error(
[
'The "jiti" package is required to run stories but was not found.',
'',
'Install it with:',
' pnpm add jiti',
].join('\n')
),
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