import { createHash } from 'node:crypto'; import { mkdir, readFile, readdir, writeFile } from 'node:fs/promises'; import { dirname, resolve } from 'node:path'; type Size = { width: number; height: number }; type IconAsset = { file: string; nodeId: string; role: string; sourcePath: string; sourceSize: Size; size: Size; palette: string[]; sha256?: string; usedBy?: string[]; }; type RetainedIcon = { file: string; size: Size; sha256: string; reason: string; usedBy?: string[] }; type IconManifest = { fileKey: string; assets: IconAsset[]; retained: RetainedIcon[] }; const manifestPath = resolve('scripts/figma-icon-manifest.json'); const manifest = JSON.parse(await readFile(manifestPath, 'utf8')) as IconManifest; const verifyOnly = process.argv.includes('--check'); const hash = (value: string | Uint8Array) => createHash('sha256').update(value).digest('hex'); async function refreshReferences(): Promise { async function files(directory: string): Promise { const entries = await readdir(directory, {withFileTypes: true}); return (await Promise.all(entries.map(entry => entry.isDirectory() ? files(directory + '/' + entry.name) : Promise.resolve([directory + '/' + entry.name])))).flat(); } const paths = [...await files('src')].filter(path => /\.(?:tsx?|css)$/.test(path)); const sources = await Promise.all(paths.map(async path => ({path, text:await readFile(path,'utf8')}))); for (const asset of [...manifest.assets,...manifest.retained]) { const url = asset.file.replace(/^public/, ''); const name = url.replace(/^\/static\/images\//, '').replace(/\.svg$/, ''); const literal = new RegExp('["\x27`]' + name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + '["\x27`]'); asset.usedBy = sources.filter(source => source.text.includes(url) || literal.test(source.text)).map(source => source.path).sort(); } await writeFile(manifestPath, JSON.stringify(manifest,null,2)+'\n'); console.log('Refreshed Figma icon references from native TS/TSX and CSS.'); } function svgRoot(svg: string): string { const root = svg.match(/]*>/i)?.[0]; if (!root || !/<\/(?:[\w-]+:)?svg\s*>/i.test(svg)) throw new Error('Invalid or empty SVG document'); return root; } function attribute(root: string, name: string): string { return root.match(new RegExp(`\\b${name}\\s*=\\s*["']([^"']+)["']`, 'i'))?.[1] ?? ''; } function validateSVG(asset: IconAsset | RetainedIcon, bytes: Buffer, checkHash: boolean): void { const svg = bytes.toString('utf8'); const root = svgRoot(svg); if (!/<(?:path|rect|circle|ellipse|line|polyline|polygon|text|use)\b/i.test(svg)) throw new Error('SVG has no visible geometry'); for (const [name, value] of Object.entries(asset.size)) { if (Number(attribute(root, name).replace(/px$/, '')) !== value) throw new Error(`Incorrect root ${name}`); if ('sourceSize' in asset && Math.abs(asset.sourceSize[name as keyof Size] - value) > 0.01) throw new Error(`Root ${name} differs from the Figma node`); } const viewBox = attribute(root, 'viewBox').trim().split(/[\s,]+/).map(Number); if (viewBox.length !== 4 || !viewBox.every(Number.isFinite) || viewBox[2] <= 0 || viewBox[3] <= 0) throw new Error('Invalid SVG viewBox'); if (Math.abs(viewBox[2] / viewBox[3] - asset.size.width / asset.size.height) > 0.00001) throw new Error('SVG aspect ratio would distort the icon'); if (checkHash && asset.sha256 && hash(bytes) !== asset.sha256) throw new Error('SVG differs from its recorded Figma export or retained original'); if ('palette' in asset) { const colors = new Set((svg.match(/#[\da-f]{6}\b|#[\da-f]{3}\b/gi) ?? []).map(value => { const lower = value.toLowerCase(); return lower.length === 4 ? '#' + [...lower.slice(1)].map(char => char + char).join('') : lower; })); if (/\b(?:fill|stroke)=["']white["']/i.test(svg)) colors.add('#ffffff'); for (const color of colors) if (!asset.palette.includes(color)) throw new Error(`Paint ${color} does not match the Figma node`); } } async function verifyLocal(): Promise { for (const asset of [...manifest.assets, ...manifest.retained]) { try { validateSVG(asset, await readFile(resolve(asset.file)), true); } catch (error) { throw new Error(`${asset.file}: ${(error as Error).message}`); } } console.log(`Verified ${manifest.assets.length} Figma SVG exports and ${manifest.retained.length} retained icons: geometry, palette, dimensions and hashes.`); } async function request(url: string, headers?: Record): Promise { for (let attempt = 0; attempt < 3; attempt++) { let response: Response; try { response = await fetch(url, { headers, signal: AbortSignal.timeout(60_000) }); } catch { if (attempt < 2) continue; throw new Error('Figma asset request failed'); } if (response.ok) return response; if ((response.status === 429 || response.status >= 500) && attempt < 2) { const delay = Math.min(30, Math.max(1, Number(response.headers.get('retry-after')) || 3)); await new Promise(resolveDelay => setTimeout(resolveDelay, delay * 1000)); continue; } throw new Error(`Figma asset request returned HTTP ${response.status}`); } throw new Error('Figma asset request failed'); } async function syncIcons(): Promise { const token = process.env.FIGMA_TOKEN; if (!token) throw new Error('FIGMA_TOKEN is required to synchronize icons; --check works offline'); const ids = [...new Set(manifest.assets.map(asset => asset.nodeId))]; const exported = new Map(); for (let offset = 0; offset < ids.length; offset += 30) { const batch = ids.slice(offset, offset + 30); const query = new URLSearchParams({ ids: batch.join(','), format: 'svg', svg_outline_text: 'true', use_absolute_bounds: 'true', }); const response = await request(`https://api.figma.com/v1/images/${manifest.fileKey}?${query}`, { 'X-Figma-Token': token }); const result = await response.json() as { images?: Record }; // Signed download URLs and credentials are deliberately never logged or saved. await Promise.all(batch.map(async id => { const url = result.images?.[id]; if (!url) throw new Error(`Figma returned no SVG for node ${id}`); exported.set(id, Buffer.from(await (await request(url)).arrayBuffer())); })); console.log(`Downloaded Figma SVG batch ${Math.floor(offset / 30) + 1}: ${batch.length} nodes.`); } // Save complete REST response bytes: no root, whitespace, geometry or paint edits. // The exported intrinsic size is recorded here; display size belongs to CSS/HTML. const pending = manifest.assets.map(previous => { const bytes = exported.get(previous.nodeId)!; const root = svgRoot(bytes.toString('utf8')); const asset = { ...previous, size: { width: Number(attribute(root, 'width').replace(/px$/, '')), height: Number(attribute(root, 'height').replace(/px$/, '')) } }; try { validateSVG(asset, bytes, false); } catch (error) { throw new Error(`${asset.file} (node ${asset.nodeId}): ${(error as Error).message}`); } return { asset, bytes }; }); for (const { asset, bytes } of pending) { await mkdir(dirname(resolve(asset.file)), { recursive: true }); await writeFile(resolve(asset.file), bytes); asset.sha256 = hash(bytes); } manifest.assets = pending.map(({ asset }) => asset); await writeFile(manifestPath, JSON.stringify(manifest, null, 2) + '\n'); await refreshReferences(); await verifyLocal(); } if (process.argv.includes('--refresh-references')) await refreshReferences(); else if (verifyOnly) await verifyLocal(); else await syncIcons();