import {chromium} from 'playwright'; import * as fs from 'fs/promises'; import * as fsSync from 'fs'; import * as os from 'os'; import * as path from 'path'; // ============================================================================= // Types // ============================================================================= export type Viewport = { width: number; height: number; }; export type NetworkRequest = { url: string; status: number; method: string; resourceType: string; contentType?: string; }; export type RenderResult = { html: string; screenshot: Buffer; requests: NetworkRequest[]; }; export type FirecrawlAsset = { url: string; contentType?: string; }; export type FirecrawlScrapeResult = { success: boolean; html?: string; markdown?: string; links?: string[]; assets?: FirecrawlAsset[]; }; export type FirecrawlClient = { scrapeUrl: ( url: string, options?: {formats?: string[]; includeAssets?: boolean} ) => Promise; }; export type Asset = { url: string; localPath: string; contentType?: string; type: 'image' | 'font' | 'svg' | 'css' | 'other'; downloaded: boolean; }; export type ScrapeResult = { assets: Asset[]; html: string; firecrawlHtml?: string; }; export type Section = { name: string; html: string; selector?: string; order: number; }; export type ComputedStyles = { selector: string; styles: Record; }; export type ExtractedFonts = { googleFontsImports: string[]; fontFamilies: string[]; fontFaceDeclarations: string[]; }; export type ExtractedColors = { hex: string[]; rgb: string[]; hsl: string[]; cssVariables: Record; }; export type ScreenshotComparison = { similarity: number; // 0-1, where 1 is identical diffPixels: number; totalPixels: number; diffImagePath?: string; }; // ============================================================================= // Errors // ============================================================================= export class RenderError extends Error { constructor(message: string, public readonly cause?: unknown) { super(message); this.name = 'RenderError'; } } export class ScrapeError extends Error { constructor(message: string, public readonly cause?: unknown) { super(message); this.name = 'ScrapeError'; } } // ============================================================================= // Constants // ============================================================================= export const defaultViewport: Viewport = {width: 1440, height: 900}; // ============================================================================= // Utilities // ============================================================================= const FIRECRAWL_ENV_KEY = 'FIRECRAWL_API_KEY'; const getConfigDir = (): string => { if (process.platform === 'win32') { return process.env.APPDATA || path.join(os.homedir(), 'AppData', 'Roaming'); } return process.env.XDG_CONFIG_HOME || path.join(os.homedir(), '.config'); }; const getFirecrawlEnvPaths = (): string[] => [ path.join(process.cwd(), '.env'), path.join(__dirname, '..', '.env'), path.join(getConfigDir(), 'website-clone-skill', '.env'), ]; const parseEnvValue = (content: string, key: string): string | undefined => { const regex = new RegExp(`^\\s*${key}\\s*=\\s*(.+)\\s*$`, 'm'); const match = content.match(regex); if (!match) return undefined; const raw = match[1].trim(); const quoted = raw.match(/^(['"])(.*)\1$/); return (quoted ? quoted[2] : raw).trim(); }; const loadEnvValue = (key: string): string | undefined => { const existing = process.env[key]; if (existing) return existing; for (const envPath of getFirecrawlEnvPaths()) { try { if (!fsSync.existsSync(envPath)) continue; const content = fsSync.readFileSync(envPath, 'utf8'); const value = parseEnvValue(content, key); if (value) { process.env[key] = value; return value; } } catch { continue; } } return undefined; }; export const getFirecrawlApiKey = (): string | undefined => loadEnvValue(FIRECRAWL_ENV_KEY); getFirecrawlApiKey(); const toFilename = (url: string): string => { try { const parsed = new URL(url); const segments = parsed.pathname.split('/').filter(Boolean); const filename = segments.pop() ?? 'asset'; const clean = filename.split('?')[0].split('#')[0]; return clean || 'asset'; } catch { return 'asset'; } }; const normalizeAssetUrl = (url: string): string => { try { const parsed = new URL(url); parsed.hash = ''; parsed.search = ''; return parsed.toString(); } catch { return url; } }; const isDataUrl = (url: string): boolean => url.trim().startsWith('data:'); const inferAssetType = (url: string, contentType?: string): Asset['type'] => { const lowerUrl = url.toLowerCase(); const type = contentType?.toLowerCase() ?? ''; if (type.includes('image') || lowerUrl.match(/\.(png|jpg|jpeg|webp|gif|ico)$/)) { return 'image'; } if (type.includes('svg') || lowerUrl.endsWith('.svg')) { return 'svg'; } if (type.includes('font') || lowerUrl.match(/\.(woff|woff2|ttf|otf|eot)$/)) { return 'font'; } if (type.includes('css') || lowerUrl.endsWith('.css')) { return 'css'; } return 'other'; }; const toAsset = (url: string, contentType: string | undefined, outputDir: string): Asset => { const filename = toFilename(url); const assetType = inferAssetType(url, contentType); const subdir = assetType === 'font' ? 'fonts' : 'assets'; return { url, localPath: path.join(outputDir, 'public', subdir, filename), contentType, type: assetType, downloaded: false, }; }; /** * Rewrite CSS url() references to use local paths. * Converts absolute URLs to relative paths pointing to /assets/ or /fonts/. */ const rewriteCssUrls = (css: string, baseUrl: string): string => { // Match url() with various quote styles and URL formats const urlRegex = /url\(\s*(['"]?)([^'")]+)\1\s*\)/g; return css.replace(urlRegex, (match, quote, url) => { const trimmedUrl = url.trim(); // Skip data URLs and already-relative paths if (trimmedUrl.startsWith('data:') || trimmedUrl.startsWith('/assets/') || trimmedUrl.startsWith('/fonts/')) { return match; } try { // Resolve relative URLs against the CSS file's base URL let absoluteUrl = trimmedUrl; if (trimmedUrl.startsWith('//')) { absoluteUrl = 'https:' + trimmedUrl; } else if (!trimmedUrl.startsWith('http')) { absoluteUrl = new URL(trimmedUrl, baseUrl).toString(); } // Extract filename and determine target directory const filename = toFilename(absoluteUrl); const extension = path.extname(filename).toLowerCase(); const isFont = ['.woff', '.woff2', '.ttf', '.otf', '.eot'].includes(extension); const localPath = isFont ? `/fonts/${filename}` : `/assets/${filename}`; return `url(${quote}${localPath}${quote})`; } catch { // If URL parsing fails, leave unchanged return match; } }); }; const downloadAsset = async (asset: Asset): Promise => { try { const response = await fetch(asset.url); if (!response.ok) { console.warn(`Failed to download ${asset.url}: ${response.status}`); return asset; } let buffer = Buffer.from(await response.arrayBuffer()); // Rewrite CSS url() references to local paths if (asset.type === 'css') { const cssContent = buffer.toString('utf-8'); const rewrittenCss = rewriteCssUrls(cssContent, asset.url); buffer = Buffer.from(rewrittenCss, 'utf-8'); console.log(`Rewrote CSS URLs in: ${path.basename(asset.localPath)}`); } await fs.mkdir(path.dirname(asset.localPath), {recursive: true}); await fs.writeFile(asset.localPath, buffer); return {...asset, downloaded: true}; } catch (error) { console.warn(`Failed to download ${asset.url}:`, error); return asset; } }; // ============================================================================= // Public API // ============================================================================= export type RenderOptions = { url: string; viewport?: Viewport; waitUntil?: 'load' | 'domcontentloaded' | 'networkidle'; extraWaitMs?: number; }; /** * Render a page with Playwright and capture HTML, screenshot, and network requests. */ export const renderPage = async ({ url, viewport = defaultViewport, waitUntil = 'networkidle', extraWaitMs = 2000, }: RenderOptions): Promise => { let browser; try { browser = await chromium.launch({headless: true}); const context = await browser.newContext({viewport}); const page = await context.newPage(); const requests: NetworkRequest[] = []; page.on('response', (response) => { const request = response.request(); requests.push({ url: response.url(), status: response.status(), method: request.method(), resourceType: request.resourceType(), contentType: response.headers()['content-type'], }); }); console.log(`Rendering: ${url}`); await page.goto(url, {waitUntil, timeout: 60000}); if (extraWaitMs) { await page.waitForTimeout(extraWaitMs); } const html = await page.content(); const screenshot = await page.screenshot({fullPage: true}); console.log(`Rendered: ${html.length} bytes HTML, ${requests.length} network requests`); return {html, screenshot, requests}; } catch (error) { throw new RenderError(`Failed to render ${url}`, error); } finally { if (browser) { await browser.close(); } } }; export type ScrapeOptions = { url: string; renderResult: RenderResult; firecrawlClient: FirecrawlClient; outputDir: string; downloadAssets?: boolean; }; /** * Scrape assets from the page using Firecrawl and network logs. */ export const scrapePage = async ({ url, renderResult, firecrawlClient, outputDir, downloadAssets: shouldDownload = true, }: ScrapeOptions): Promise => { let firecrawlHtml: string | undefined; let firecrawlAssets: FirecrawlAsset[] = []; try { console.log(`Scraping with Firecrawl: ${url}`); const response = await firecrawlClient.scrapeUrl(url, { formats: ['html', 'links'], includeAssets: true, }); if (!response.success) { throw new ScrapeError(`Firecrawl scrape failed for ${url}`); } firecrawlHtml = response.html; firecrawlAssets = response.assets ?? []; console.log(`Firecrawl returned: ${firecrawlHtml?.length ?? 0} bytes`); } catch (error) { if (error instanceof ScrapeError) { throw error; } throw new ScrapeError(`Firecrawl request failed for ${url}`, error); } const discoveredAssets = firecrawlAssets .filter((asset) => asset.url && !isDataUrl(asset.url)) .map((asset) => toAsset(asset.url, asset.contentType, outputDir)); // Collect assets from network requests const networkAssets = renderResult.requests .filter((request) => request.status >= 200 && request.status < 300) .filter((request) => { const type = inferAssetType(request.url, request.contentType); return type !== 'other'; }) .map((request) => toAsset(request.url, request.contentType, outputDir)); // Dedupe by URL const assetMap = new Map(); const allAssets = [...discoveredAssets, ...networkAssets]; for (const asset of allAssets) { const key = normalizeAssetUrl(asset.url); const existing = assetMap.get(key); if (!existing) { assetMap.set(key, asset); } else if (!existing.contentType && asset.contentType) { assetMap.set(key, {...existing, contentType: asset.contentType}); } } let assets = Array.from(assetMap.values()); console.log(`Found ${assets.length} assets`); // Download assets if (shouldDownload) { console.log('Downloading assets...'); const downloaded: Asset[] = []; for (const asset of assets) { if (asset.type !== 'other') { const result = await downloadAsset(asset); downloaded.push(result); } else { downloaded.push(asset); } } assets = downloaded; console.log(`Downloaded ${assets.filter(a => a.downloaded).length} assets`); } return { assets, html: renderResult.html, firecrawlHtml, }; }; /** * Save screenshot to file. */ export const saveScreenshot = async (screenshot: Buffer, outputPath: string): Promise => { await fs.mkdir(path.dirname(outputPath), {recursive: true}); await fs.writeFile(outputPath, screenshot); console.log(`Screenshot saved: ${outputPath}`); }; /** * Write a file to disk. */ export const writeFile = async (filePath: string, content: string): Promise => { await fs.mkdir(path.dirname(filePath), {recursive: true}); await fs.writeFile(filePath, content, 'utf-8'); }; /** * Create the Next.js project structure. */ export const createProjectStructure = async (outputDir: string): Promise => { const dirs = [ path.join(outputDir, 'src', 'app'), path.join(outputDir, 'src', 'components', 'sections'), path.join(outputDir, 'src', 'components', 'ui'), path.join(outputDir, 'src', 'lib'), path.join(outputDir, 'public', 'assets'), path.join(outputDir, 'public', 'fonts'), ]; for (const dir of dirs) { await fs.mkdir(dir, {recursive: true}); } console.log(`Created project structure in ${outputDir}`); }; // ============================================================================= // Project Scaffolding (Next.js + shadcn/ui) // ============================================================================= /** * Copy boilerplate files from templates directory. * This sets up a complete Next.js 15 + Tailwind 4 + shadcn/ui project. */ export const scaffoldProject = async (outputDir: string, templateDir: string): Promise => { const filesToCopy = [ 'package.json', 'next.config.ts', 'tsconfig.json', 'components.json', 'postcss.config.mjs', 'eslint.config.mjs', '.gitignore', 'src/lib/utils.ts', 'src/app/layout.tsx', 'src/app/globals.css', 'src/components/ui/button.tsx', ]; for (const file of filesToCopy) { const src = path.join(templateDir, file); const dest = path.join(outputDir, file); try { const content = await fs.readFile(src, 'utf-8'); await fs.mkdir(path.dirname(dest), {recursive: true}); await fs.writeFile(dest, content, 'utf-8'); console.log(`Copied: ${file}`); } catch (error) { console.warn(`Could not copy ${file}:`, error); } } console.log(`Scaffolded project in ${outputDir}`); }; /** * Install shadcn/ui components using the CLI. * Run this after scaffoldProject. */ export const installShadcnComponents = async ( projectDir: string, components: string[] = ['button', 'card', 'input', 'label', 'separator'] ): Promise => { const {exec} = await import('child_process'); const {promisify} = await import('util'); const execAsync = promisify(exec); try { // Install dependencies first console.log('Installing dependencies...'); await execAsync('npm install', {cwd: projectDir}); // Install shadcn components console.log(`Installing shadcn components: ${components.join(', ')}`); const componentList = components.join(' '); const {stdout} = await execAsync( `npx shadcn@latest add ${componentList} --yes`, {cwd: projectDir} ); console.log('shadcn components installed successfully'); return stdout; } catch (error) { console.warn('Could not install shadcn components:', error); return ''; } }; /** * Generate an AGENTS.md file for the cloned project. */ export const generateAgentsMd = (options: { sourceUrl: string; siteName?: string; sections?: string[]; }): string => { const {sourceUrl, siteName = 'Cloned Website', sections = []} = options; return `## Project Summary A pixel-perfect clone of ${siteName} (${sourceUrl}). ## Tech Stack - Framework: Next.js 15 (App Router) - Styling: Tailwind CSS 4, styled-jsx - Components: React, shadcn/ui, Lucide React (icons) ## Architecture - \`src/app/page.tsx\`: Main entry point assembling all sections. - \`src/components/sections/\`: Contains individual sections of the website. - \`src/components/ui/\`: shadcn/ui components. - \`src/app/globals.css\`: Global styles including custom fonts and Tailwind 4 configuration. ## Sections ${sections.map(s => `- ${s}`).join('\n')} ## Project Guidelines - Use "use client" for components using styled-jsx or client-side interactivity. - Maintain responsiveness across mobile and desktop. - All assets are local in \`public/assets/\`. ## Running the Project \`\`\`bash npm install npm run dev \`\`\` `; }; // ============================================================================= // Style Extraction // ============================================================================= export type ExtractStylesOptions = { url: string; selectors: string[]; viewport?: Viewport; properties?: string[]; }; const defaultStyleProperties = [ 'color', 'background-color', 'background', 'font-family', 'font-size', 'font-weight', 'line-height', 'letter-spacing', 'padding', 'padding-top', 'padding-right', 'padding-bottom', 'padding-left', 'margin', 'margin-top', 'margin-right', 'margin-bottom', 'margin-left', 'border', 'border-radius', 'box-shadow', 'text-shadow', 'width', 'height', 'max-width', 'max-height', 'min-width', 'min-height', 'display', 'position', 'top', 'right', 'bottom', 'left', 'z-index', 'opacity', 'transform', 'gap', 'flex-direction', 'justify-content', 'align-items', 'grid-template-columns', 'grid-template-rows', ]; /** * Extract computed styles for specific elements on a page. * Uses Playwright to get the exact rendered CSS values. */ export const extractComputedStyles = async ({ url, selectors, viewport = defaultViewport, properties = defaultStyleProperties, }: ExtractStylesOptions): Promise => { let browser; try { browser = await chromium.launch({headless: true}); const context = await browser.newContext({viewport}); const page = await context.newPage(); console.log(`Extracting styles from: ${url}`); await page.goto(url, {waitUntil: 'networkidle', timeout: 60000}); await page.waitForTimeout(2000); const results: ComputedStyles[] = []; for (const selector of selectors) { const styles = await page.evaluate( ({sel, props}) => { const element = document.querySelector(sel); if (!element) return null; const computed = window.getComputedStyle(element); const styleObj: Record = {}; for (const prop of props) { const value = computed.getPropertyValue(prop); if (value && value !== 'none' && value !== 'normal' && value !== 'auto') { styleObj[prop] = value; } } return styleObj; }, {sel: selector, props: properties} ); if (styles) { results.push({selector, styles}); } } console.log(`Extracted styles for ${results.length} elements`); return results; } finally { if (browser) { await browser.close(); } } }; // ============================================================================= // Font Extraction // ============================================================================= /** * Extract fonts from HTML and CSS content. * Finds Google Fonts imports, font-family declarations, and @font-face rules. */ export const extractFonts = (html: string, css?: string): ExtractedFonts => { const content = html + (css || ''); // Find Google Fonts imports const googleFontsRegex = /https:\/\/fonts\.googleapis\.com\/css2?\?[^"'\s)]+/g; const googleFontsImports = [...new Set(content.match(googleFontsRegex) || [])]; // Find font-family declarations const fontFamilyRegex = /font-family:\s*["']?([^;"'\n]+)["']?/gi; const fontFamilies: string[] = []; let match; while ((match = fontFamilyRegex.exec(content)) !== null) { const families = match[1].split(',').map(f => f.trim().replace(/["']/g, '')); fontFamilies.push(...families); } const uniqueFontFamilies = [...new Set(fontFamilies)].filter( f => !['inherit', 'initial', 'unset', 'sans-serif', 'serif', 'monospace', 'cursive', 'fantasy', 'system-ui'].includes(f.toLowerCase()) ); // Find @font-face declarations const fontFaceRegex = /@font-face\s*\{[^}]+\}/g; const fontFaceDeclarations = content.match(fontFaceRegex) || []; console.log(`Found: ${googleFontsImports.length} Google Fonts imports, ${uniqueFontFamilies.length} font families, ${fontFaceDeclarations.length} @font-face rules`); return { googleFontsImports, fontFamilies: uniqueFontFamilies, fontFaceDeclarations, }; }; /** * Generate Google Fonts import URL from font names. */ export const generateGoogleFontsUrl = (fonts: {name: string; weights?: number[]}[]): string => { const families = fonts.map(f => { const name = f.name.replace(/\s+/g, '+'); const weights = f.weights?.join(';') || '400;500;600;700'; return `family=${name}:wght@${weights}`; }); return `https://fonts.googleapis.com/css2?${families.join('&')}&display=swap`; }; // ============================================================================= // Color Extraction // ============================================================================= const hexRegex = /#(?:[0-9a-fA-F]{3,4}){1,2}\b/g; const rgbRegex = /rgba?\(\s*\d+\s*,\s*\d+\s*,\s*\d+\s*(?:,\s*[\d.]+\s*)?\)/g; const hslRegex = /hsla?\(\s*\d+\s*,\s*[\d.]+%?\s*,\s*[\d.]+%?\s*(?:,\s*[\d.]+\s*)?\)/g; const cssVarRegex = /--[\w-]+:\s*([^;]+)/g; /** * Extract all colors from HTML/CSS content. */ export const extractColors = (content: string): ExtractedColors => { const hexColors = [...new Set(content.match(hexRegex) || [])]; const rgbColors = [...new Set(content.match(rgbRegex) || [])]; const hslColors = [...new Set(content.match(hslRegex) || [])]; const cssVariables: Record = {}; let match; while ((match = cssVarRegex.exec(content)) !== null) { const fullMatch = match[0]; const varName = fullMatch.split(':')[0]; const varValue = match[1].trim(); if (varValue.match(hexRegex) || varValue.match(rgbRegex) || varValue.match(hslRegex)) { cssVariables[varName] = varValue; } } console.log(`Found: ${hexColors.length} hex, ${rgbColors.length} rgb, ${hslColors.length} hsl colors, ${Object.keys(cssVariables).length} CSS variables`); return { hex: hexColors, rgb: rgbColors, hsl: hslColors, cssVariables, }; }; /** * Convert RGB to hex color. */ export const rgbToHex = (r: number, g: number, b: number): string => { return '#' + [r, g, b].map(x => x.toString(16).padStart(2, '0')).join(''); }; /** * Parse RGB string to values. */ export const parseRgb = (rgb: string): {r: number; g: number; b: number; a?: number} | null => { const match = rgb.match(/rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*(?:,\s*([\d.]+)\s*)?\)/); if (!match) return null; return { r: parseInt(match[1]), g: parseInt(match[2]), b: parseInt(match[3]), a: match[4] ? parseFloat(match[4]) : undefined, }; }; // ============================================================================= // Screenshot Comparison // ============================================================================= /** * Compare two screenshots and return similarity score. * Requires pixelmatch package for accurate comparison. */ export const compareScreenshots = async ( original: Buffer, clone: Buffer, outputDiffPath?: string ): Promise => { try { // Dynamic import to avoid requiring pixelmatch if not used const {PNG} = await import('pngjs'); const pixelmatch = (await import('pixelmatch')).default; const img1 = PNG.sync.read(original); const img2 = PNG.sync.read(clone); // Ensure same dimensions by using the smaller of the two const width = Math.min(img1.width, img2.width); const height = Math.min(img1.height, img2.height); const diff = new PNG({width, height}); const diffPixels = pixelmatch( img1.data, img2.data, diff.data, width, height, {threshold: 0.1} ); const totalPixels = width * height; const similarity = 1 - (diffPixels / totalPixels); let diffImagePath: string | undefined; if (outputDiffPath) { await fs.mkdir(path.dirname(outputDiffPath), {recursive: true}); await fs.writeFile(outputDiffPath, PNG.sync.write(diff)); diffImagePath = outputDiffPath; console.log(`Diff image saved: ${outputDiffPath}`); } console.log(`Screenshot comparison: ${(similarity * 100).toFixed(2)}% similar (${diffPixels} different pixels)`); return { similarity, diffPixels, totalPixels, diffImagePath, }; } catch (error) { console.warn('Screenshot comparison requires pixelmatch and pngjs packages:', error); return { similarity: -1, diffPixels: -1, totalPixels: -1, }; } }; // ============================================================================= // Enhanced Render with Style Extraction // ============================================================================= export type EnhancedRenderResult = RenderResult & { fonts: ExtractedFonts; colors: ExtractedColors; stylesheets: string[]; }; /** * Render a page and automatically extract fonts and colors. */ export const renderPageWithExtraction = async (options: RenderOptions): Promise => { let browser; try { browser = await chromium.launch({headless: true}); const context = await browser.newContext({viewport: options.viewport ?? defaultViewport}); const page = await context.newPage(); const requests: NetworkRequest[] = []; const stylesheets: string[] = []; page.on('response', async (response) => { const request = response.request(); const contentType = response.headers()['content-type'] || ''; requests.push({ url: response.url(), status: response.status(), method: request.method(), resourceType: request.resourceType(), contentType, }); // Capture CSS content if (contentType.includes('text/css') || request.resourceType() === 'stylesheet') { try { const text = await response.text(); stylesheets.push(text); } catch { // Response may not be available } } }); console.log(`Rendering with extraction: ${options.url}`); await page.goto(options.url, {waitUntil: options.waitUntil ?? 'networkidle', timeout: 60000}); if (options.extraWaitMs) { await page.waitForTimeout(options.extraWaitMs); } const html = await page.content(); const screenshot = await page.screenshot({fullPage: true}); // Extract inline styles const inlineStyles = await page.evaluate(() => { const styles: string[] = []; document.querySelectorAll('style').forEach(el => { styles.push(el.textContent || ''); }); return styles; }); const allCss = [...stylesheets, ...inlineStyles].join('\n'); const fonts = extractFonts(html, allCss); const colors = extractColors(html + allCss); console.log(`Rendered: ${html.length} bytes HTML, ${requests.length} network requests`); return { html, screenshot, requests, fonts, colors, stylesheets, }; } catch (error) { throw new RenderError(`Failed to render ${options.url}`, error); } finally { if (browser) { await browser.close(); } } }; // ============================================================================= // CSS Generation Helpers // ============================================================================= /** * Generate CSS variable declarations from extracted colors. */ export const generateColorVariables = (colors: ExtractedColors, namePrefix = 'color'): string => { const lines: string[] = []; // Add hex colors with generated names colors.hex.forEach((hex, i) => { lines.push(` --${namePrefix}-${i + 1}: ${hex};`); }); // Add CSS variables that were already named Object.entries(colors.cssVariables).forEach(([name, value]) => { lines.push(` ${name}: ${value};`); }); return lines.join('\n'); }; /** * Generate Tailwind theme extension from extracted colors. */ export const generateTailwindColors = (colors: ExtractedColors): Record => { const tailwindColors: Record = {}; colors.hex.forEach((hex, i) => { tailwindColors[`custom-${i + 1}`] = hex; }); return tailwindColors; }; /** * Generate @theme block for Tailwind v4 globals.css. */ export const generateThemeBlock = (options: { fonts: ExtractedFonts; colors: ExtractedColors; }): string => { const {fonts, colors} = options; const lines: string[] = ['@theme {']; // Colors lines.push(' /* Extracted Colors */'); colors.hex.forEach((hex, i) => { lines.push(` --color-extracted-${i + 1}: ${hex};`); }); // Fonts if (fonts.fontFamilies.length > 0) { lines.push(''); lines.push(' /* Extracted Fonts */'); fonts.fontFamilies.forEach((font, i) => { const varName = font.toLowerCase().replace(/\s+/g, '-'); lines.push(` --font-${varName}: "${font}", sans-serif;`); }); } lines.push('}'); return lines.join('\n'); };