import { dirname, isAbsolute, join, relative, resolve } from 'node:path' import { Glob } from 'bun' import type { CaseModule, DisplayCaseConfig } from '../index' export interface LoadedModule { /** Absolute path to the case file. */ file: string module: CaseModule } export interface LoadError { file: string error: string } const CONFIG_NAMES = ['display-case.config.ts', 'display-case.config.tsx'] /** Resolve and import a consumer's Display Case config from a package dir. */ export async function resolveConfig( pkgDir: string, ): Promise<{ config: DisplayCaseConfig; configPath: string }> { for (const name of CONFIG_NAMES) { const candidate = join(pkgDir, name) if (await Bun.file(candidate).exists()) { const mod = (await import(candidate)) as { default?: DisplayCaseConfig } if (!mod.default) { throw new Error(`${name} must default-export defineConfig(...)`) } return { config: mod.default, configPath: candidate } } } throw new Error( `No Display Case config found in ${pkgDir} (expected one of: ${CONFIG_NAMES.join(', ')})`, ) } /** Resolve the configured roots globs to absolute case-file paths. */ export async function discoverCaseFiles( pkgDir: string, config: DisplayCaseConfig, ): Promise { const found = new Set() for (const pattern of config.roots) { const glob = new Glob(pattern) for await (const match of glob.scan({ cwd: pkgDir, absolute: true })) { if (!match.includes('/node_modules/')) found.add(match) } } return [...found].sort() } /** * Import each case file's default export. A file that fails to load (throws on * import, or has no valid default) is collected as an error and skipped so the * rest still load. * * Note: Bun caches ES modules by resolved path and ignores `?v=`-style query * busting, so re-importing an edited file in the same process returns the stale * module. The long-lived dev server therefore rebuilds its manifest in a fresh * subprocess (see `loadManifestFresh` in server.ts); the one-shot callers here * (`--print-manifest`, `check`) each run in their own process, so a bare import * is always current for them. */ export async function loadModules( files: string[], ): Promise<{ modules: LoadedModule[]; errors: LoadError[] }> { const modules: LoadedModule[] = [] const errors: LoadError[] = [] for (const file of files) { try { const mod = (await import(file)) as { default?: CaseModule } if (!mod.default || typeof mod.default.component !== 'string') { errors.push({ file, error: 'no valid default export (use defineCases/defineFlow)', }) continue } modules.push({ file, module: mod.default }) } catch (err) { errors.push({ file, error: err instanceof Error ? err.message : String(err), }) } } return { modules, errors } } /** Absolute path of the gitignored cache dir for a consumer package. */ export function cacheDir(pkgDir: string): string { return join(pkgDir, '.display-case') } /** Resolve the baseline directory (config override or the default cache). */ export function baselineDir(pkgDir: string, config: DisplayCaseConfig): string { if (config.baselineDir) { return isAbsolute(config.baselineDir) ? config.baselineDir : join(pkgDir, config.baselineDir) } return join(cacheDir(pkgDir), 'baselines') } function importPath(from: string, to: string): string { let rel = relative(dirname(from), to) if (!rel.startsWith('.')) rel = `./${rel}` return rel } /** * Codegen a per-component render entry: a static module that imports exactly * one case file (a single component's cases) plus the consumer config and mounts * it. Both the dev server (lazily, on demand) and `publish` (once per component) * build one of these per component, so no single bundler pass ever holds the * whole catalog's module graph (which crashes Bun's bundler at scale). * `componentId` is the catalog slug; it names the entry file so concurrent * per-component builds never collide on disk. */ export async function codegenCaseRenderEntry( pkgDir: string, file: string, configPath: string, componentId: string, /** The substrate stage runtime to mount with. Defaults to the DOM mount — * the built-in substrate's stage — so a caller that has not resolved a * substrate still generates the entry Display Case has always generated. */ stageEntry?: string, ): Promise { const dir = cacheDir(pkgDir) const entry = join(dir, `render-case-${componentId}.tsx`) const here = resolve(import.meta.dir, '..') const mountImport = importPath( entry, stageEntry ?? join(here, 'ui', 'render-mount.tsx'), ) const configImport = importPath(entry, configPath) const modImport = importPath(entry, file) const source = `// AUTO-GENERATED by display-case — do not edit. import { mountRender } from '${mountImport}' import config from '${configImport}' import m0 from '${modImport}' mountRender([Object.assign(m0, { sourcePath: ${JSON.stringify(relative(pkgDir, file))} })], config) ` await Bun.write(entry, source) return entry } /** * Codegen a per-component SSR entry: the in-process pre-render counterpart of * {@link codegenCaseRenderEntry}, importing one case file and exporting * `renderCaseToHtml` for that component. Built and imported on demand by the dev * server. The `seq` suffix makes each build a fresh on-disk name so Bun's * resolved-path import cache returns the current module after an edit (the same * staleness that forces the manifest into a subprocess). */ export async function codegenCaseSsrEntry( pkgDir: string, file: string, configPath: string, componentId: string, seq: number, ): Promise { const dir = cacheDir(pkgDir) const entry = join(dir, `ssr-case-${componentId}-${seq}.tsx`) const here = resolve(import.meta.dir, '..') const rendererImport = importPath( entry, join(here, 'render', 'ssr-render.tsx'), ) const substrateImport = importPath( entry, join(here, 'substrate', 'resolve.ts'), ) const configImport = importPath(entry, configPath) const modImport = importPath(entry, file) // The entry resolves the substrate and wires it into the renderer. Doing it // here — rather than inside `ssr-render` — keeps the render layer pointing // inward: it depends on the substrate *contract*, never on an implementation. const source = `// AUTO-GENERATED by display-case — do not edit. import { makeCaseRenderer } from '${rendererImport}' import { resolveSubstrate } from '${substrateImport}' import config from '${configImport}' import m0 from '${modImport}' export const renderCaseToHtml = makeCaseRenderer([Object.assign(m0, { sourcePath: ${JSON.stringify(relative(pkgDir, file))} })], config, resolveSubstrate(config)) ` await Bun.write(entry, source) return entry } /** * Codegen the published build's substrate entry: a module exporting the * showcase's resolved substrate and its config. * * The production host serves each isolated render through `substrate.document()` * exactly as the dev server does, so it needs the substrate the showcase * configured — not a hard-coded assumption that documents are HTML. Bundling it * here (rather than resolving the config at serve time) keeps the published * build self-contained: it carries no reference to the consumer's source tree. */ export async function codegenSubstrateEntry( pkgDir: string, configPath: string, ): Promise { const dir = cacheDir(pkgDir) const entry = join(dir, 'substrate-entry.ts') const here = resolve(import.meta.dir, '..') const resolveImport = importPath(entry, join(here, 'substrate', 'resolve.ts')) const configImport = importPath(entry, configPath) const source = `// AUTO-GENERATED by display-case — do not edit. import { resolveSubstrate } from '${resolveImport}' import config from '${configImport}' export { config } export const substrate = resolveSubstrate(config) ` await Bun.write(entry, source) return entry } /** * Codegen the primer entry: a static module that imports the consumer's `.mdx` * document (compiled by the MDX bundler plugin) and hands it to the primer * mount. `primerPath` is the config's `primer` value, relative to the package. */ export async function codegenPrimerEntry( pkgDir: string, primerPath: string, ): Promise { const dir = cacheDir(pkgDir) const entry = join(dir, 'primer-entry.tsx') const here = resolve(import.meta.dir, '..') const mountImport = importPath(entry, join(here, 'ui', 'primer-mount.tsx')) const mdxImport = importPath(entry, resolve(pkgDir, primerPath)) const source = `// AUTO-GENERATED by display-case — do not edit. import { mountPrimer } from '${mountImport}' import MDXContent from '${mdxImport}' mountPrimer(MDXContent) ` await Bun.write(entry, source) return entry } /** * Codegen the SSR-primer entry: imports the compiled MDX and exports * `renderPrimerToHtml` — the server's primer pre-render function. The browser * counterpart of this is {@link codegenPrimerEntry}; this one runs under Bun * and returns markup instead of mounting. */ export async function codegenSsrPrimerEntry( pkgDir: string, primerPath: string, configPath: string, ): Promise { const dir = cacheDir(pkgDir) const entry = join(dir, 'ssr-primer-entry.tsx') const here = resolve(import.meta.dir, '..') const rendererImport = importPath( entry, join(here, 'render', 'ssr-primer.tsx'), ) const mdxImport = importPath(entry, resolve(pkgDir, primerPath)) const configImport = importPath(entry, configPath) // Pass `config` so the primer render honors `styleEngines` exactly as a case // render does (its specimens are real consumer components). const source = `// AUTO-GENERATED by display-case — do not edit. import { makePrimerRenderer } from '${rendererImport}' import config from '${configImport}' import MDXContent from '${mdxImport}' export const renderPrimerToHtml = makePrimerRenderer(MDXContent, config) ` await Bun.write(entry, source) return entry }