|
| 1 | +import type { DesignContextNode, SerializedPaint } from '@figma-mcp-relay/shared'; |
| 2 | + |
| 3 | +import type { RepoSvg, SvgColorContract } from '../icons/repo-icons.js'; |
| 4 | +import type { ProjectProfile } from '../profile/profile.js'; |
| 5 | +import { diceSimilarity, type MappingStatus } from './component-map.js'; |
| 6 | + |
| 7 | +// The icon join: a Figma icon node → an existing project `.svg` file, so codegen reuses the designer's |
| 8 | +// curated asset instead of re-exporting a duplicate. Like the component/token joins it's name-based and |
| 9 | +// pure (no Figma, no fs). It carries two extra dimensions the other joins don't need: |
| 10 | +// - a *color contract* (read from the matched file) deciding whether/how the icon may be recolored, and |
| 11 | +// - whether recoloring is even possible given how the project imports svg (currentColor dies through |
| 12 | +// an <img> in url mode), so a wrong recolor instruction is caught at grounding time. |
| 13 | +// Per-icon it only ever claims a *verified* file match; the library route (lucide / iconify) is surfaced |
| 14 | +// at the result level by the tool, not fabricated here. |
| 15 | + |
| 16 | +const ICON_MARKER = /(^|[\s/_-])ic(on)?s?([\s/_-]|$)/i; |
| 17 | +// Figma's default names for vector art that isn't a named, reusable icon — excluded so a decorative |
| 18 | +// stroke ("Vector 3") doesn't read as a missing icon. A real icon is named (search, arrow-right). |
| 19 | +const DEFAULT_VECTOR_NAME = |
| 20 | + /^(vector|union|subtract|intersect|exclude|rectangle|ellipse|line|polygon|star|group|frame|shape|path|oval|boolean)\s*\d*$/i; |
| 21 | +const VECTOR_TYPES = new Set(['VECTOR', 'BOOLEAN_OPERATION']); |
| 22 | + |
| 23 | +const carriesMarker = (node: DesignContextNode): boolean => |
| 24 | + ICON_MARKER.test(node.name) || |
| 25 | + (node.mainComponent !== undefined && |
| 26 | + (ICON_MARKER.test(node.mainComponent.name) || |
| 27 | + (node.mainComponent.componentSetName !== undefined && |
| 28 | + ICON_MARKER.test(node.mainComponent.componentSetName)))); |
| 29 | + |
| 30 | +/** The label to match on: the set/main name for an instance, else the node name. */ |
| 31 | +const sourceName = (node: DesignContextNode): string => |
| 32 | + node.mainComponent?.componentSetName ?? node.mainComponent?.name ?? node.name; |
| 33 | + |
| 34 | +/** |
| 35 | + * Strip the icon decoration off a name → the bare icon label ("Icons/ic_arrow-right" → |
| 36 | + * "arrow-right"). |
| 37 | + */ |
| 38 | +export const iconLabel = (raw: string): string => { |
| 39 | + const segs = raw |
| 40 | + .split('/') |
| 41 | + .map(s => s.trim()) |
| 42 | + .filter(Boolean); |
| 43 | + while (segs.length > 1 && /^ic(on)?s?$/i.test(segs[0] as string)) segs.shift(); |
| 44 | + const last = segs[segs.length - 1] ?? raw; |
| 45 | + return last.replace(/^ic(on)?s?[-_]/i, '').trim(); |
| 46 | +}; |
| 47 | + |
| 48 | +const norm = (s: string): string => s.toLowerCase().replace(/[^a-z0-9]/g, ''); |
| 49 | + |
| 50 | +const hex2 = (n: number): string => |
| 51 | + Math.round(Math.max(0, Math.min(1, n)) * 255) |
| 52 | + .toString(16) |
| 53 | + .padStart(2, '0'); |
| 54 | + |
| 55 | +const toHex = (c: { r: number; g: number; b: number }): string => |
| 56 | + `#${hex2(c.r)}${hex2(c.g)}${hex2(c.b)}`.toUpperCase(); |
| 57 | + |
| 58 | +/** The icon's intended color at the usage site: a solid fill hex and/or a bound variable. */ |
| 59 | +const fillOf = (node: DesignContextNode): IconMapping['fill'] => { |
| 60 | + const out: { hex?: string; variable?: boolean } = {}; |
| 61 | + const fills = node.fills; |
| 62 | + if (Array.isArray(fills)) { |
| 63 | + const solid = (fills as SerializedPaint[]).find(p => p.type === 'SOLID' && p.visible !== false); |
| 64 | + if (solid?.type === 'SOLID') out.hex = toHex(solid.color); |
| 65 | + } |
| 66 | + if (node.boundVariables?.fills !== undefined && node.boundVariables.fills.length > 0) |
| 67 | + out.variable = true; |
| 68 | + return out.hex === undefined && out.variable === undefined ? undefined : out; |
| 69 | +}; |
| 70 | + |
| 71 | +export interface FigmaIconUsage { |
| 72 | + /** Bare icon label used for the join + display (decoration stripped). */ |
| 73 | + name: string; |
| 74 | + /** The original Figma node name, kept for the report. */ |
| 75 | + figmaName: string; |
| 76 | + nodeIds: string[]; |
| 77 | + fill?: { hex?: string; variable?: boolean }; |
| 78 | +} |
| 79 | + |
| 80 | +/** |
| 81 | + * Walk the grounded trees and collect icon usages, grouped by label so an icon used N times is one |
| 82 | + * row with N node ids. An icon "root" is recorded without recursing into it, so the vectors inside |
| 83 | + * a named icon aren't double-counted. Detection (name-marker / vector-leaf with a meaningful name) |
| 84 | + * is deliberately a best guess — a false positive just yields an `unmapped` row the agent can |
| 85 | + * ignore, while the alternative (missing reuse) is the costlier error. |
| 86 | + */ |
| 87 | +export const collectFigmaIcons = (roots: readonly DesignContextNode[]): FigmaIconUsage[] => { |
| 88 | + const byKey = new Map<string, FigmaIconUsage>(); |
| 89 | + |
| 90 | + const record = (node: DesignContextNode): void => { |
| 91 | + const figmaName = sourceName(node); |
| 92 | + const label = iconLabel(figmaName); |
| 93 | + if (label === '') return; |
| 94 | + const key = norm(label); |
| 95 | + const usage: FigmaIconUsage = byKey.get(key) ?? { name: label, figmaName, nodeIds: [] }; |
| 96 | + usage.nodeIds.push(node.id); |
| 97 | + const fill = fillOf(node); |
| 98 | + if (usage.fill === undefined && fill !== undefined) usage.fill = fill; |
| 99 | + byKey.set(key, usage); |
| 100 | + }; |
| 101 | + |
| 102 | + const visit = (node: DesignContextNode): void => { |
| 103 | + const marker = carriesMarker(node); |
| 104 | + const isVectorLeaf = VECTOR_TYPES.has(node.type); |
| 105 | + const isInstanceLike = node.type === 'INSTANCE' || node.type === 'COMPONENT'; |
| 106 | + |
| 107 | + // An icon root we record and stop descending into. |
| 108 | + if ( |
| 109 | + (isInstanceLike && marker) || |
| 110 | + marker || |
| 111 | + (isVectorLeaf && !DEFAULT_VECTOR_NAME.test(node.name.trim())) |
| 112 | + ) { |
| 113 | + record(node); |
| 114 | + return; |
| 115 | + } |
| 116 | + for (const child of node.children ?? []) visit(child); |
| 117 | + }; |
| 118 | + |
| 119 | + for (const root of roots) visit(root); |
| 120 | + return [...byKey.values()]; |
| 121 | +}; |
| 122 | + |
| 123 | +export interface IconMapping { |
| 124 | + figmaName: string; |
| 125 | + name: string; |
| 126 | + nodeIds: string[]; |
| 127 | + fill?: { hex?: string; variable?: boolean }; |
| 128 | + candidate?: { |
| 129 | + /** Repo-relative path of the matched `.svg`. */ |
| 130 | + filePath: string; |
| 131 | + colorContract: SvgColorContract; |
| 132 | + /** How to color it in this project, grounded off the contract + svg mode + styling system. */ |
| 133 | + recolor: string; |
| 134 | + confidence: number; |
| 135 | + }; |
| 136 | + status: MappingStatus; |
| 137 | +} |
| 138 | + |
| 139 | +// Deliberately no fabricated `import` string. The loader form (?react / ?component / { ReactComponent } |
| 140 | +// / url) is already returned at the result level on profile.svg.importHint, and the actual specifier — |
| 141 | +// an alias like @/assets/… or a path relative to the *consuming* file — depends on the project's alias |
| 142 | +// config (tsconfig paths / vite resolve.alias, a long tail) and the not-yet-written caller's location, |
| 143 | +// which the join can't know. Precomputing it would mean guessing, and a confidently-wrong import is |
| 144 | +// worse than none. So, like component_map (which only returns filePath), we ground the file + loader |
| 145 | +// form + color contract and let codegen compose the import, mirroring the project's existing imports. |
| 146 | + |
| 147 | +/** How to recolor, grounded off the file's contract and whether this project's svg mode allows it. */ |
| 148 | +const recolorGuidance = ( |
| 149 | + contract: SvgColorContract, |
| 150 | + svg: ProjectProfile['svg'], |
| 151 | + tailwind: boolean, |
| 152 | +): string => { |
| 153 | + const colorClass = tailwind ? 'text-{token}' : 'the CSS `color` property (or var(--token))'; |
| 154 | + switch (contract) { |
| 155 | + case 'currentColor': |
| 156 | + return svg.mode === 'component' |
| 157 | + ? `recolorable — set ${colorClass} at the usage site (fill is currentColor); it also inherits the parent's text color` |
| 158 | + : `currentColor can't apply through an <img> (url mode) — inline the svg or add an svg loader to recolor, else it renders with no color`; |
| 159 | + case 'fixed': |
| 160 | + return 'single fixed color baked into the file — not recolorable via CSS; if the Figma fill differs, convert its fills to currentColor or re-export'; |
| 161 | + case 'multi-color': |
| 162 | + return 'multi-color asset — render as-is, do not recolor'; |
| 163 | + case 'unknown': |
| 164 | + return 'color contract unclear (no explicit fill or currentColor found) — inspect the svg before relying on recolor'; |
| 165 | + } |
| 166 | +}; |
| 167 | + |
| 168 | +const statusFor = (confidence: number, threshold: number): MappingStatus => { |
| 169 | + if (confidence >= 0.85) return 'high'; |
| 170 | + if (confidence >= threshold) return 'medium'; |
| 171 | + if (confidence >= 0.5) return 'low'; |
| 172 | + return 'unmapped'; |
| 173 | +}; |
| 174 | + |
| 175 | +export interface IconJoinOptions { |
| 176 | + threshold: number; |
| 177 | + svg: ProjectProfile['svg']; |
| 178 | + tailwind: boolean; |
| 179 | +} |
| 180 | + |
| 181 | +// Icons demand near-exact matching, unlike the component join. The asymmetry is the reason: a wrong |
| 182 | +// icon file is a *silent visual bug* (an up-arrow rendered as the matched down-arrow), while a missed |
| 183 | +// match just re-exports the correct icon fresh — so precision beats recall. Raw Dice can't separate a |
| 184 | +// real typo ("inifinte"→infinite, 0.71) from a wrong neighbor ("checkbox"→check 0.73, "arr-u"→arr-d |
| 185 | +// 0.67, "cash"→trash 0.57) — they overlap — and the wrong ones are the costlier error, so the floor |
| 186 | +// is set at the high bar: only an exact (after separator/space normalization) or long-name one-char |
| 187 | +// match reuses a file; everything else falls through to a fresh export. (Real synonyms like |
| 188 | +// trash↔delete need a synonym table, not fuzz — a future step, same as the token join's.) |
| 189 | +const ICON_MATCH_FLOOR = 0.85; |
| 190 | + |
| 191 | +const bestSvgMatch = ( |
| 192 | + label: string, |
| 193 | + svgs: readonly RepoSvg[], |
| 194 | +): { svg: RepoSvg; score: number } | null => { |
| 195 | + const target = norm(label); |
| 196 | + let best: { svg: RepoSvg; score: number } | null = null; |
| 197 | + for (const svg of svgs) { |
| 198 | + const score = Math.max( |
| 199 | + diceSimilarity(target, norm(svg.fileName)), |
| 200 | + diceSimilarity(target, norm(iconLabel(svg.fileName))), |
| 201 | + ); |
| 202 | + if (best === null || score > best.score) best = { svg, score }; |
| 203 | + } |
| 204 | + return best; |
| 205 | +}; |
| 206 | + |
| 207 | +const joinOne = ( |
| 208 | + icon: FigmaIconUsage, |
| 209 | + svgs: readonly RepoSvg[], |
| 210 | + opts: IconJoinOptions, |
| 211 | +): IconMapping => { |
| 212 | + const base: IconMapping = { |
| 213 | + figmaName: icon.figmaName, |
| 214 | + name: icon.name, |
| 215 | + nodeIds: icon.nodeIds, |
| 216 | + ...(icon.fill === undefined ? {} : { fill: icon.fill }), |
| 217 | + status: 'unmapped', |
| 218 | + }; |
| 219 | + |
| 220 | + const match = bestSvgMatch(icon.name, svgs); |
| 221 | + if (match === null || match.score < ICON_MATCH_FLOOR) return base; |
| 222 | + |
| 223 | + const confidence = Number(match.score.toFixed(3)); |
| 224 | + return { |
| 225 | + ...base, |
| 226 | + candidate: { |
| 227 | + filePath: match.svg.path, |
| 228 | + colorContract: match.svg.colorContract, |
| 229 | + recolor: recolorGuidance(match.svg.colorContract, opts.svg, opts.tailwind), |
| 230 | + confidence, |
| 231 | + }, |
| 232 | + status: statusFor(confidence, opts.threshold), |
| 233 | + }; |
| 234 | +}; |
| 235 | + |
| 236 | +/** Join every Figma icon usage against the project's `.svg` files; pure over its inputs. */ |
| 237 | +export const joinIcons = ( |
| 238 | + icons: readonly FigmaIconUsage[], |
| 239 | + svgs: readonly RepoSvg[], |
| 240 | + opts: IconJoinOptions, |
| 241 | +): IconMapping[] => icons.map(i => joinOne(i, svgs, opts)); |
0 commit comments