Skip to main content
Glama
GetUiComponentService.ts8.44 kB
/** * GetUiComponentService * * DESIGN PATTERNS: * - Service pattern for business logic encapsulation * - Single responsibility principle * * 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 { promises as fs } from 'node:fs'; import { log } from '@agiflowai/aicode-utils'; import type { DesignSystemConfig } from '../../config'; import { getAppDesignSystemConfig } from '../../config'; import { ComponentRendererService } from '../ComponentRendererService'; import { StoriesIndexService } from '../StoriesIndexService'; import type { GetUiComponentInput, GetUiComponentResult, GetUiComponentServiceConfig, } from './types'; import { DEFAULT_GET_UI_COMPONENT_CONFIG } from './types'; /** * Factory function type for creating StoriesIndexService instances. */ export type StoriesIndexFactory = () => StoriesIndexService; /** * Factory function type for creating ComponentRendererService instances. */ export type RendererFactory = (config: DesignSystemConfig, appPath: string) => ComponentRendererService; /** * GetUiComponentService handles rendering UI component previews. * * Locates components in the stories index, renders them with app-specific * design system configuration, and returns screenshot results. * * @example * ```typescript * const service = new GetUiComponentService(); * const result = await service.getComponent({ * componentName: 'Button', * appPath: 'apps/my-app', * }); * console.log(result.imagePath); * ``` */ export class GetUiComponentService { private config: Required<GetUiComponentServiceConfig>; private storiesIndexFactory: StoriesIndexFactory; private rendererFactory: RendererFactory; /** * Creates a new GetUiComponentService instance. * @param config - Service configuration options * @param storiesIndexFactory - Factory for creating StoriesIndexService (for DI/testing) * @param rendererFactory - Factory for creating ComponentRendererService (for DI/testing) */ constructor( config: GetUiComponentServiceConfig = {}, storiesIndexFactory: StoriesIndexFactory = () => new StoriesIndexService(), rendererFactory: RendererFactory = (c, p) => new ComponentRendererService(c, p), ) { this.config = { ...DEFAULT_GET_UI_COMPONENT_CONFIG, ...config }; this.storiesIndexFactory = storiesIndexFactory; this.rendererFactory = rendererFactory; } /** * Get a UI component preview image. * * @param input - Component input parameters * @returns Result with image path and component metadata * @throws Error if input validation fails, component not found, or rendering fails */ async getComponent(input: GetUiComponentInput): Promise<GetUiComponentResult> { // Validate required inputs if (!input.componentName || typeof input.componentName !== 'string') { throw new Error('componentName is required and must be a non-empty string'); } if (!input.appPath || typeof input.appPath !== 'string') { throw new Error('appPath is required and must be a non-empty string'); } // Validate optional inputs before applying defaults. // This guards against incorrect types from untyped callers at runtime. if (input.storyName !== undefined && typeof input.storyName !== 'string') { throw new Error('storyName must be a string'); } if (input.darkMode !== undefined && typeof input.darkMode !== 'boolean') { throw new Error('darkMode must be a boolean'); } if (input.selector !== undefined) { if (typeof input.selector !== 'string') { throw new Error('selector must be a string'); } // Validate selector to prevent CSS injection attacks // Only allow safe CSS selector characters if (!/^[a-zA-Z0-9_\-#.\[\]=":' ]+$/.test(input.selector)) { throw new Error('selector contains invalid characters'); } // Prevent JavaScript execution in selectors if (/javascript:|expression\(|url\(/i.test(input.selector)) { throw new Error('selector contains potentially malicious content'); } } const { componentName, appPath, storyName = this.config.defaultStoryName, darkMode = this.config.defaultDarkMode, } = input; log.info( `[GetUiComponentService] Starting for component: ${componentName}, appPath: ${appPath}, storyName: ${storyName}`, ); // Initialize stories index using injected factory const storiesIndex = this.storiesIndexFactory(); try { await storiesIndex.initialize(); } catch (error) { throw new Error( `Failed to initialize stories index: ${error instanceof Error ? error.message : String(error)}`, ); } // Find the component const componentInfo = storiesIndex.findComponentByName(componentName); if (!componentInfo) { throw new Error( `Component "${componentName}" not found in stories index. Ensure the component has a .stories.tsx file and has been indexed.`, ); } log.info(`[GetUiComponentService] Found component: ${componentInfo.title}`); // Validate story exists and get valid story name const validStoryName = this.resolveStoryName(storyName, componentInfo.stories); // Get app-specific design system configuration const designSystemConfig = await getAppDesignSystemConfig(appPath); log.info(`[GetUiComponentService] Using theme provider: ${designSystemConfig.themeProvider}`); log.info(`[GetUiComponentService] Design system type: ${designSystemConfig.type}`); // Render the component with app-specific config using injected factory const renderer = this.rendererFactory(designSystemConfig, appPath); try { const renderResult = await renderer.renderComponent(componentInfo, { storyName: validStoryName, width: this.config.defaultWidth, height: this.config.defaultHeight, darkMode, }); log.info(`[GetUiComponentService] Component rendered to: ${renderResult.imagePath}`); // Read the story file content const storyFileContent = await this.readStoryFile(componentInfo.filePath); log.info('[GetUiComponentService] Completed successfully'); return { imagePath: renderResult.imagePath, format: 'png', dimensions: '900px width, 80% quality', storyFilePath: componentInfo.filePath, storyFileContent, componentTitle: componentInfo.title, availableStories: componentInfo.stories, renderedStory: validStoryName, }; } finally { // Ensure Vite server is always cleaned up await renderer.dispose(); } } /** * Resolve and validate the story name. * * @param requestedStory - The requested story name * @param availableStories - List of available stories for the component * @returns Valid story name (requested or fallback) */ private resolveStoryName(requestedStory: string, availableStories: string[]): string { if (availableStories.includes(requestedStory)) { return requestedStory; } log.warn( `[GetUiComponentService] Story "${requestedStory}" not found, available stories: ${availableStories.join(', ')}`, ); // Fallback to first available story, or 'Default' if no stories exist const fallbackStory = availableStories[0] || 'Default'; log.info(`[GetUiComponentService] Using fallback story: ${fallbackStory}`); return fallbackStory; } /** * Read the story file content. * * @param filePath - Path to the story file * @returns File content or error message */ private async readStoryFile(filePath: string): Promise<string> { try { const content = await fs.readFile(filePath, 'utf-8'); log.info(`[GetUiComponentService] Story file read successfully (${content.length} chars)`); return content; } catch (error) { log.warn( `[GetUiComponentService] Warning: Could not read story file: ${error instanceof Error ? error.message : String(error)}`, ); return `// Could not read file: ${filePath}\n// Error: ${error instanceof Error ? error.message : String(error)}`; } } }

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