/** * Runtime adapter for converting Storybook CSF modules to Fragment definitions. * * This operates on IMPORTED modules at runtime, not source code parsing. * By leveraging Vite's module system, we get 100% accurate render functions * without any regex or AST parsing complexity. * * Supports Storybook 8.x with both CSF2 (Template.bind) and CSF3 (object stories). */ import { createElement, type ComponentType, type ReactNode } from "react"; import { toId, storyNameFromExport, isExportStory } from "./storybook-csf.js"; import type { FragmentDefinition, FragmentMeta, FragmentUsage, PropDefinition, FragmentVariant, ControlType, VariantLoader, PlayFunction, PlayFunctionContext, VariantRenderOptions, } from "./types.js"; // Re-export @storybook/csf utilities for use in other modules export { toId, storyNameFromExport, isExportStory }; /** * Storybook decorator function signature */ export type Decorator = ( Story: () => ReactNode, context: StoryContext ) => ReactNode; /** * Storybook loader function signature */ export type Loader = (context: StoryContext) => Promise>; /** * Storybook play function signature (internal, extends StoryContext) */ type StorybookPlayFunction = (context: StorybookPlayFunctionContext) => Promise; /** * Context passed to Storybook play functions (extends StoryContext for compatibility) */ interface StorybookPlayFunctionContext extends StoryContext { canvasElement: HTMLElement; step: (name: string, fn: () => Promise) => Promise; } /** * Context passed to decorators and render functions */ export interface StoryContext { args: Record; argTypes: Record; globals: Record; parameters: Record; id: string; kind: string; name: string; story: string; viewMode: "story" | "docs"; loaded: Record; abortSignal: AbortSignal; componentId: string; title: string; } /** * Storybook Meta (default export) */ export interface StoryMeta { title?: string; component?: ComponentType; subcomponents?: Record>; tags?: string[]; parameters?: Record & { docs?: { description?: { component?: string; }; }; }; argTypes?: Record; args?: Record; decorators?: Decorator[]; loaders?: Loader[]; render?: (args: Record, context?: StoryContext) => ReactNode; // Story filtering includeStories?: string[] | RegExp; excludeStories?: string[] | RegExp; } /** * Storybook argType definition */ export interface StoryArgType { control?: | string | false | { type: string; min?: number; max?: number; step?: number; presetColors?: string[] }; options?: string[]; description?: string; table?: { defaultValue?: { summary: string }; type?: { summary: string }; category?: string; subcategory?: string; disable?: boolean; }; type?: { name: string; required?: boolean }; name?: string; defaultValue?: unknown; if?: { arg?: string; exists?: boolean }; mapping?: Record; action?: string; } /** * Storybook Story export (CSF3) */ export interface Story { args?: Record; argTypes?: Record; render?: (args: Record, context?: StoryContext) => ReactNode; decorators?: Decorator[]; loaders?: Loader[]; play?: StorybookPlayFunction; parameters?: Record & { docs?: { description?: { story?: string; }; }; }; name?: string; storyName?: string; // Legacy CSF2 tags?: string[]; } /** * CSF2 story function (from Template.bind({})) with args attached */ export type CSF2Story = ((args: Record) => ReactNode) & { args?: Record; argTypes?: Record; decorators?: Decorator[]; loaders?: Loader[]; play?: StorybookPlayFunction; parameters?: Record; storyName?: string; }; /** * A complete Storybook module with default meta and named story exports */ export interface StoryModule { default: StoryMeta; [exportName: string]: Story | CSF2Story | StoryMeta | unknown; } /** * Global configuration from preview.tsx */ export interface PreviewConfig { decorators?: Decorator[]; parameters?: Record; globalTypes?: Record; args?: Record; argTypes?: Record; loaders?: Loader[]; } // Store for global preview config (set by previewLoader) let globalPreviewConfig: PreviewConfig = {}; /** * Set the global preview configuration loaded from .storybook/preview.tsx */ export function setPreviewConfig(config: PreviewConfig): void { globalPreviewConfig = config; } /** * Get the current global preview configuration */ export function getPreviewConfig(): PreviewConfig { return globalPreviewConfig; } /** * Convert a Storybook module to a Fragment definition at runtime. * * @param storyModule - The imported Storybook module * @param filePath - File path for metadata extraction * @returns A complete FragmentDefinition ready for the viewer */ export function storyModuleToFragment( storyModule: StoryModule, filePath: string ): FragmentDefinition | null { const meta = storyModule.default; const component = meta.component; // Stories without a component (e.g., documentation pages, icon galleries) are skipped if (!component) { return null; } const componentName = extractComponentName(meta, filePath); const category = extractCategory(meta.title); const props = convertArgTypes(meta.argTypes ?? {}, globalPreviewConfig.argTypes); const variants = extractVariants(storyModule, component, meta); // Extract Figma URL from parameters.design.url (storybook-addon-designs) or parameters.figma const figmaUrl = extractFigmaUrl(meta.parameters); const fragmentMeta: FragmentMeta = { name: componentName, description: meta.parameters?.docs?.description?.component ?? `${componentName} component`, category, tags: meta.tags?.filter((t) => t !== "autodocs"), status: "stable", figma: figmaUrl, }; const usage: FragmentUsage = { when: [`Use ${componentName} for its intended purpose`], whenNot: ["When a more specific component is available"], }; return { component, meta: fragmentMeta, usage, props, variants, }; } /** * Extract component name from meta or file path */ function extractComponentName(meta: StoryMeta, filePath: string): string { // Try title (last fragment of path like "Components/Forms/Button" -> "Button") if (meta.title) { const parts = meta.title.split("/"); return parts[parts.length - 1]; } // Try component displayName if (meta.component?.displayName) { return meta.component.displayName; } // Try component name if (meta.component?.name && meta.component.name !== "Component") { return meta.component.name; } // Fallback: extract from file path const match = filePath.match(/([^/\\]+)\.stories\.(tsx?|jsx?)$/); return match?.[1] ?? "Unknown"; } /** * Extract category from Storybook title path */ function extractCategory(title?: string): string { if (!title) return "general"; const parts = title.split("/"); // "Components/Forms/Button" -> "forms" (need at least 3 parts for a subcategory) if (parts.length >= 3) { return parts[parts.length - 2].toLowerCase(); } // "Components/Button" -> "general" (no subcategory specified) return "general"; } /** * Extract Figma URL from Storybook parameters * Supports storybook-addon-designs format and custom figma parameter */ function extractFigmaUrl(parameters?: Record): string | undefined { if (!parameters) return undefined; // Try storybook-addon-designs format: parameters.design.url const design = parameters.design as { url?: string; type?: string } | undefined; if (design?.url && typeof design.url === "string") { return design.url; } // Try custom figma parameter: parameters.figma if (typeof parameters.figma === "string") { return parameters.figma; } return undefined; } /** * Convert Storybook argTypes to Fragment props * Merges global argTypes from preview config with meta argTypes */ function convertArgTypes( argTypes: Record, globalArgTypes?: Record ): Record { const props: Record = {}; // Merge global and meta argTypes (meta takes precedence) const mergedArgTypes = { ...globalArgTypes, ...argTypes }; for (const [name, argType] of Object.entries(mergedArgTypes)) { // Skip disabled argTypes if (argType.table?.disable) continue; // Skip action-only argTypes (no control) if (argType.control === false && argType.action) continue; // Extract control type and options const { controlType, controlOptions } = extractControlInfo(argType); props[name] = { type: inferPropType(argType), description: argType.description ?? `${name} prop`, ...(argType.options && { values: argType.options }), ...(argType.table?.defaultValue && { default: argType.table.defaultValue.summary, }), ...(argType.defaultValue !== undefined && { default: argType.defaultValue, }), ...(argType.type?.required && { required: true }), ...(controlType && { controlType }), ...(controlOptions && Object.keys(controlOptions).length > 0 && { controlOptions }), }; } return props; } /** * Extract control type and options from a Storybook argType */ function extractControlInfo(argType: StoryArgType): { controlType?: ControlType; controlOptions?: PropDefinition["controlOptions"]; } { // Handle no control or explicitly disabled control if (argType.control === undefined || argType.control === false) { return {}; } const control = typeof argType.control === "string" ? { type: argType.control } : argType.control; // Map control type string to ControlType const validControlTypes: ControlType[] = [ "text", "number", "range", "boolean", "select", "multi-select", "radio", "inline-radio", "check", "inline-check", "object", "file", "color", "date" ]; const controlType = validControlTypes.includes(control.type as ControlType) ? (control.type as ControlType) : undefined; // Extract control options for controls that need them const controlOptions: PropDefinition["controlOptions"] = {}; if (control.min !== undefined) controlOptions.min = control.min; if (control.max !== undefined) controlOptions.max = control.max; if (control.step !== undefined) controlOptions.step = control.step; if (control.presetColors) controlOptions.presetColors = control.presetColors; return { controlType, controlOptions: Object.keys(controlOptions).length > 0 ? controlOptions : undefined, }; } /** * Infer prop type from Storybook control/type * Handles all Storybook 8.x control types */ function inferPropType(argType: StoryArgType): PropDefinition["type"] { // Action argType → function if (argType.action) return "function"; // If has options, it's an enum if (argType.options?.length) return "enum"; // Check explicit type if (argType.type?.name) { const typeMap: Record = { string: "string", number: "number", boolean: "boolean", object: "object", array: "array", function: "function", }; const mapped = typeMap[argType.type.name]; if (mapped) return mapped; } // Check control type const control = typeof argType.control === "string" ? argType.control : argType.control ? argType.control.type : undefined; if (control) { const controlMap: Record = { // Text controls text: "string", // Number controls number: "number", range: "number", // Boolean controls boolean: "boolean", check: "boolean", "inline-check": "boolean", // Enum/selection controls select: "enum", "multi-select": "enum", radio: "enum", "inline-radio": "enum", // Object controls object: "object", file: "object", // Special string controls color: "string", date: "string", }; const mapped = controlMap[control]; if (mapped) return mapped; } return "string"; } /** * Check if a value looks like a Storybook story * Handles both CSF 3 (objects) and CSF 2 (functions from Template.bind({})) */ function isStory(value: unknown): value is Story | CSF2Story { // CSF 3: Story is an object with args/render/play if (typeof value === "object" && value !== null) { const obj = value as Record; if ("args" in obj || "render" in obj || "play" in obj) return true; } // CSF 2: Story is a function (from Template.bind({})) with args attached if (typeof value === "function") { const fn = value as ((...args: unknown[]) => unknown) & { args?: unknown }; if ("args" in fn) return true; } return false; } /** * Extract variants from story exports using @storybook/csf utilities */ function extractVariants( storyModule: StoryModule, component: ComponentType, meta: StoryMeta ): FragmentVariant[] { const variants: FragmentVariant[] = []; for (const [exportName, exportValue] of Object.entries(storyModule)) { // Skip default export if (exportName === "default") continue; // Use isExportStory to filter based on includeStories/excludeStories if (!isExportStory(exportName, meta)) continue; // Check if it's a story if (!isStory(exportValue)) continue; const story = exportValue as Story | CSF2Story; // Get story name using storyNameFromExport const storyName = (typeof story === "object" && story.name) || (typeof story === "object" && story.storyName) || (typeof story === "function" && story.storyName) || storyNameFromExport(exportName); // Generate story ID matching Storybook format const storyId = toId(meta.title || "Unknown", exportName); // Extract description based on story format let description = `${storyName} variant`; if (typeof story === "object" && story.parameters?.docs?.description?.story) { description = story.parameters.docs.description.story; } // Check for play function and capture it const storyPlayFn = typeof story === "object" ? story.play : story.play; const hasPlayFunction = !!storyPlayFn; // Create wrapped play function that adapts Storybook context to our PlayFunctionContext const wrappedPlay: PlayFunction | undefined = storyPlayFn ? async (context: PlayFunctionContext): Promise => { // Build full Storybook context for compatibility const args = { ...globalPreviewConfig.args, ...meta.args, ...(typeof story === "function" ? story.args : story.args), }; const fullContext = buildStoryContext(meta, story, args, storyId, storyName); // Merge our context with Storybook context const playContext = { ...fullContext, canvasElement: context.canvasElement, args: context.args, step: context.step, }; await storyPlayFn(playContext as unknown as StorybookPlayFunctionContext); } : undefined; // Get story tags const storyTags = typeof story === "object" ? story.tags : undefined; // Collect loaders from global, meta, and story (in order) const loaders = collectLoaders(meta, story); // Compute the merged args for this variant (for code generation) const variantArgs = { ...globalPreviewConfig.args, ...meta.args, ...(typeof story === "function" ? story.args : story.args), }; // Only include args if there are any defined const hasArgs = Object.keys(variantArgs).length > 0; variants.push({ name: storyName, description, render: createRenderFunction(story, component, meta, storyId, storyName), // Store Storybook-specific metadata ...(hasPlayFunction && { hasPlayFunction: true }), ...(wrappedPlay && { play: wrappedPlay }), ...(storyId && { storyId }), ...(storyTags && { tags: storyTags }), ...(loaders.length > 0 && { loaders }), ...(hasArgs && { args: variantArgs }), }); } return variants; } /** * Collect loaders from global, meta, and story levels * Returns wrapped loader functions that execute with context */ function collectLoaders( meta: StoryMeta, story: Story | CSF2Story ): VariantLoader[] { const allLoaders: Loader[] = [ ...(globalPreviewConfig.loaders ?? []), ...(meta.loaders ?? []), ...(typeof story === "function" ? story.loaders ?? [] : story.loaders ?? []), ]; if (allLoaders.length === 0) { return []; } // Wrap each loader to execute without requiring context at call time // The actual context will be built when the loader is executed return allLoaders.map((loader) => { return async (): Promise> => { // Create a minimal context for loader execution const minimalContext: StoryContext = { args: {}, argTypes: {}, globals: {}, parameters: {}, id: "", kind: meta.title || "Unknown", name: "", story: "", viewMode: "story", loaded: {}, abortSignal: new AbortController().signal, componentId: "", title: meta.title || "Unknown", }; return loader(minimalContext); }; }); } /** * Build a StoryContext for decorators and render functions */ function buildStoryContext( meta: StoryMeta, story: Story | CSF2Story, args: Record, storyId: string, storyName: string, loadedData?: Record ): StoryContext { const mergedArgs = { ...globalPreviewConfig.args, ...meta.args, ...(typeof story === "object" ? story.args : story.args), ...args, }; const mergedArgTypes = { ...globalPreviewConfig.argTypes, ...meta.argTypes, ...(typeof story === "object" ? story.argTypes : story.argTypes), }; const mergedParameters = { ...globalPreviewConfig.parameters, ...meta.parameters, ...(typeof story === "object" ? story.parameters : story.parameters), }; return { args: mergedArgs, argTypes: mergedArgTypes ?? {}, globals: {}, parameters: mergedParameters ?? {}, id: storyId, kind: meta.title || "Unknown", name: storyName, story: storyName, viewMode: "story", loaded: loadedData ?? {}, abortSignal: new AbortController().signal, componentId: toId(meta.title || "Unknown", ""), title: meta.title || "Unknown", }; } /** * Create a render function for a story * Handles both CSF 3 (objects) and CSF 2 (functions) * Applies decorators in correct order: story → meta → global (innermost first) * Accepts optional args overrides and loaded data from loaders */ function createRenderFunction( story: Story | CSF2Story, component: ComponentType, meta: StoryMeta, storyId: string, storyName: string ): (options?: VariantRenderOptions) => ReactNode { return (options?: VariantRenderOptions) => { // Merge args: global → meta → story → runtime overrides const args = { ...globalPreviewConfig.args, ...meta.args, ...(typeof story === "function" ? story.args : story.args), ...options?.args, // Runtime overrides from viewer props panel }; const loadedData = options?.loadedData; // Build the story context with loaded data const context = buildStoryContext(meta, story, args, storyId, storyName, loadedData); // Create the base render function let renderFn: () => ReactNode; if (typeof story === "function") { // CSF 2: Story is a function (from Template.bind({})) renderFn = () => story(args); } else if (story.render) { // CSF 3: Story has custom render function // Support both render(args) and render(args, context) signatures renderFn = () => story.render!.length >= 2 ? story.render!(args, context) : story.render!(args); } else if (meta.render) { // CSF 3: Meta has default render function renderFn = () => meta.render!.length >= 2 ? meta.render!(args, context) : meta.render!(args); } else { // Default: render component with args renderFn = () => createElement(component, args); } // Collect decorators in Storybook order // story → meta → global, then reverse to apply innermost first const allDecorators = [ ...(globalPreviewConfig.decorators ?? []), ...(meta.decorators ?? []), ...(typeof story === "function" ? story.decorators ?? [] : story.decorators ?? []), ].reverse(); // Apply decorators if any if (allDecorators.length > 0) { return applyDecorators(renderFn, allDecorators, context); } return renderFn(); }; } /** * Apply decorators in the correct order * Decorators wrap from innermost to outermost */ function applyDecorators( renderFn: () => ReactNode, decorators: Decorator[], context: StoryContext ): ReactNode { // Start with the base render function let storyFn: () => ReactNode = renderFn; // Each decorator wraps the previous one for (const decorator of decorators) { const wrappedFn = storyFn; storyFn = () => decorator(wrappedFn, context); } return storyFn(); } /** * Convert PascalCase to Title Case * @deprecated Use storyNameFromExport from @storybook/csf instead */ function pascalToTitle(name: string): string { return name.replace(/([A-Z])/g, " $1").trim(); }