/** * Clone Architect — Layout Analyzer * * Prend le raw-css.json d'une extraction et produit layout-analysis.md : * - Type de layout (sidebar+main, top-nav, full-width, split) * - Dimensions clés (sidebar width, header height, card sizes, gaps) * - Navigation (type, items, active state) * - Grille (columns, pattern, aspect-ratio) * - Composants identifiés * - Animations/transitions */ import { readFile, writeFile } from 'fs/promises'; import { join } from 'path'; import { parseRgb, luminanceSimple } from './shared/css-helpers.js'; interface ExtractionResult { url: string; domain: string; viewport: { width: number; height: number }; pageTitle: string; cssCustomProperties: Record; elements: Record; children: number; rect: { x: number; y: number; width: number; height: number } } | null>; sections: Array<{ index: number; tag: string; classes: string[]; role: string; estimatedPurpose: string; rect: { x: number; y: number; width: number; height: number }; styles: Record; childCount: number }>; allColors: string[]; allFontFamilies: string[]; allFontSizes: string[]; allBorderRadii: string[]; allShadows: string[]; allTransitions: string[]; images: { src: string; alt: string; width: number; height: number }[]; links: { href: string; text: string; isNav: boolean }[]; } function detectLayoutType(data: ExtractionResult): string { const sidebar = data.elements.sidebar; const header = data.elements.header; const nav = data.elements.nav; const main = data.elements.main; if (sidebar && sidebar.rect.height > 200) { return 'sidebar + main content'; } if (header && nav) { if (main) return 'top-nav + main content'; return 'top-nav + full-width sections'; } if (!header && !nav && !sidebar) { return 'full-width (single column)'; } return 'top-nav + content'; } function parsePixels(val: string): number { const match = val?.match(/^([\d.]+)px$/); return match ? parseFloat(match[1]) : 0; } function formatColor(color: string): string { return color.replace(/\s+/g, ''); } function groupColors(colors: string[]): { backgrounds: string[]; text: string[]; accents: string[] } { const backgrounds: string[] = []; const text: string[] = []; const accents: string[] = []; for (const c of colors) { const rgb = parseRgb(c); if (!rgb) continue; const lum = luminanceSimple(rgb); if (lum > 0.85) backgrounds.push(c); else if (lum < 0.2) text.push(c); else accents.push(c); } return { backgrounds, text, accents }; } function generateAnalysis(data: ExtractionResult): string { const layoutType = detectLayoutType(data); const colorGroups = groupColors(data.allColors); const navLinks = data.links.filter(l => l.isNav); const lines: string[] = []; lines.push(`# Layout Analysis — ${data.domain}`); lines.push(`> Extracted from ${data.url} on ${new Date().toISOString().split('T')[0]}`); lines.push(`> Viewport: ${data.viewport.width}x${data.viewport.height}`); lines.push(''); // ── Layout Type ── lines.push('## Layout Type'); lines.push(`**${layoutType}**`); lines.push(''); // ── Dimensions ── lines.push('## Key Dimensions'); const dims: string[] = []; const { sidebar, header, main, hero } = data.elements; if (header) dims.push(`- Header height: ${header.rect.height}px`); if (sidebar) dims.push(`- Sidebar width: ${sidebar.rect.width}px`); if (main) dims.push(`- Main content width: ${main.rect.width}px (max-width: ${main.styles.maxWidth})`); if (hero) dims.push(`- Hero height: ${hero.rect.height}px`); const body = data.elements.body; if (body) { dims.push(`- Page background: ${formatColor(body.styles.backgroundColor)}`); dims.push(`- Base font: ${body.styles.fontFamily.split(',')[0].trim()}`); dims.push(`- Base font size: ${body.styles.fontSize}`); dims.push(`- Base text color: ${formatColor(body.styles.color)}`); } lines.push(dims.length ? dims.join('\n') : '- No key dimensions detected'); lines.push(''); // ── Navigation ── lines.push('## Navigation'); const navEl = data.elements.nav || data.elements.header; if (navEl) { lines.push(`- Type: ${navEl.tag} (${navEl.styles.display})`); lines.push(`- Position: ${navEl.styles.position}`); lines.push(`- Background: ${formatColor(navEl.styles.backgroundColor)}`); lines.push(`- Height: ${navEl.rect.height}px`); if (navLinks.length > 0) { lines.push(`- Items (${navLinks.length}):`); for (const link of navLinks.slice(0, 15)) { lines.push(` - "${link.text}" → ${link.href}`); } } } else { lines.push('- No navigation element detected'); } lines.push(''); // ── Sections ── lines.push('## Page Sections'); for (const section of data.sections.slice(0, 20)) { lines.push(`### ${section.estimatedPurpose} (${section.tag}.${section.classes.slice(0, 2).join('.')})`); lines.push(`- Position: y=${section.rect.y}px, height=${section.rect.height}px`); lines.push(`- Layout: ${section.styles.display} ${section.styles.flexDirection || section.styles.gridTemplateColumns || ''}`); lines.push(`- Background: ${formatColor(section.styles.backgroundColor)}`); lines.push(`- Padding: ${section.styles.padding}`); lines.push(`- Children: ${section.childCount}`); lines.push(''); } // ── Components ── lines.push('## Components Detected'); const componentKeys = ['card', 'button', 'input', 'badge', 'avatar', 'logo', 'dropdown', 'modal']; for (const key of componentKeys) { const el = data.elements[key]; if (!el) continue; lines.push(`### ${key}`); lines.push(`- Tag: ${el.tag}`); lines.push(`- Classes: ${el.classes.join(', ') || 'none'}`); lines.push(`- Size: ${el.rect.width}x${el.rect.height}px`); lines.push(`- Background: ${formatColor(el.styles.backgroundColor)}`); lines.push(`- Border: ${el.styles.border}`); lines.push(`- Border radius: ${el.styles.borderRadius}`); lines.push(`- Shadow: ${el.styles.boxShadow}`); lines.push(`- Font: ${el.styles.fontSize} ${el.styles.fontWeight}`); lines.push(`- Text: "${el.text.slice(0, 80)}"`); lines.push(''); } // ── Colors ── lines.push('## Color Palette'); lines.push(`Total unique colors: ${data.allColors.length}`); lines.push(''); if (colorGroups.backgrounds.length) { lines.push(`### Backgrounds (${colorGroups.backgrounds.length})`); for (const c of colorGroups.backgrounds.slice(0, 10)) lines.push(`- ${formatColor(c)}`); lines.push(''); } if (colorGroups.text.length) { lines.push(`### Text (${colorGroups.text.length})`); for (const c of colorGroups.text.slice(0, 10)) lines.push(`- ${formatColor(c)}`); lines.push(''); } if (colorGroups.accents.length) { lines.push(`### Accents (${colorGroups.accents.length})`); for (const c of colorGroups.accents.slice(0, 10)) lines.push(`- ${formatColor(c)}`); lines.push(''); } // ── Typography ── lines.push('## Typography'); lines.push(`### Font Families (${data.allFontFamilies.length})`); for (const f of data.allFontFamilies.slice(0, 10)) { lines.push(`- ${f.split(',')[0].trim()}`); } lines.push(''); lines.push(`### Font Sizes (${data.allFontSizes.length})`); const sortedSizes = [...data.allFontSizes].sort((a, b) => parsePixels(a) - parsePixels(b)); for (const s of sortedSizes) lines.push(`- ${s}`); lines.push(''); // ── Border Radii ── lines.push('## Border Radii'); for (const r of data.allBorderRadii.slice(0, 10)) lines.push(`- ${r}`); lines.push(''); // ── Shadows ── lines.push('## Shadows'); for (const s of data.allShadows.slice(0, 10)) lines.push(`- ${s}`); lines.push(''); // ── Transitions ── lines.push('## Transitions & Animations'); for (const t of data.allTransitions.slice(0, 10)) lines.push(`- ${t}`); lines.push(''); // ── CSS Custom Properties ── const varsCount = Object.keys(data.cssCustomProperties).length; if (varsCount > 0) { lines.push(`## CSS Custom Properties (${varsCount})`); const entries = Object.entries(data.cssCustomProperties).slice(0, 50); for (const [prop, val] of entries) { lines.push(`- \`${prop}\`: ${val}`); } lines.push(''); } // ── Images ── if (data.images.length > 0) { lines.push(`## Images (${data.images.length})`); for (const img of data.images.slice(0, 15)) { lines.push(`- ${img.width}x${img.height} — ${img.alt || 'no alt'} — ${img.src.slice(0, 80)}`); } lines.push(''); } return lines.join('\n'); } // ── CLI ────────────────────────────────────────────────────────────── const domain = process.argv[2]; if (!domain) { console.error('Usage: npm run analyze -- '); console.error('Example: npm run analyze -- linear.app'); process.exit(1); } const baseDir = join(process.cwd(), 'extractions', domain); async function main() { console.log(`\n📊 Analyzing layout for ${domain}...`); const rawPath = join(baseDir, 'raw-css.json'); const raw = JSON.parse(await readFile(rawPath, 'utf-8')); // Analyze desktop viewport const desktopData = raw.desktop as ExtractionResult; if (!desktopData) { console.error('No desktop data found in raw-css.json'); process.exit(1); } const analysis = generateAnalysis(desktopData); // Also generate mobile analysis if present let mobileAnalysis = ''; if (raw.mobile) { mobileAnalysis = '\n\n---\n\n' + generateAnalysis(raw.mobile as ExtractionResult); } const outputPath = join(baseDir, 'layout-analysis.md'); await writeFile(outputPath, analysis + mobileAnalysis); console.log(`✅ Layout analysis saved to ${outputPath}`); } main().catch(err => { console.error('Error:', err); process.exit(1); });