Skip to main content
Glama
StoriesIndexService.ts10.8 kB
/** * StoriesIndexService * * DESIGN PATTERNS: * - Service pattern for business logic encapsulation * - Single responsibility principle * - Caching with file content hashing * * CODING STANDARDS: * - Use async/await for asynchronous operations * - Throw descriptive errors for error cases * - Keep methods focused and well-named * - Document complex logic with comments * * AVOID: * - Mixing concerns (keep focused on single domain) * - Direct tool implementation (services should be tool-agnostic) */ import { createHash } from 'node:crypto'; import { promises as fs } from 'node:fs'; import { log, TemplatesManagerService } from '@agiflowai/aicode-utils'; import { loadCsf } from '@storybook/csf-tools'; import { glob } from 'glob'; import type { ComponentInfo, StoryMeta } from './types'; /** * StoriesIndexService handles indexing and querying Storybook story files. * * Provides methods for scanning story files, extracting metadata using AST parsing, * and querying components by tags, title, or name. * * @example * ```typescript * const service = new StoriesIndexService(); * await service.initialize(); * const components = service.getAllComponents(); * const button = service.findComponentByName('Button'); * ``` */ /** * Result of initialization with success/failure statistics. */ export interface InitializationResult { /** Total number of story files found */ totalFiles: number; /** Number of successfully indexed files */ successCount: number; /** Number of files that failed to index */ failureCount: number; /** List of files that failed with their error messages */ failures: Array<{ filePath: string; error: string }>; } export class StoriesIndexService { private componentIndex: Map<string, ComponentInfo> = new Map(); private monorepoRoot: string; private initialized = false; /** Last initialization result for error reporting */ private lastInitResult: InitializationResult | null = null; /** * Creates a new StoriesIndexService instance */ constructor() { this.monorepoRoot = TemplatesManagerService.getWorkspaceRootSync(); } /** * Initialize the index by scanning all .stories files. * @returns Initialization result with success/failure statistics */ async initialize(): Promise<InitializationResult> { if (this.initialized && this.lastInitResult) { return this.lastInitResult; } log.info('[StoriesIndexService] Initializing story index...'); // Find all .stories.tsx and .stories.ts files const storyFiles = await glob('**/*.stories.{ts,tsx}', { cwd: this.monorepoRoot, ignore: ['**/node_modules/**', '**/dist/**', '**/.next/**', '**/build/**'], absolute: true, }); log.info(`[StoriesIndexService] Found ${storyFiles.length} story files`); // Collect indexing results const failures: Array<{ filePath: string; error: string }> = []; let successCount = 0; // Process each story file for (const filePath of storyFiles) { try { await this.indexStoryFile(filePath); successCount++; } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); log.error(`[StoriesIndexService] Error indexing ${filePath}: ${errorMessage}`); failures.push({ filePath, error: errorMessage }); } } this.initialized = true; // Store and return result this.lastInitResult = { totalFiles: storyFiles.length, successCount, failureCount: failures.length, failures, }; // Log summary if (failures.length > 0) { log.warn( `[StoriesIndexService] Indexed ${successCount}/${storyFiles.length} files successfully. ${failures.length} files failed.`, ); } else { log.info(`[StoriesIndexService] Indexed ${this.componentIndex.size} components successfully`); } return this.lastInitResult; } /** * Get the last initialization result. * @returns Initialization result or null if not initialized */ getLastInitResult(): InitializationResult | null { return this.lastInitResult; } /** * Index a single story file using @storybook/csf-tools. * * Uses the official Storybook CSF parser which handles all CSF formats * including TypeScript satisfies/as expressions. */ private async indexStoryFile(filePath: string): Promise<void> { // Read file content for hashing and parsing const content = await fs.readFile(filePath, 'utf-8'); const fileHash = this.hashContent(content); // Parse the story file using @storybook/csf-tools const csf = loadCsf(content, { fileName: filePath, makeTitle: (title) => title, }); // Parse the CSF to extract meta and stories await csf.parse(); // Validate meta exists with title if (!csf.meta?.title) { log.warn(`[StoriesIndexService] No valid meta title in ${filePath}`); return; } // Extract story names from the parsed stories (filter out undefined) const stories = csf.stories.map((story) => story.name).filter((name): name is string => !!name); // Extract tags (ensure it's an array of strings) const tags: string[] = Array.isArray(csf.meta.tags) ? csf.meta.tags.filter((t): t is string => typeof t === 'string') : []; // Extract description from file header JSDoc or meta.parameters.docs.description const description = this.extractDescription(content, csf.meta as unknown as Record<string, unknown>); // Build StoryMeta from csf.meta const meta: StoryMeta = { title: csf.meta.title, tags, }; // Create component info const componentInfo: ComponentInfo = { title: meta.title, filePath, fileHash, tags, stories, meta, description, }; // Index by title this.componentIndex.set(meta.title, componentInfo); } /** * Extract component description from file header JSDoc or meta.parameters.docs.description. * * Priority: * 1. meta.parameters.docs.description.component (Storybook standard) * 2. File header JSDoc comment (first block comment in file) * * @param content - Raw file content * @param meta - Parsed meta object from csf-tools * @returns Description string or undefined */ private extractDescription( content: string, meta: Record<string, unknown>, ): string | undefined { // Priority 1: Check meta.parameters.docs.description.component const parameters = meta?.parameters as Record<string, unknown> | undefined; const docs = parameters?.docs as Record<string, unknown> | undefined; const descriptionObj = docs?.description as Record<string, unknown> | undefined; const docsDescription = descriptionObj?.component; if (typeof docsDescription === 'string' && docsDescription.trim()) { return docsDescription.trim(); } // Priority 2: Extract file header JSDoc comment // Look for the first block comment at the start of the file (after optional whitespace) const jsDocMatch = content.match(/^\s*\/\*\*\s*([\s\S]*?)\s*\*\//); if (jsDocMatch?.[1]) { // Clean up JSDoc formatting: remove leading asterisks and normalize whitespace const description = jsDocMatch[1] .split('\n') .map((line) => line.replace(/^\s*\*\s?/, '').trim()) .filter((line) => !line.startsWith('@')) // Remove JSDoc tags .join(' ') .replace(/\s+/g, ' ') .trim(); if (description) { return description; } } return undefined; } /** * Hash file content for cache invalidation */ private hashContent(content: string): string { return createHash('sha256').update(content).digest('hex'); } /** * Get all components filtered by tags * @param tags - Optional array of tags to filter by * @returns Array of matching components */ getComponentsByTags(tags?: string[]): ComponentInfo[] { const components = Array.from(this.componentIndex.values()); if (!tags || tags.length === 0) { return components; } return components.filter((component) => tags.some((tag) => component.tags.includes(tag))); } /** * Get component by title * @param title - Exact title to match (e.g., "Components/Button") * @returns Component info or undefined */ getComponentByTitle(title: string): ComponentInfo | undefined { return this.componentIndex.get(title); } /** * Find component by partial name match * @param name - Partial name to search for * @returns First matching component or undefined */ findComponentByName(name: string): ComponentInfo | undefined { const lowerName = name.toLowerCase(); // First try exact match on the component name (last part of title) for (const component of this.componentIndex.values()) { const componentName = component.title.split('/').pop() || component.title; if (componentName.toLowerCase() === lowerName) { return component; } } // Then try partial match for (const component of this.componentIndex.values()) { const componentName = component.title.split('/').pop() || component.title; if (componentName.toLowerCase().includes(lowerName)) { return component; } } return undefined; } /** * Refresh a specific file if it has changed * @param filePath - Absolute path to story file * @returns True if file was updated, false if unchanged */ async refreshFile(filePath: string): Promise<boolean> { const content = await fs.readFile(filePath, 'utf-8'); const newHash = this.hashContent(content); // Find existing component with this file path const existingComponent = Array.from(this.componentIndex.values()).find((c) => c.filePath === filePath); if (existingComponent && existingComponent.fileHash === newHash) { // No changes return false; } // Re-index the file await this.indexStoryFile(filePath); return true; } /** * Get all indexed components * @returns Array of all component info objects */ getAllComponents(): ComponentInfo[] { return Array.from(this.componentIndex.values()); } /** * Clear the index (useful for testing) */ clear(): void { this.componentIndex.clear(); this.initialized = false; this.lastInitResult = null; } /** * Get all unique tags from indexed components * @returns Sorted array of unique tag names */ getAllTags(): string[] { const tagSet = new Set<string>(); for (const component of this.componentIndex.values()) { for (const tag of component.tags) { tagSet.add(tag); } } return Array.from(tagSet).sort(); } }

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/AgiFlow/aicode-toolkit'

If you have feedback or need assistance with the MCP directory API, please join our Discord server