import { existsSync, readFileSync } from 'fs' import fs from 'fs/promises' import path from 'path' import type { Config } from 'svgo' import type { ValidationEntry } from '../../scripts/shared/validate-size' import type { SizeGuardrailsConfig } from '../../scripts/steps/download-assets' import imageSize from 'image-size' import { loadConfig, optimize } from 'svgo' import { parseSvgWidth, reportViolations, } from '../../scripts/shared/validate-size' import { generateAssetModule } from './templates/assetTemplate' import { generateBaseTypes } from './templates/baseTypes' import { generateIndexFile } from './templates/indexTemplate' // Configuration const illustrationsSvgDir = path.resolve('./assets/') const srcRoot = path.resolve('./src') const outputDir = path.join(srcRoot, 'generated') const assetsOutputDir = path.join(outputDir, 'assets') // Optimized assets go here const configPath = path.resolve('./.figma-sync.json') const verboseFromArg = process.argv.some( arg => arg === '--verbose' || arg === '-v', ) const verboseFromNx = ['1', 'true', 'yes', 'on'].includes( (process.env.NX_VERBOSE_LOGGING ?? '').toLowerCase(), ) const isVerbose = verboseFromArg || verboseFromNx function verboseLog(message: string) { if (isVerbose) { console.log(message) } } // Utility functions function toPascalCase(str: string) { return str .replace(/([a-z])([A-Z])/g, '$1 $2') .replace(/[^a-z0-9]+/gi, ' ') .split(' ') .map(word => word.charAt(0).toUpperCase() + word.slice(1)) .join('') } function getComponentName(filePath: string) { const filename = path.basename(filePath) const typeMatch = /Type=([^,.]+)/.exec(filename) const nameMatch = /Name=([^,.]+)/.exec(filename) const bgMatch = /With background=yes/.exec(filename) const type = typeMatch ? typeMatch[1] : null const name = nameMatch ? nameMatch[1] : 'Illustration' let base = '' if (type === 'empty-state') { base += 'EmptyState' } base += toPascalCase(name ?? '') if (bgMatch) { base += 'WithBackground' } // Add Animation suffix for animated illustrations const relativePath = path.relative(illustrationsSvgDir, filePath) const folder = relativePath.split(path.sep)[0] if (folder === 'animated-illustrations') { base += 'Animation' } return base } async function getAllAssetFiles(dir: string) { let results: string[] = [] const list = await fs.readdir(dir, { withFileTypes: true }) for (const entry of list) { const filePath = path.join(dir, entry.name) if (entry.isDirectory()) { results = results.concat(await getAllAssetFiles(filePath)) } else if (/\.(?:svg|gif|webp|png|jpg|jpeg)$/i.exec(filePath)) { results.push(filePath) } } return results } function getAssetTypeFromPath(filePath: string) { const relativePath = path.relative(illustrationsSvgDir, filePath) const folder = relativePath.split(path.sep)[0] const ext = path.extname(filePath).toLowerCase() if (folder === 'animated-illustrations') { if (ext !== '.webp') { throw new Error( `Animated illustrations must be WEBP format. Found: ${ext} in ${filePath}`, ) } return { type: 'image' as const, animated: true, category: 'animation' as const, } } if (folder === 'illustrated-icons') { return { type: 'svg' as const, animated: false, category: 'icon' as const } } if (folder === 'illustrated-pictures') { return { type: 'svg' as const, animated: false, category: 'picture' as const, } } // Fallback to extension-based detection for backward compatibility const animatedFormats = ['.gif', '.webp'] if (ext === '.svg') { return { type: 'svg' as const, animated: false } } return { type: 'image' as const, animated: animatedFormats.includes(ext), } } function optimizeSvg(svgContent: string, filePath: string, svgoConfig: Config) { try { const result = optimize(svgContent, { ...svgoConfig, path: filePath, }) return result.data } catch (error) { console.warn( `āš ļø SVG optimization failed for ${path.basename( filePath, )}, using original:`, (error as Error).message, ) return svgContent } } async function getImageDimensions( imagePath: string, ): Promise<{ width: number; height: number }> { try { const buffer = await fs.readFile(imagePath) const dimensions = imageSize(buffer) if (!dimensions.width || !dimensions.height) { throw new Error(`Could not determine dimensions for ${imagePath}`) } return { width: dimensions.width, height: dimensions.height } } catch (error) { throw new Error( `Failed to get dimensions for ${imagePath}: ${(error as Error).message}`, ) } } async function processAsset(assetPath: string) { const assetFile = path.basename(assetPath) const componentName = getComponentName(assetPath) const assetType = getAssetTypeFromPath(assetPath) const svgoConfig = (await loadConfig()) ?? {} // Generate output path in the generated/assets folder const ext = path.extname(assetPath) const outputFileName = `${componentName}${ext}` const optimizedAssetPath = path.join(assetsOutputDir, outputFileName) let sizeBefore = 0 let sizeAfter = 0 let dimensions: { width: number; height: number } | undefined if (assetType.type === 'svg') { // Optimize SVG and save to generated/assets folder const originalContent = await fs.readFile(assetPath, 'utf8') sizeBefore = originalContent.length const optimizedContent = optimizeSvg(originalContent, assetPath, svgoConfig) sizeAfter = optimizedContent.length // Write optimized content to generated/assets folder await fs.writeFile(optimizedAssetPath, optimizedContent, 'utf8') const reduction = (((sizeBefore - sizeAfter) / sizeBefore) * 100).toFixed(1) verboseLog( `šŸ“‰ ${assetFile}: ${sizeBefore} → ${sizeAfter} bytes (-${reduction}%)`, ) } else { // For images, copy to generated/assets folder (assume they're already optimized) await fs.copyFile(assetPath, optimizedAssetPath) const stats = await fs.stat(assetPath) sizeBefore = sizeAfter = stats.size // Extract dimensions for animated illustrations if (assetType.category === 'animation') { dimensions = await getImageDimensions(assetPath) verboseLog( `šŸŽ¬ ${assetFile}: ${(sizeBefore / 1024).toFixed(2)}KB (animation ${ dimensions.width }x${dimensions.height})`, ) } else { verboseLog( `šŸ“¦ ${assetFile}: ${(sizeBefore / 1024).toFixed(2)}KB (${ assetType.animated ? 'animated ' : '' }${assetType.type})`, ) } } return { name: componentName, type: assetType.type, animated: assetType.animated, category: assetType.category, dimensions, sizeBefore, sizeAfter, originalPath: assetPath, optimizedPath: optimizedAssetPath, originalFileName: assetFile, } } function loadSizeGuardrails(): SizeGuardrailsConfig | undefined { if (!existsSync(configPath)) { console.log('ā„¹ļø No .figma-sync.json found, skipping size guardrails') return undefined } const config = JSON.parse(readFileSync(configPath, 'utf-8')) as { sizeGuardrails?: SizeGuardrailsConfig } if (!config.sizeGuardrails) { console.log('ā„¹ļø No sizeGuardrails config, skipping size validation') return undefined } return config.sizeGuardrails } async function main() { console.log('šŸŽØ Generating and optimizing illustration assets...') if (isVerbose) { console.log('šŸ”Ž Verbose mode enabled (full asset logs)') } // Ensure output directories exist await fs.mkdir(outputDir, { recursive: true }) await fs.mkdir(assetsOutputDir, { recursive: true }) // Generate base types file const baseTypesContent = generateBaseTypes() await fs.writeFile(path.join(outputDir, 'types.ts'), baseTypesContent, 'utf8') verboseLog('āœ… Generated base types') // Load size guardrails config const sizeGuardrails = loadSizeGuardrails() const violations: ValidationEntry[] = [] const warnings: ValidationEntry[] = [] // Discover all asset files const assetFiles = await getAllAssetFiles(illustrationsSvgDir) console.log(`šŸ” Found ${assetFiles.length} assets to process`) if (assetFiles.length === 0) { console.warn('āš ļø No assets found! Check your assets directory.') return } // Process all assets const components = [] for (const assetPath of assetFiles) { try { const component = await processAsset(assetPath) // Validate optimized SVG against size guardrails if (sizeGuardrails && component.type === 'svg') { const optimizedContent = await fs.readFile( component.optimizedPath, 'utf8', ) const optimizedSize = optimizedContent.length const width = parseSvgWidth(optimizedContent) const exceedsFileSize = optimizedSize > sizeGuardrails.maxFileSize const exceedsWidth = width !== undefined && width > sizeGuardrails.maxWidth const isAllowlisted = sizeGuardrails.allowlist.includes( component.originalFileName, ) if (exceedsFileSize || exceedsWidth) { const details: string[] = [] if (exceedsFileSize) { details.push( `${(optimizedSize / 1024).toFixed(1)}KB after SVGO (limit: ${( sizeGuardrails.maxFileSize / 1024 ).toFixed(0)}KB)`, ) } if (exceedsWidth) { details.push( `${width}px width (limit: ${sizeGuardrails.maxWidth}px)`, ) } const detailStr = details.join('; ') if (isAllowlisted) { const msg = `${component.originalFileName}: ${detailStr}. Consider simplifying this illustration in Figma.` warnings.push({ filename: component.originalFileName, message: msg, }) console.warn(` āš ļø [Allowlisted] ${msg}`) } else { const msg = `${component.originalFileName}: ${detailStr}. Automatic optimization could not reduce below limit — simplify in Figma.` violations.push({ filename: component.originalFileName, message: msg, }) console.error(` āŒ ${msg}`) } } } // Generate individual asset module const moduleContent = generateAssetModule( component.name, component.optimizedPath, { type: component.type, animated: component.animated, category: component.category, dimensions: component.dimensions, }, component.originalFileName, ) const outPath = path.join(outputDir, `${component.name}.ts`) await fs.writeFile(outPath, moduleContent, 'utf8') components.push(component) verboseLog( `āœ… ${component.originalFileName} → ${component.name}.ts + optimized asset`, ) if (!isVerbose && components.length % 25 === 0) { console.log( `ā³ Processed ${components.length}/${assetFiles.length} assets...`, ) } } catch (error) { console.error( `āŒ Failed to process ${assetPath}:`, (error as Error).message, ) if (isVerbose) { console.error(error) } } } // Generate main index file const indexContent = generateIndexFile(components) await fs.writeFile( path.join(outputDir, 'illustrationAssets.ts'), indexContent, 'utf8', ) // Summary const totalSizeBefore = components.reduce((sum, c) => sum + c.sizeBefore, 0) const totalSizeAfter = components.reduce((sum, c) => sum + c.sizeAfter, 0) const totalReduction = totalSizeBefore > 0 ? (((totalSizeBefore - totalSizeAfter) / totalSizeBefore) * 100).toFixed( 1, ) : '0' console.log(`\nšŸŽ‰ Generated ${components.length} illustration assets!`) console.log( `šŸ“Š Total optimization: ${(totalSizeBefore / 1024).toFixed(1)}KB → ${( totalSizeAfter / 1024 ).toFixed(1)}KB (-${totalReduction}%)`, ) console.log( `šŸ“ Optimized assets saved to: ${path.relative( process.cwd(), assetsOutputDir, )}`, ) console.log(` - SVGs: ${components.filter(c => c.type === 'svg').length}`) console.log( ` - Images: ${ components.filter(c => c.type === 'image' && c.category !== 'animation') .length }`, ) console.log( ` - Animations: ${ components.filter(c => c.category === 'animation').length }`, ) console.log( ` - Total Animated: ${components.filter(c => c.animated).length}`, ) // Report size guardrail results if (sizeGuardrails) { reportViolations(violations, warnings, sizeGuardrails.strict) } } main().catch((err: unknown) => { console.error('āŒ Error generating illustrations:', err) process.exit(1) })