/** * Component Metadata Generator * * Scans wokwi-elements repository and generates component metadata JSON. * Runs during build-time to extract: * - Component names from @customElement decorators * - Properties from @property decorators * - Display metadata from .stories.ts files * - Pin information from pinInfo getters */ import * as fs from 'fs'; import * as path from 'path'; import * as ts from 'typescript'; import type { ComponentMetadata, ComponentCategory } from '../frontend/src/types/component-metadata'; // Hardcoded category mapping (components don't self-declare categories) const CATEGORY_MAP: Record = { // Boards 'arduino-uno': 'boards', 'arduino-mega': 'boards', 'arduino-nano': 'boards', 'esp32-devkit-v1': 'boards', 'pi-pico': 'boards', // Sensors 'dht22': 'sensors', 'hc-sr04': 'sensors', 'pir-motion-sensor': 'sensors', 'mq2-gas-sensor': 'sensors', 'mpu6050': 'sensors', 'ds18b20-temp': 'sensors', 'ntc-temperature-sensor': 'sensors', 'photoresistor-sensor': 'sensors', // Displays 'lcd1602': 'displays', 'lcd2004': 'displays', 'ssd1306': 'displays', 'tm1637-7segment': 'displays', 'ks2e-7segment': 'displays', 'max7219-matrix': 'displays', 'ili9341': 'displays', // Input 'pushbutton': 'input', 'slide-switch': 'input', 'dip-switch-8': 'input', 'membrane-keypad': 'input', 'potentiometer': 'input', 'sliding-potentiometer': 'input', // Output 'led': 'output', 'led-bar-graph': 'output', 'neopixel': 'output', 'led-matrix': 'output', 'rgb-led': 'output', 'buzzer': 'output', 'relay-module': 'output', // Motors 'stepper-motor': 'motors', 'servo': 'motors', 'biaxial-stepper': 'motors', // Communication 'bluetooth-hc-05': 'communication', 'wifi-module': 'communication', // Passive Components 'resistor': 'passive', 'capacitor': 'passive', 'diode': 'passive', 'analog-multiplexer': 'passive', 'ir-receiver': 'passive', 'ir-remote': 'passive', 'franzininho': 'passive', 'logic-analyzer': 'passive', }; interface ParsedComponent { tagName: string; className: string; properties: Array<{ name: string; type: string; defaultValue?: any; }>; pinCount: number; } class MetadataGenerator { private wokwiElementsPath: string; private outputPath: string; constructor() { this.wokwiElementsPath = path.join(__dirname, '../wokwi-libs/wokwi-elements/src'); this.outputPath = path.join(__dirname, '../frontend/public/components-metadata.json'); } /** * Main entry point - generates metadata JSON */ async generate(): Promise { console.log('šŸ” Scanning wokwi-elements directory...'); if (!fs.existsSync(this.wokwiElementsPath)) { console.error(`āŒ wokwi-elements not found at: ${this.wokwiElementsPath}`); console.log('šŸ’” Make sure to initialize the git submodule:'); console.log(' git submodule update --init --recursive'); process.exit(1); } const components: ComponentMetadata[] = []; const elementFiles = this.findElementFiles(); console.log(`šŸ“¦ Found ${elementFiles.length} element files`); for (const filePath of elementFiles) { try { const metadata = this.parseElementFile(filePath); if (metadata) { components.push(metadata); console.log(` āœ“ ${metadata.name} (${metadata.tagName})`); } } catch (error) { console.error(` āœ— Failed to parse ${path.basename(filePath)}:`, error); } } // Sort by category and name components.sort((a, b) => { if (a.category !== b.category) { return a.category.localeCompare(b.category); } return a.name.localeCompare(b.name); }); const output = { version: '1.0.0', generatedAt: new Date().toISOString(), components, }; // Ensure output directory exists const outputDir = path.dirname(this.outputPath); if (!fs.existsSync(outputDir)) { fs.mkdirSync(outputDir, { recursive: true }); } fs.writeFileSync(this.outputPath, JSON.stringify(output, null, 2)); console.log(`\nāœ… Generated metadata for ${components.length} components`); console.log(`šŸ“„ Output: ${this.outputPath}`); } /** * Find all *-element.ts files (excluding .stories.ts) */ private findElementFiles(): string[] { const files = fs.readdirSync(this.wokwiElementsPath); return files .filter(file => file.endsWith('-element.ts') && !file.includes('.stories')) .map(file => path.join(this.wokwiElementsPath, file)); } /** * Parse a single element file and extract metadata */ private parseElementFile(filePath: string): ComponentMetadata | null { const sourceCode = fs.readFileSync(filePath, 'utf-8'); const sourceFile = ts.createSourceFile( filePath, sourceCode, ts.ScriptTarget.Latest, true ); const parsed = this.parseTypeScriptAST(sourceFile); if (!parsed) return null; const id = this.extractIdFromTagName(parsed.tagName); const category = CATEGORY_MAP[id] || 'other'; const storiesMetadata = this.parseStoriesFile(filePath); return { id, tagName: parsed.tagName, name: storiesMetadata?.name || this.formatName(id), category, description: storiesMetadata?.description, thumbnail: this.generateThumbnailPlaceholder(id), properties: parsed.properties.map(prop => ({ name: prop.name, type: this.mapPropertyType(prop.type), defaultValue: prop.defaultValue, control: this.inferControl(prop.type), })), defaultValues: this.extractDefaultValues(parsed.properties), pinCount: parsed.pinCount, tags: this.generateTags(id, storiesMetadata?.name || ''), }; } /** * Parse TypeScript AST to extract decorators and properties */ private parseTypeScriptAST(sourceFile: ts.SourceFile): ParsedComponent | null { let tagName = ''; let className = ''; const properties: ParsedComponent['properties'] = []; let pinCount = 0; const visit = (node: ts.Node) => { // Find @customElement decorator if (ts.isDecorator(node)) { const decorator = node as ts.Decorator; if (ts.isCallExpression(decorator.expression)) { const call = decorator.expression; if (ts.isIdentifier(call.expression) && call.expression.text === 'customElement') { const arg = call.arguments[0]; if (ts.isStringLiteral(arg)) { tagName = arg.text; } } } } // Find class declaration if (ts.isClassDeclaration(node) && node.name) { className = node.name.text; // Find @property decorators node.members.forEach(member => { if (ts.isPropertyDeclaration(member)) { const propertyDecorators = ts.getDecorators(member); if (propertyDecorators?.some(d => ts.isCallExpression(d.expression) && ts.isIdentifier(d.expression.expression) && d.expression.expression.text === 'property' )) { const name = member.name.getText(); const type = member.type?.getText() || 'any'; const defaultValue = member.initializer?.getText(); properties.push({ name, type, defaultValue: defaultValue ? eval(defaultValue) : undefined, }); } } // Count pins from pinInfo getter if (ts.isGetAccessor(member)) { const accessorName = member.name.getText(); if (accessorName === 'pinInfo') { const bodyText = member.body?.getText() || ''; // Count array elements in return statement const matches = bodyText.match(/\{[^}]+\}/g); if (matches) { pinCount = matches.length; } } } }); } ts.forEachChild(node, visit); }; visit(sourceFile); if (!tagName || !className) return null; return { tagName, className, properties, pinCount }; } /** * Parse corresponding .stories.ts file for UI metadata */ private parseStoriesFile(elementFilePath: string): { name?: string; description?: string } | null { const storiesPath = elementFilePath.replace('-element.ts', '-element.stories.ts'); if (!fs.existsSync(storiesPath)) return null; try { const content = fs.readFileSync(storiesPath, 'utf-8'); // Extract title (name) const titleMatch = content.match(/title:\s*['"]([^'"]+)['"]/); const name = titleMatch?.[1]; // Extract description const descMatch = content.match(/description:\s*['"`]([^'"`]+)['"`]/); const description = descMatch?.[1]; return { name, description }; } catch { return null; } } /** * Helper methods */ private extractIdFromTagName(tagName: string): string { return tagName.replace('wokwi-', ''); } private formatName(id: string): string { return id .split('-') .map(word => word.charAt(0).toUpperCase() + word.slice(1)) .join(' '); } private mapPropertyType(tsType: string): 'string' | 'number' | 'boolean' | 'color' | 'select' { if (tsType.includes('number')) return 'number'; if (tsType.includes('boolean')) return 'boolean'; if (tsType.includes('string')) return 'string'; return 'string'; } private inferControl(tsType: string): 'text' | 'range' | 'color' | 'boolean' | 'select' { if (tsType.includes('boolean')) return 'boolean'; if (tsType.includes('number')) return 'range'; return 'text'; } private extractDefaultValues(properties: ParsedComponent['properties']): Record { const defaults: Record = {}; properties.forEach(prop => { if (prop.defaultValue !== undefined) { defaults[prop.name] = prop.defaultValue; } }); return defaults; } private generateThumbnailPlaceholder(id: string): string { // For now, return a simple SVG placeholder // TODO: Extract actual SVG from render() method return ` ${id.toUpperCase()} `; } private generateTags(id: string, name: string): string[] { const tags = [id, name.toLowerCase()]; // Add individual words for better search id.split('-').forEach(word => tags.push(word)); name.split(' ').forEach(word => tags.push(word.toLowerCase())); return [...new Set(tags)]; // Remove duplicates } } // Run generator const generator = new MetadataGenerator(); generator.generate().catch(error => { console.error('āŒ Metadata generation failed:', error); process.exit(1); });