inkhouse 0.1.0-beta.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (62) hide show
  1. package/README.md +201 -0
  2. package/bin/inkhouse.mjs +171 -0
  3. package/code.js +11802 -0
  4. package/manifest.json +30 -0
  5. package/package.json +45 -0
  6. package/scanner/blob-placement-regression.ts +132 -0
  7. package/scanner/class-collector.ts +69 -0
  8. package/scanner/cli.ts +336 -0
  9. package/scanner/component-scanner.ts +2876 -0
  10. package/scanner/css-patch-regression.ts +112 -0
  11. package/scanner/css-token-reader-regression.ts +92 -0
  12. package/scanner/css-token-reader.ts +477 -0
  13. package/scanner/font-style-resolver-regression.ts +32 -0
  14. package/scanner/index.ts +9 -0
  15. package/scanner/radial-gradient-regression.ts +53 -0
  16. package/scanner/style-map.ts +145 -0
  17. package/scanner/tailwind-parser.ts +644 -0
  18. package/scanner/transform-math-regression.ts +42 -0
  19. package/scanner/types.ts +298 -0
  20. package/src/blob-placement.ts +111 -0
  21. package/src/change-detection.ts +204 -0
  22. package/src/class-utils.ts +105 -0
  23. package/src/clip-path-decorative.ts +194 -0
  24. package/src/color-resolver.ts +98 -0
  25. package/src/colors.ts +196 -0
  26. package/src/component-defs.ts +54 -0
  27. package/src/component-gen.ts +561 -0
  28. package/src/component-lookup.ts +82 -0
  29. package/src/config.ts +115 -0
  30. package/src/design-system.ts +59 -0
  31. package/src/dev-server.ts +173 -0
  32. package/src/figma-globals.d.ts +3 -0
  33. package/src/font-style-resolver.ts +171 -0
  34. package/src/github.ts +1465 -0
  35. package/src/icon-builder.ts +607 -0
  36. package/src/image-cache.ts +22 -0
  37. package/src/inline-text.ts +271 -0
  38. package/src/layout-parser.ts +667 -0
  39. package/src/layout-utils.ts +155 -0
  40. package/src/main.ts +687 -0
  41. package/src/node-ir.ts +595 -0
  42. package/src/pack-provider.ts +148 -0
  43. package/src/packs.ts +126 -0
  44. package/src/radial-gradient.ts +84 -0
  45. package/src/render-context.ts +138 -0
  46. package/src/responsive-analyzer.ts +139 -0
  47. package/src/state-analyzer.ts +143 -0
  48. package/src/story-builder.ts +1706 -0
  49. package/src/story-layout.ts +38 -0
  50. package/src/tailwind.ts +2379 -0
  51. package/src/text-builder.ts +116 -0
  52. package/src/text-line.ts +42 -0
  53. package/src/token-source.ts +43 -0
  54. package/src/tokens.ts +717 -0
  55. package/src/transform-math.ts +44 -0
  56. package/src/ui-builder.ts +1996 -0
  57. package/src/utility-resolver.ts +125 -0
  58. package/src/variables.ts +1042 -0
  59. package/src/width-solver.ts +466 -0
  60. package/templates/patch-tokens-route.ts +165 -0
  61. package/templates/scan-components-route.ts +57 -0
  62. package/ui.html +1222 -0
@@ -0,0 +1,607 @@
1
+ /**
2
+ * Icon Builder Module
3
+ *
4
+ * Handles icon creation, SVG processing, and icon manipulation for Figma.
5
+ * Extracted from ui-builder.ts for better modularity.
6
+ */
7
+
8
+ import { COMPONENT_DEFS } from './tokens';
9
+
10
+ declare const figma: any;
11
+
12
+ // ---------------------------------------------------------------------------
13
+ // Types
14
+ // ---------------------------------------------------------------------------
15
+
16
+ type RGB = { r: number; g: number; b: number };
17
+
18
+ type NodeIR = {
19
+ kind: string;
20
+ tagLower?: string;
21
+ props?: Record<string, string>;
22
+ children?: NodeIR[];
23
+ };
24
+
25
+ // ---------------------------------------------------------------------------
26
+ // Constants
27
+ // ---------------------------------------------------------------------------
28
+
29
+ export const ICON_PATHS: Record<string, { d: string; viewBox: number; stroke: boolean; strokeWidth?: number }> = {
30
+ home: {
31
+ d: "M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6",
32
+ viewBox: 24,
33
+ stroke: true,
34
+ strokeWidth: 2
35
+ },
36
+ portfolio: {
37
+ d: "M3 3C2.44772 3 2 3.44772 2 4V20C2 20.5523 2.44772 21 3 21H21C21.5523 21 22 20.5523 22 20V4C22 3.44772 21.5523 3 21 3H3ZM8 5V8H4V5H8ZM4 14V10H8V14H4ZM4 16H8V19H4V16ZM10 16H20V19H10V16ZM20 14H10V10H20V14ZM20 5V8H10V5H20Z",
38
+ viewBox: 24,
39
+ stroke: false
40
+ },
41
+ strategy: {
42
+ d: "M12 22C6.477 22 2 17.523 2 12S6.477 2 12 2s10 4.477 10 10-4.477 10-10 10Z M16.5 7.5 14 14 7.5 16.5 10 10l6.5-2.5Zm-4.5 5.5a1 1 0 1 0 0-2 1 1 0 0 0 0 2Z",
43
+ viewBox: 24,
44
+ stroke: true,
45
+ strokeWidth: 1.8
46
+ },
47
+ pda: {
48
+ d: "M22.0049 7.99979H13.0049C12.4526 7.99979 12.0049 8.4475 12.0049 8.99979V14.9998C12.0049 15.5521 12.4526 15.9998 13.0049 15.9998H22.0049V19.9998C22.0049 20.5521 21.5572 20.9998 21.0049 20.9998H3.00488C2.4526 20.9998 2.00488 20.5521 2.00488 19.9998V3.99979C2.00488 3.4475 2.4526 2.99979 3.00488 2.99979H21.0049C21.5572 2.99979 22.0049 3.4475 22.0049 3.99979V7.99979ZM15.0049 10.9998H18.0049V12.9998H15.0049V10.9998Z",
49
+ viewBox: 24,
50
+ stroke: false
51
+ },
52
+ account: {
53
+ d: "M3 4.99509C3 3.89323 3.89262 3 4.99509 3H19.0049C20.1068 3 21 3.89262 21 4.99509V19.0049C21 20.1068 20.1074 21 19.0049 21H4.99509C3.89323 21 3 20.1074 3 19.0049V4.99509ZM5 5V19H19V5H5ZM7.97216 18.1808C7.35347 17.9129 6.76719 17.5843 6.22083 17.2024C7.46773 15.2753 9.63602 14 12.1022 14C14.5015 14 16.6189 15.2071 17.8801 17.0472C17.3438 17.4436 16.7664 17.7877 16.1555 18.0718C15.2472 16.8166 13.77 16 12.1022 16C10.3865 16 8.87271 16.8641 7.97216 18.1808ZM12 13C10.067 13 8.5 11.433 8.5 9.5C8.5 7.567 10.067 6 12 6C13.933 6 15.5 7.567 15.5 9.5C15.5 11.433 13.933 13 12 13ZM12 11C12.8284 11 13.5 10.3284 13.5 9.5C13.5 8.67157 12.8284 8 12 8C11.1716 8 10.5 8.67157 10.5 9.5C10.5 10.3284 11.1716 11 12 11Z",
54
+ viewBox: 24,
55
+ stroke: false
56
+ },
57
+ logout: {
58
+ d: "M3 3a1 1 0 00-1 1v12a1 1 0 102 0V4a1 1 0 00-1-1zm10.293 9.293a1 1 0 001.414 1.414l3-3a1 1 0 000-1.414l-3-3a1 1 0 10-1.414 1.414L14.586 9H7a1 1 0 100 2h7.586l-1.293 1.293z",
59
+ viewBox: 20,
60
+ stroke: false
61
+ },
62
+ menu: {
63
+ d: "M4 6h16M4 12h16M4 18h16",
64
+ viewBox: 24,
65
+ stroke: true,
66
+ strokeWidth: 2
67
+ },
68
+ close: {
69
+ d: "M6 18L18 6M6 6l12 12",
70
+ viewBox: 24,
71
+ stroke: true,
72
+ strokeWidth: 2
73
+ },
74
+ 'chevron-down': {
75
+ d: "M6 9l6 6 6-6",
76
+ viewBox: 24,
77
+ stroke: true,
78
+ strokeWidth: 2
79
+ },
80
+ check: {
81
+ d: "M5 13l4 4L19 7",
82
+ viewBox: 24,
83
+ stroke: true,
84
+ strokeWidth: 2
85
+ }
86
+ };
87
+
88
+ const SVG_CHILD_TAGS: Record<string, boolean> = {
89
+ path: true,
90
+ circle: true,
91
+ rect: true,
92
+ line: true,
93
+ polyline: true,
94
+ polygon: true,
95
+ ellipse: true,
96
+ g: true,
97
+ use: true,
98
+ defs: true,
99
+ clippath: true,
100
+ mask: true,
101
+ };
102
+
103
+ // ---------------------------------------------------------------------------
104
+ // Helper Functions
105
+ // ---------------------------------------------------------------------------
106
+
107
+ /**
108
+ * Convert RGB to hex string
109
+ */
110
+ export function rgbToHex(color: RGB): string {
111
+ const r = Math.round(Math.max(0, Math.min(1, color.r)) * 255);
112
+ const g = Math.round(Math.max(0, Math.min(1, color.g)) * 255);
113
+ const b = Math.round(Math.max(0, Math.min(1, color.b)) * 255);
114
+ return '#' + [r, g, b].map(x => x.toString(16).padStart(2, '0')).join('');
115
+ }
116
+
117
+ function escapeSvgAttr(value: string): string {
118
+ return String(value || '')
119
+ .replace(/&/g, '&amp;')
120
+ .replace(/"/g, '&quot;')
121
+ .replace(/'/g, '&#39;')
122
+ .replace(/</g, '&lt;')
123
+ .replace(/>/g, '&gt;');
124
+ }
125
+
126
+ function extractSvgAttribute(svg: string, attr: string): string | null {
127
+ const re = new RegExp('<svg[^>]*\\s' + attr + '=["\\\']([^"\\\']+)["\\\']', 'i');
128
+ const match = svg.match(re);
129
+ return match ? match[1] : null;
130
+ }
131
+
132
+ function normalizeSvgPaintValue(value: string | null): string | null {
133
+ if (!value) return null;
134
+ const lower = value.trim().toLowerCase();
135
+ if (lower === 'none') return null;
136
+ return value;
137
+ }
138
+
139
+ /**
140
+ * Preprocess SVG for Figma compatibility
141
+ * - Replaces "currentColor" with the actual hex color (Figma doesn't understand CSS currentColor)
142
+ */
143
+ function preprocessSvgForFigma(svg: string, color?: RGB): string {
144
+ const hexColor = color ? rgbToHex(color) : '#000000';
145
+ let output = svg.replace(/currentColor/gi, hexColor);
146
+ output = output.replace(/\scolor=["'][^"']*["']/gi, '');
147
+ output = output.replace(/\sstyle=["']([^"']*)["']/gi, function(_match, rawStyles) {
148
+ const cleaned = String(rawStyles || '')
149
+ .split(';')
150
+ .map(s => s.trim())
151
+ .filter(Boolean)
152
+ .filter(s => s.toLowerCase().indexOf('color:') !== 0)
153
+ .join('; ');
154
+ if (!cleaned) return '';
155
+ return ' style="' + cleaned + '"';
156
+ });
157
+ const rootFill = normalizeSvgPaintValue(extractSvgAttribute(output, 'fill'));
158
+ const rootStroke = normalizeSvgPaintValue(extractSvgAttribute(output, 'stroke'));
159
+
160
+ output = output.replace(/<path\b([^>]*)>/gi, function(_match, rawAttrs) {
161
+ let attrs = rawAttrs || '';
162
+ let suffix = '';
163
+ if (attrs.trim().endsWith('/')) {
164
+ attrs = attrs.replace(/\s*\/$/, '');
165
+ suffix = ' /';
166
+ }
167
+
168
+ const hasFill = /\sfill=/.test(attrs);
169
+ const hasStroke = /\sstroke=/.test(attrs);
170
+
171
+ if (!hasStroke && rootStroke) {
172
+ attrs += ' stroke="' + rootStroke + '"';
173
+ }
174
+ if (!hasFill) {
175
+ if (!hasStroke && rootFill) {
176
+ attrs += ' fill="' + rootFill + '"';
177
+ } else if (!hasStroke && !rootFill && !rootStroke) {
178
+ attrs += ' fill="' + hexColor + '"';
179
+ }
180
+ }
181
+
182
+ return '<path' + attrs + suffix + '>';
183
+ });
184
+
185
+ return output;
186
+ }
187
+
188
+ // ---------------------------------------------------------------------------
189
+ // SVG Node Manipulation
190
+ // ---------------------------------------------------------------------------
191
+
192
+ function getVectorPaintUsage(node: SceneNode): { hasFills: boolean; hasStrokes: boolean } {
193
+ let hasFills = false;
194
+ let hasStrokes = false;
195
+ const vectors: any[] = [];
196
+ if (node.type === 'VECTOR') {
197
+ vectors.push(node);
198
+ } else if ('findAll' in node) {
199
+ const found = (node as any).findAll((child: any) => child.type === 'VECTOR');
200
+ for (const vector of found) vectors.push(vector);
201
+ }
202
+ for (const vector of vectors) {
203
+ if (Array.isArray(vector.fills) && vector.fills.length > 0) hasFills = true;
204
+ if (Array.isArray(vector.strokes) && vector.strokes.length > 0) hasStrokes = true;
205
+ }
206
+ return { hasFills, hasStrokes };
207
+ }
208
+
209
+ function recolorIconPaint(node: SceneNode, color: RGB): void {
210
+ const vectors: any[] = [];
211
+ if (node.type === 'VECTOR') {
212
+ vectors.push(node);
213
+ } else if ('findAll' in node) {
214
+ const found = (node as any).findAll((child: any) => child.type === 'VECTOR');
215
+ for (const vector of found) vectors.push(vector);
216
+ }
217
+ const paint = { type: 'SOLID', color: { r: color.r, g: color.g, b: color.b } };
218
+ for (const vector of vectors) {
219
+ if (Array.isArray(vector.fills) && vector.fills.length > 0) {
220
+ vector.fills = [paint];
221
+ }
222
+ if (Array.isArray(vector.strokes) && vector.strokes.length > 0) {
223
+ vector.strokes = [paint];
224
+ }
225
+ }
226
+ }
227
+
228
+ function applyIconPaint(
229
+ node: SceneNode,
230
+ color: RGB,
231
+ options: { stroke?: boolean; strokeWidth?: number } = {}
232
+ ): void {
233
+ const vectors: any[] = [];
234
+ if (node.type === 'VECTOR') {
235
+ vectors.push(node);
236
+ } else if ('findAll' in node) {
237
+ const found = (node as any).findAll((child: any) => child.type === 'VECTOR');
238
+ for (const vector of found) vectors.push(vector);
239
+ }
240
+ for (const vector of vectors) {
241
+ const paint = { type: 'SOLID', color: { r: color.r, g: color.g, b: color.b } };
242
+ if (options.stroke) {
243
+ vector.strokes = [paint];
244
+ vector.strokeWeight = options.strokeWidth || vector.strokeWeight || 1.5;
245
+ vector.fills = [];
246
+ } else {
247
+ vector.fills = [paint];
248
+ vector.strokes = [];
249
+ }
250
+ }
251
+ }
252
+
253
+ export function normalizeIconNode(node: SceneNode): void {
254
+ if (!node) return;
255
+ if ('clipsContent' in node) (node as any).clipsContent = false;
256
+ if ('layoutGrow' in node) (node as any).layoutGrow = 0;
257
+ const vectors: any[] = [];
258
+ if (node.type === 'VECTOR') {
259
+ vectors.push(node);
260
+ } else if ('findAll' in node) {
261
+ const found = (node as any).findAll((child: any) => child.type === 'VECTOR');
262
+ for (const vector of found) vectors.push(vector);
263
+ }
264
+ for (const vector of vectors) {
265
+ if ('strokeAlign' in vector) {
266
+ try {
267
+ vector.strokeAlign = 'CENTER';
268
+ } catch (_err) {
269
+ // ignore
270
+ }
271
+ }
272
+ }
273
+ }
274
+
275
+ export function flattenSvgNode(node: SceneNode): SceneNode | null {
276
+ if (!node || !('children' in node)) return null;
277
+ const children = (node as any).children || [];
278
+ if (!children.length) return null;
279
+ try {
280
+ const flattened = figma.flatten(Array.from(children));
281
+ flattened.name = node.name;
282
+ normalizeIconNode(flattened);
283
+ try {
284
+ const parent = (node as any).parent as any;
285
+ if (parent && 'appendChild' in parent) {
286
+ (parent as any).appendChild(flattened);
287
+ }
288
+ node.remove();
289
+ } catch (_err) {
290
+ // ignore
291
+ }
292
+ return flattened;
293
+ } catch (_err) {
294
+ return null;
295
+ }
296
+ }
297
+
298
+ export function resizeSvgNodeTo(node: SceneNode, width: number, height: number): void {
299
+ if (!node || width <= 0 || height <= 0) return;
300
+ if (node.type === 'VECTOR') {
301
+ if ('resizeWithoutConstraints' in node) (node as any).resizeWithoutConstraints(width, height);
302
+ else if ('resize' in node) (node as any).resize(width, height);
303
+ return;
304
+ }
305
+ const vectors = ('findAll' in node) ? (node as any).findAll((child: any) => child.type === 'VECTOR') : [];
306
+ const baseWidth = (node as any).width || 0;
307
+ const baseHeight = (node as any).height || 0;
308
+ if (!vectors.length || baseWidth <= 0 || baseHeight <= 0) {
309
+ if ('resizeWithoutConstraints' in node) (node as any).resizeWithoutConstraints(width, height);
310
+ else if ('resize' in node) (node as any).resize(width, height);
311
+ return;
312
+ }
313
+ const sx = width / baseWidth;
314
+ const sy = height / baseHeight;
315
+ for (const vector of vectors) {
316
+ if ('resizeWithoutConstraints' in vector) {
317
+ vector.resizeWithoutConstraints(vector.width * sx, vector.height * sy);
318
+ } else if ('resize' in vector) {
319
+ vector.resize(vector.width * sx, vector.height * sy);
320
+ }
321
+ if (typeof vector.x === 'number') vector.x = vector.x * sx;
322
+ if (typeof vector.y === 'number') vector.y = vector.y * sy;
323
+ }
324
+ if ('resizeWithoutConstraints' in node) (node as any).resizeWithoutConstraints(width, height);
325
+ else if ('resize' in node) (node as any).resize(width, height);
326
+ }
327
+
328
+ export function wrapIconNode(
329
+ icon: SceneNode,
330
+ width: number,
331
+ height: number,
332
+ pad: number,
333
+ name: string
334
+ ): FrameNode {
335
+ const wrapper = figma.createFrame();
336
+ wrapper.name = name;
337
+ wrapper.layoutMode = 'NONE';
338
+ wrapper.fills = [];
339
+ wrapper.strokes = [];
340
+ wrapper.resize(width, height);
341
+ wrapper.clipsContent = false;
342
+ if ('layoutGrow' in wrapper) (wrapper as any).layoutGrow = 0;
343
+
344
+ const innerWidth = Math.max(1, width - pad * 2);
345
+ const innerHeight = Math.max(1, height - pad * 2);
346
+ if (innerWidth !== width || innerHeight !== height) {
347
+ resizeSvgNodeTo(icon, innerWidth, innerHeight);
348
+ }
349
+ normalizeIconNode(icon);
350
+ if ('x' in icon) (icon as any).x = Math.round((width - (icon as any).width) / 2);
351
+ if ('y' in icon) (icon as any).y = Math.round((height - (icon as any).height) / 2);
352
+ wrapper.appendChild(icon);
353
+ return wrapper;
354
+ }
355
+
356
+ // ---------------------------------------------------------------------------
357
+ // Icon Creation Functions
358
+ // ---------------------------------------------------------------------------
359
+
360
+ /**
361
+ * Create an icon from the built-in ICON_PATHS
362
+ */
363
+ export function createIcon(name: string, color: RGB): any {
364
+ const spec = ICON_PATHS[name];
365
+ if (!spec) return null;
366
+ const hexColor = color && typeof color.r === 'number' ? rgbToHex(color) : '#000000';
367
+ const strokeAttrs = spec.stroke
368
+ ? `fill="none" stroke="${hexColor}" stroke-width="${spec.strokeWidth || 1.5}" stroke-linecap="round" stroke-linejoin="round"`
369
+ : `fill="${hexColor}"`;
370
+ const svg = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${spec.viewBox} ${spec.viewBox}"><path d="${spec.d}" ${strokeAttrs} /></svg>`;
371
+ const node = figma.createNodeFromSvg(svg);
372
+ node.name = `icon/${name}`;
373
+ normalizeIconNode(node);
374
+ // Keep the imported SVG frame so its original viewBox canvas is preserved.
375
+ // Flattening collapses to tight path bounds, which distorts icon proportions
376
+ // for sparse glyphs (for example chevrons) when resized to Tailwind sizes.
377
+ const target = node;
378
+ const usage = getVectorPaintUsage(target);
379
+ if (!usage.hasFills && !usage.hasStrokes) {
380
+ applyIconPaint(target, color, { stroke: spec.stroke, strokeWidth: spec.strokeWidth || 1.5 });
381
+ }
382
+ normalizeIconNode(target);
383
+ return target;
384
+ }
385
+
386
+ /**
387
+ * Create an icon from an SVG string
388
+ */
389
+ export function createIconFromSvg(svg: string, color: RGB): any {
390
+ try {
391
+ const usesCurrentColor = /currentcolor/i.test(svg);
392
+ const processedSvg = preprocessSvgForFigma(svg, color);
393
+ const node = figma.createNodeFromSvg(processedSvg);
394
+ node.name = 'icon/svg';
395
+ normalizeIconNode(node);
396
+
397
+ const vectors: SceneNode[] = [];
398
+ if (node.type === 'VECTOR') {
399
+ vectors.push(node);
400
+ } else if ('findAll' in node) {
401
+ const found = (node as FrameNode).findAll(function(child) { return child.type === 'VECTOR'; });
402
+ for (let i = 0; i < found.length; i++) vectors.push(found[i]);
403
+ }
404
+ if (vectors.length === 0) {
405
+ node.remove();
406
+ return null;
407
+ }
408
+
409
+ // Keep the imported SVG frame so resize operations honor the source
410
+ // viewBox instead of scaling from tight vector bounds.
411
+ const target = node;
412
+
413
+ let finalUsage = getVectorPaintUsage(target);
414
+ if (usesCurrentColor && (finalUsage.hasFills || finalUsage.hasStrokes)) {
415
+ recolorIconPaint(target, color);
416
+ finalUsage = getVectorPaintUsage(target);
417
+ }
418
+ if (!finalUsage.hasFills && !finalUsage.hasStrokes) {
419
+ const svgHasStroke = /stroke=/.test(processedSvg) && !/stroke=["']none["']/i.test(processedSvg);
420
+ const svgHasFill = /fill=/.test(processedSvg) && !/fill=["']none["']/i.test(processedSvg);
421
+ applyIconPaint(target, color, { stroke: svgHasStroke && !svgHasFill });
422
+ finalUsage = getVectorPaintUsage(target);
423
+ if (!finalUsage.hasFills && !finalUsage.hasStrokes) {
424
+ target.remove();
425
+ return null;
426
+ }
427
+ }
428
+
429
+ normalizeIconNode(target);
430
+ return target;
431
+ } catch (_err) {
432
+ return null;
433
+ }
434
+ }
435
+
436
+ // ---------------------------------------------------------------------------
437
+ // NodeIR to SVG Conversion
438
+ // ---------------------------------------------------------------------------
439
+
440
+ function nodeIrToSvgChild(node: NodeIR): string | null {
441
+ if (node.kind !== 'element') return null;
442
+
443
+ const tag = node.tagLower || '';
444
+ const props = node.props || {};
445
+
446
+ if (!SVG_CHILD_TAGS[tag]) {
447
+ const inner = (node.children || [])
448
+ .map(function(child) { return nodeIrToSvgChild(child); })
449
+ .filter(Boolean)
450
+ .join('');
451
+ return inner || null;
452
+ }
453
+
454
+ const attrs: string[] = [];
455
+ for (const key in props) {
456
+ if (props[key] == null) continue;
457
+ if (key === 'className') continue;
458
+ const attrKey = key.replace(/[A-Z]/g, function(m) { return '-' + m.toLowerCase(); });
459
+ attrs.push(`${attrKey}="${escapeSvgAttr(props[key])}"`);
460
+ }
461
+
462
+ const inner = (node.children || [])
463
+ .map(function(child) { return nodeIrToSvgChild(child); })
464
+ .filter(Boolean)
465
+ .join('');
466
+ if (inner) {
467
+ return `<${tag}${attrs.length ? ' ' + attrs.join(' ') : ''}>${inner}</${tag}>`;
468
+ }
469
+ return `<${tag}${attrs.length ? ' ' + attrs.join(' ') : ''} />`;
470
+ }
471
+
472
+ export function nodeIrToSvg(node: NodeIR): string | null {
473
+ if (node.kind !== 'element' || node.tagLower !== 'svg') return null;
474
+
475
+ const attrs: string[] = [];
476
+ const props = node.props || {};
477
+
478
+ if (props.viewBox) attrs.push(`viewBox="${escapeSvgAttr(props.viewBox)}"`);
479
+ if (props.width) attrs.push(`width="${escapeSvgAttr(props.width)}"`);
480
+ if (props.height) attrs.push(`height="${escapeSvgAttr(props.height)}"`);
481
+ if (props.fill) attrs.push(`fill="${escapeSvgAttr(props.fill)}"`);
482
+ if (props.stroke) attrs.push(`stroke="${escapeSvgAttr(props.stroke)}"`);
483
+ if (props['stroke-width']) attrs.push(`stroke-width="${escapeSvgAttr(props['stroke-width'])}"`);
484
+ if (props.strokeWidth) attrs.push(`stroke-width="${escapeSvgAttr(props.strokeWidth)}"`);
485
+ if (props['stroke-linecap']) attrs.push(`stroke-linecap="${escapeSvgAttr(props['stroke-linecap'])}"`);
486
+ if (props.strokeLinecap) attrs.push(`stroke-linecap="${escapeSvgAttr(props.strokeLinecap)}"`);
487
+ if (props['stroke-linejoin']) attrs.push(`stroke-linejoin="${escapeSvgAttr(props['stroke-linejoin'])}"`);
488
+ if (props.strokeLinejoin) attrs.push(`stroke-linejoin="${escapeSvgAttr(props.strokeLinejoin)}"`);
489
+
490
+ attrs.unshift(`xmlns="http://www.w3.org/2000/svg"`);
491
+
492
+ const children = (node.children || [])
493
+ .map(function(child) { return nodeIrToSvgChild(child); })
494
+ .filter(Boolean)
495
+ .join('');
496
+
497
+ const hasViewBox = !!props.viewBox;
498
+ const safeAttrs = hasViewBox ? attrs : attrs.concat(['viewBox="0 0 24 24"']);
499
+
500
+ return `<svg ${safeAttrs.join(' ')}>${children}</svg>`;
501
+ }
502
+
503
+ // ---------------------------------------------------------------------------
504
+ // Size Resolution
505
+ // ---------------------------------------------------------------------------
506
+
507
+ function resolveSpacingToken(token: string): number | null {
508
+ const spacingScale = COMPONENT_DEFS && COMPONENT_DEFS.spacingScale ? COMPONENT_DEFS.spacingScale : {};
509
+ if (spacingScale && spacingScale[token] != null) return spacingScale[token];
510
+ const numeric = parseFloat(token);
511
+ if (!Number.isNaN(numeric)) return numeric * 4;
512
+ return null;
513
+ }
514
+
515
+ function getBaseClass(cls: string): string | null {
516
+ if (!cls) return null;
517
+ const parts = cls.split(':');
518
+ return parts[parts.length - 1] || null;
519
+ }
520
+
521
+ export function resolveIconSizeFromClasses(classes: string[], props: Record<string, string>): { width: number; height: number } {
522
+ let width: number | null = null;
523
+ let height: number | null = null;
524
+
525
+ const parsePx = (value: string): number | null => {
526
+ const n = parseFloat(value);
527
+ if (Number.isNaN(n)) return null;
528
+ return n;
529
+ };
530
+
531
+ if (props.size) {
532
+ const sizeValue = parsePx(props.size);
533
+ if (sizeValue != null) {
534
+ width = sizeValue;
535
+ height = sizeValue;
536
+ }
537
+ }
538
+
539
+ if (props.width) {
540
+ const w = parsePx(props.width);
541
+ if (w != null) width = w;
542
+ }
543
+ if (props.height) {
544
+ const h = parsePx(props.height);
545
+ if (h != null) height = h;
546
+ }
547
+
548
+ for (const cls of classes) {
549
+ const base = getBaseClass(cls);
550
+ if (!base) continue;
551
+ const sizeBracket = base.match(/^size-\[(\d+(?:\.\d+)?)px\]$/);
552
+ if (sizeBracket) {
553
+ const size = parseFloat(sizeBracket[1]);
554
+ width = size;
555
+ height = size;
556
+ break;
557
+ }
558
+ const sizeToken = base.match(/^size-(\d+(?:\.\d+)?)$/);
559
+ if (sizeToken) {
560
+ const size = resolveSpacingToken(sizeToken[1]);
561
+ if (size != null) {
562
+ width = size;
563
+ height = size;
564
+ break;
565
+ }
566
+ }
567
+ }
568
+
569
+ for (const cls of classes) {
570
+ const base = getBaseClass(cls);
571
+ if (!base) continue;
572
+ const wBracket = base.match(/^w-\[(\d+(?:\.\d+)?)px\]$/);
573
+ if (wBracket) {
574
+ width = parseFloat(wBracket[1]);
575
+ continue;
576
+ }
577
+ const hBracket = base.match(/^h-\[(\d+(?:\.\d+)?)px\]$/);
578
+ if (hBracket) {
579
+ height = parseFloat(hBracket[1]);
580
+ continue;
581
+ }
582
+ const wToken = base.match(/^w-(\d+(?:\.\d+)?)$/);
583
+ if (wToken) {
584
+ const w = resolveSpacingToken(wToken[1]);
585
+ if (w != null) width = w;
586
+ }
587
+ const hToken = base.match(/^h-(\d+(?:\.\d+)?)$/);
588
+ if (hToken) {
589
+ const h = resolveSpacingToken(hToken[1]);
590
+ if (h != null) height = h;
591
+ }
592
+ }
593
+
594
+ if (width == null && height == null) {
595
+ width = 24;
596
+ height = 24;
597
+ } else if (width == null && height != null) {
598
+ width = height;
599
+ } else if (height == null && width != null) {
600
+ height = width;
601
+ }
602
+
603
+ return { width: width as number, height: height as number };
604
+ }
605
+
606
+ // Re-export for convenience
607
+ export { getVectorPaintUsage };
@@ -0,0 +1,22 @@
1
+ /**
2
+ * Module-level image cache: src path → figma imageHash (raster) or SVG string.
3
+ * Populated by prefetchImages() before rendering starts.
4
+ */
5
+ let _imageMap: Map<string, string> = new Map();
6
+ let _svgMap: Map<string, string> = new Map();
7
+
8
+ export function setImageMap(map: Map<string, string>): void {
9
+ _imageMap = map;
10
+ }
11
+
12
+ export function getImageHash(src: string): string | null {
13
+ return _imageMap.get(src) ?? null;
14
+ }
15
+
16
+ export function setSvgMap(map: Map<string, string>): void {
17
+ _svgMap = map;
18
+ }
19
+
20
+ export function getSvgString(src: string): string | null {
21
+ return _svgMap.get(src) ?? null;
22
+ }