366 lines
11 KiB
TypeScript
366 lines
11 KiB
TypeScript
/**
|
|
* 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<string, ComponentCategory> = {
|
|
// 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<void> {
|
|
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<string, any> {
|
|
const defaults: Record<string, any> = {};
|
|
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 `<svg width="64" height="64" xmlns="http://www.w3.org/2000/svg">
|
|
<rect width="64" height="64" fill="#e0e0e0" rx="4"/>
|
|
<text x="50%" y="50%" text-anchor="middle" dy=".3em" font-size="10" fill="#666">
|
|
${id.toUpperCase()}
|
|
</text>
|
|
</svg>`;
|
|
}
|
|
|
|
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);
|
|
});
|