Skip to main content
Glama
CSSThemeService.ts6.43 kB
/** * CSSThemeService * * DESIGN PATTERNS: * - Extends BaseThemeService for CSS-based theme extraction * - Uses postcss AST parser for robust CSS parsing * - Extracts theme names from CSS class selectors * * CODING STANDARDS: * - Implement abstract methods from base class * - Use async/await for file operations * - Throw descriptive errors with context * * AVOID: * - Hardcoding theme paths * - Synchronous file operations * - Fragile regex-based CSS parsing */ import { promises as fs } from 'node:fs'; import path from 'node:path'; import { log, TemplatesManagerService } from '@agiflowai/aicode-utils'; import postcss from 'postcss'; import { BaseThemeService } from './BaseThemeService'; import type { AvailableThemesResult, ThemeInfo, ThemeServiceConfig } from './types'; /** * CSS-based theme service implementation. * * Extracts themes from CSS files by parsing class selectors that contain * CSS custom properties (variables). Each top-level class selector with * color variables is treated as a theme. * * @example * ```typescript * const service = new CSSThemeService({ themePath: 'apps/my-app/src/styles/colors.css' }); * const result = await service.listThemes(); * console.log(result.themes); // [{ name: 'slate', ... }, { name: 'blue', ... }] * ``` */ export class CSSThemeService extends BaseThemeService { private monorepoRoot: string; /** * Creates a new CSSThemeService instance * @param config - Theme service configuration with themePath or cssFiles */ constructor(config: ThemeServiceConfig) { super(config); this.monorepoRoot = TemplatesManagerService.getWorkspaceRootSync(); } /** * Get the source identifier for this service * @returns 'css-file' identifier */ getSourceId(): 'css-file' { return 'css-file'; } /** * List available themes by parsing CSS files. * * Scans the configured CSS files for class selectors that define * CSS custom properties (--color-*, etc.) and returns them as themes. * * @returns Promise resolving to available themes result * @throws Error if no theme files are configured or files cannot be read */ async listThemes(): Promise<AvailableThemesResult> { const cssFilePaths = this.resolveCSSFilePaths(); if (cssFilePaths.length === 0) { throw new Error( 'No theme CSS files configured. Set themePath or cssFiles in project.json style-system config.', ); } const themes: ThemeInfo[] = []; for (const cssFilePath of cssFilePaths) { try { await this.validatePath(cssFilePath); const fileThemes = await this.extractThemesFromCSS(cssFilePath); themes.push(...fileThemes); } catch (error) { log.warn(`[CSSThemeService] Could not process ${cssFilePath}:`, error); } } // Deduplicate themes by name const uniqueThemes = this.deduplicateThemes(themes); return { themes: uniqueThemes.sort((a, b) => a.name.localeCompare(b.name)), activeTheme: uniqueThemes.length > 0 ? uniqueThemes[0].name : undefined, source: 'css-file', }; } /** * Resolve configured CSS file paths to absolute paths. * @returns Array of absolute CSS file paths */ private resolveCSSFilePaths(): string[] { const paths: string[] = []; // Add themePath if configured if (this.config.themePath) { const themePath = path.isAbsolute(this.config.themePath) ? this.config.themePath : path.join(this.monorepoRoot, this.config.themePath); paths.push(themePath); } // Add cssFiles if configured if (this.config.cssFiles && this.config.cssFiles.length > 0) { for (const cssFile of this.config.cssFiles) { const cssPath = path.isAbsolute(cssFile) ? cssFile : path.join(this.monorepoRoot, cssFile); if (!paths.includes(cssPath)) { paths.push(cssPath); } } } return paths; } /** * Extract themes from a CSS file by parsing class selectors. * * Identifies class selectors that contain CSS custom property declarations * (--color-*, --primary-*, etc.) and treats each as a theme definition. * * @param cssFilePath - Absolute path to CSS file * @returns Array of ThemeInfo objects */ private async extractThemesFromCSS(cssFilePath: string): Promise<ThemeInfo[]> { const content = await fs.readFile(cssFilePath, 'utf-8'); const fileName = path.basename(cssFilePath); const themes: ThemeInfo[] = []; try { const root = postcss.parse(content); // Walk all rules looking for class selectors with color variables root.walkRules((rule) => { // Only process simple class selectors (e.g., .slate, .blue) const selector = rule.selector.trim(); if (!selector.startsWith('.') || selector.includes(' ') || selector.includes(',')) { return; } // Extract class name (remove leading dot) const className = selector.slice(1); // Check if this rule has color-related custom properties const colorVariables: Record<string, string> = {}; let hasColorVariables = false; rule.walkDecls((decl) => { if (decl.prop.startsWith('--color-') || decl.prop.startsWith('--primary-')) { colorVariables[decl.prop] = decl.value; hasColorVariables = true; } }); // Only add as theme if it has color variables if (hasColorVariables) { themes.push({ name: className, fileName, path: cssFilePath, colorVariables, shadeCount: Object.keys(colorVariables).length, }); } }); } catch (error) { throw new Error( `Failed to parse CSS file ${cssFilePath}: ${error instanceof Error ? error.message : String(error)}`, ); } log.info(`[CSSThemeService] Found ${themes.length} themes in ${fileName}`); return themes; } /** * Deduplicate themes by name, keeping the first occurrence. * @param themes - Array of themes to deduplicate * @returns Deduplicated array of themes */ private deduplicateThemes(themes: ThemeInfo[]): ThemeInfo[] { const seen = new Set<string>(); return themes.filter((theme) => { if (seen.has(theme.name)) { return false; } seen.add(theme.name); return true; }); } }

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