/** * bank-register.ts — Clone Architect Component Bank * Reads existing extractions → creates ComponentSnapshot JSON files */ import * as fs from 'fs'; import * as path from 'path'; const EXTRACTIONS_DIR = path.join(process.cwd(), 'extractions'); export const BANK_DIR = path.join(process.cwd(), 'bank'); // ─── Interfaces (unifiées via shared/types.ts) ────────────────────────────── import type { SiteTokens, ComponentSnapshot, BankIndexEntry, BankIndex, BankCatalog, } from './shared/types.js'; // Re-export pour backward compat export type { SiteTokens, ComponentSnapshot, BankIndexEntry, BankIndex, BankCatalog }; // ─── Helpers ───────────────────────────────────────────────────────────────── export function slugify(str: string): string { return str .replace(/\./g, '-') .replace(/[^a-z0-9-]/gi, '-') .replace(/-+/g, '-') .toLowerCase() .replace(/^-|-$/g, ''); } import { parseRgb as parseRgbShared, luminanceMemo as luminance, colorHueTag, } from './shared/css-helpers.js'; /** Wrapper avec fallback [0,0,0] pour compat bank-register */ function parseRgb(color: string): [number, number, number] { return parseRgbShared(color) ?? [0, 0, 0]; } function detectTags(domain: string, siteTokens: SiteTokens): string[] { const tags: string[] = []; // Theme tags.push(siteTokens.isDark ? 'dark' : 'light'); // Industry if (/sneakers|resell|wehaveit|limited|wethenew|shopify|product|store|ecom/i.test(domain)) { tags.push('ecommerce'); } if (/linear|raindrop|notion|figma|vercel|app|studio|dashboard/i.test(domain)) { tags.push('saas'); } // Font style const font = siteTokens.typography.primary.toLowerCase().replace(/['"]/g, ''); if (/grotesque|grotesk|gothic|helvetica|inter|dm\s?sans|figtree|roboto|manrope|outfit/i.test(font)) { tags.push('sans-modern'); } else if (/serif|times|georgia|playfair|garamond/i.test(font)) { tags.push('serif'); } else if (/mono|code|courier|jetbrains/i.test(font)) { tags.push('mono'); } else if (/montserrat|futura|avenir|proxima|gilroy/i.test(font)) { tags.push('geometric-sans'); } else { tags.push('custom-font'); } // Border radius const br = parseFloat(siteTokens.borderRadius.md || '0'); if (br <= 2) tags.push('sharp'); else if (br <= 8) tags.push('subtle-radius'); else if (br <= 16) tags.push('rounded'); else tags.push('pill-radius'); // Accent color hue const hueTag = colorHueTag(siteTokens.colors.accent); if (hueTag) tags.push(hueTag); else tags.push('accent-neutral'); return [...new Set(tags)]; } function buildSiteTokens(tokens: any): SiteTokens { const bgColor = tokens.colors?.background?.primary || 'rgb(255, 255, 255)'; const isTransparent = bgColor.includes('rgba(0, 0, 0, 0)'); const resolvedBg = isTransparent ? 'rgb(255, 255, 255)' : bgColor; const [r, g, b] = parseRgb(resolvedBg); return { colors: { background: resolvedBg, text: tokens.colors?.text?.primary || 'rgb(0, 0, 0)', accent: tokens.colors?.accent?.primary || 'rgb(0, 0, 0)', border: tokens.colors?.border || 'rgb(229, 231, 235)', }, typography: { primary: tokens.typography?.fontFamily?.primary || 'sans-serif', secondary: tokens.typography?.fontFamily?.secondary, }, borderRadius: { sm: tokens.borderRadius?.sm || '4px', md: tokens.borderRadius?.md || '8px', full: tokens.borderRadius?.full || '9999px', }, spacing: { xs: tokens.spacing?.xs || '4px', sm: tokens.spacing?.sm || '8px', md: tokens.spacing?.md || '16px', lg: tokens.spacing?.lg || '24px', }, isDark: luminance(r, g, b) < 0.3, }; } function normalizeType(rawType: string): string { if (rawType.startsWith('heading')) return 'heading'; if (rawType === 'buttons') return 'button'; if (rawType === 'cards') return 'card'; if (rawType === 'inputs') return 'input'; if (rawType === 'badges') return 'badge'; if (rawType === 'links') return 'link'; return rawType; } function labelVariant(type: string, rawType: string, index: number, styles: any): string { if (type === 'button') { if (index === 0) return 'Primary Button'; const bg = styles?.backgroundColor || ''; const [r, g, b] = parseRgb(bg); const lum = luminance(r, g, b); if (lum > 0.8) return `Ghost Button`; if (lum > 0.4) return `Light Button`; return `Dark Button`; } if (type === 'card') { if (index === 0) return 'Primary Card'; return `Card Variant ${index + 1}`; } if (type === 'heading') { const level = rawType.replace('heading', '').replace('H', 'h'); return `Heading ${level.toUpperCase() || (index + 1)}`; } if (type === 'input') return index === 0 ? 'Text Input' : `Input Variant ${index + 1}`; if (type === 'badge') return index === 0 ? 'Primary Badge' : `Badge Variant ${index + 1}`; if (type === 'link') return index === 0 ? 'Text Link' : `Link Variant ${index + 1}`; return `${type} ${index + 1}`; } // ─── Core: register one domain ──────────────────────────────────────────────── export async function registerDomain(domain: string, verbose = true): Promise { const extractionPath = path.join(EXTRACTIONS_DIR, domain); if (!fs.existsSync(extractionPath)) { if (verbose) console.error(` ❌ Extraction introuvable: ${domain}`); return 0; } const tokensPath = path.join(extractionPath, 'tokens.json'); const rawCssPath = path.join(extractionPath, 'raw-css.json'); const designMdPath = path.join(extractionPath, 'DESIGN.md'); if (!fs.existsSync(tokensPath) || !fs.existsSync(rawCssPath)) { if (verbose) console.error(` ❌ tokens.json ou raw-css.json manquant pour ${domain}`); return 0; } const tokens = JSON.parse(fs.readFileSync(tokensPath, 'utf-8')); const rawCss = JSON.parse(fs.readFileSync(rawCssPath, 'utf-8')); const desktop = rawCss.desktop || rawCss; const componentVariants: Record = desktop.componentVariants || {}; const siteTokens = buildSiteTokens(tokens); const baseTags = detectTags(domain, siteTokens); const registeredAt = new Date().toISOString(); const extractedAt = tokens.meta?.extractedAt || tokens.meta?.tokenizedAt || registeredAt; const url = tokens.meta?.source || `https://${domain.replace(/--.*$/, '')}/`; // Ensure bank directory fs.mkdirSync(BANK_DIR, { recursive: true }); // Load or init bank index / catalog const indexPath = path.join(BANK_DIR, '_index.json'); const catalogPath = path.join(BANK_DIR, '_catalog.json'); const index: BankIndex = fs.existsSync(indexPath) ? JSON.parse(fs.readFileSync(indexPath, 'utf-8')) : { version: '1.0.0', updatedAt: registeredAt, components: [] }; const catalog: BankCatalog = fs.existsSync(catalogPath) ? JSON.parse(fs.readFileSync(catalogPath, 'utf-8')) : { version: '1.0.0', updatedAt: registeredAt, byType: {}, byDomain: {}, byTags: {} }; // Remove existing entries for this domain (clean re-register) const domainSlug = slugify(domain); index.components = index.components.filter(c => c.domain !== domain); for (const t of Object.keys(catalog.byType)) { catalog.byType[t] = catalog.byType[t].filter(id => !id.startsWith(domainSlug + '--')); } delete catalog.byDomain[domain]; let count = 0; for (const [rawType, variants] of Object.entries(componentVariants)) { const variantArray = Array.isArray(variants) ? variants : [variants]; const normType = normalizeType(rawType); for (let i = 0; i < variantArray.length; i++) { const variant = variantArray[i]; if (!variant || typeof variant !== 'object' || Object.keys(variant).length === 0) continue; const id = `${domainSlug}--${rawType}--${i}`; const label = labelVariant(normType, rawType, i, variant); // Component-specific tags const componentTags = [...baseTags]; if (normType === 'button') componentTags.push('button'); if (normType === 'card') componentTags.push('card'); if (normType === 'heading') componentTags.push('heading', 'typography'); if (normType === 'input') componentTags.push('form', 'input'); if (normType === 'badge') componentTags.push('badge'); if (normType === 'link') componentTags.push('link'); const screenshotFull = path.join(extractionPath, 'screenshots', 'desktop-full.png'); const screenshotThumb = path.join(extractionPath, 'screenshots', 'desktop-thumb.png'); const screenshotPath = fs.existsSync(screenshotFull) ? path.relative(BANK_DIR, screenshotFull) : fs.existsSync(screenshotThumb) ? path.relative(BANK_DIR, screenshotThumb) : undefined; const snapshot: ComponentSnapshot = { id, domain, extractionPath: path.relative(BANK_DIR, extractionPath), url, extractedAt, registeredAt, componentType: normType, componentLabel: label, variantIndex: i, siteTokens, styles: variant, tags: [...new Set(componentTags)], screenshotPath, designMdPath: fs.existsSync(designMdPath) ? path.relative(BANK_DIR, designMdPath) : undefined, }; // Save snapshot to bank/{id}/snapshot.json const snapshotDir = path.join(BANK_DIR, id); fs.mkdirSync(snapshotDir, { recursive: true }); fs.writeFileSync( path.join(snapshotDir, 'snapshot.json'), JSON.stringify(snapshot, null, 2) ); // Update index index.components.push({ id, domain, type: normType, label, tags: snapshot.tags, accent: siteTokens.colors.accent, background: siteTokens.colors.background, isDark: siteTokens.isDark, extractedAt, registeredAt, }); // Update catalog if (!catalog.byType[normType]) catalog.byType[normType] = []; if (!catalog.byType[normType].includes(id)) catalog.byType[normType].push(id); if (!catalog.byDomain[domain]) catalog.byDomain[domain] = []; catalog.byDomain[domain].push(id); for (const tag of snapshot.tags) { if (!catalog.byTags[tag]) catalog.byTags[tag] = []; if (!catalog.byTags[tag].includes(id)) catalog.byTags[tag].push(id); } count++; } } // ── Register block extractions (from extract-block.ts) ───────────── const blocksDir = path.join(extractionPath, 'blocks'); if (fs.existsSync(blocksDir)) { const blockFiles = fs.readdirSync(blocksDir).filter(f => f.endsWith('.json')); for (const file of blockFiles) { try { const block = JSON.parse(fs.readFileSync(path.join(blocksDir, file), 'utf-8')); const blockSlug = file.replace('.json', ''); const id = `${domainSlug}--block--${blockSlug}`; const blockTags = [...baseTags, 'block']; // Detect block type from selector/tag const rootTag = block.rootNode?.tag || 'div'; if (['header', 'nav'].includes(rootTag)) blockTags.push('navigation'); if (['main', 'section', 'article'].includes(rootTag)) blockTags.push('content'); if (rootTag === 'footer') blockTags.push('footer'); const snapshot: ComponentSnapshot = { id, domain, extractionPath: path.relative(BANK_DIR, extractionPath), url: block.url || url, extractedAt: block.extractedAt || extractedAt, registeredAt, componentType: 'block', componentLabel: `Block: ${block.selector || blockSlug}`, variantIndex: 0, siteTokens, styles: block.rootNode?.styles || {}, tags: [...new Set(blockTags)], screenshotPath: block.screenshotPath ? path.relative(BANK_DIR, block.screenshotPath) : undefined, designMdPath: fs.existsSync(designMdPath) ? path.relative(BANK_DIR, designMdPath) : undefined, }; // Save snapshot const snapshotDir = path.join(BANK_DIR, id); fs.mkdirSync(snapshotDir, { recursive: true }); fs.writeFileSync(path.join(snapshotDir, 'snapshot.json'), JSON.stringify(snapshot, null, 2)); // Also copy the full block extraction (with DOM tree) alongside snapshot fs.writeFileSync(path.join(snapshotDir, 'block-tree.json'), JSON.stringify(block, null, 2)); // Update index index.components.push({ id, domain, type: 'block', label: snapshot.componentLabel, tags: snapshot.tags, accent: siteTokens.colors.accent, background: siteTokens.colors.background, isDark: siteTokens.isDark, extractedAt: block.extractedAt || extractedAt, registeredAt, }); // Update catalog if (!catalog.byType['block']) catalog.byType['block'] = []; catalog.byType['block'].push(id); if (!catalog.byDomain[domain]) catalog.byDomain[domain] = []; catalog.byDomain[domain].push(id); for (const tag of snapshot.tags) { if (!catalog.byTags[tag]) catalog.byTags[tag] = []; if (!catalog.byTags[tag].includes(id)) catalog.byTags[tag].push(id); } count++; } catch { if (verbose) console.error(` ⚠️ Block ${file} skipped (parse error)`); } } } // Save updated index + catalog index.updatedAt = registeredAt; catalog.updatedAt = registeredAt; fs.writeFileSync(indexPath, JSON.stringify(index, null, 2)); fs.writeFileSync(catalogPath, JSON.stringify(catalog, null, 2)); if (verbose) console.log(` ✅ ${domain} → ${count} composants enregistrés`); return count; }