figma-coder-mcp 0.2.2 → 0.3.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.
- package/LICENSE +21 -21
- package/README.md +164 -152
- package/dist/bin.js +863 -89
- package/package.json +3 -1
package/dist/bin.js
CHANGED
|
@@ -295,7 +295,8 @@ var require_client = __commonJS({
|
|
|
295
295
|
async renderImages(fileKey, nodeIds, format = "svg", requestAuth, scale = 1) {
|
|
296
296
|
const auth = this.resolveAuth(requestAuth);
|
|
297
297
|
const ids = nodeIds.map(url_1.normalizeNodeId).join(",");
|
|
298
|
-
const
|
|
298
|
+
const svgOpts = format === "svg" ? "&svg_outline_text=true&svg_simplify_stroke=true" : "";
|
|
299
|
+
const data = await this.get(`/images/${fileKey}?ids=${encodeURIComponent(ids)}&format=${format}&scale=${scale}${svgOpts}`, auth);
|
|
299
300
|
if (data.err) {
|
|
300
301
|
throw new errors_1.FigmaApiError(`Figma image render error: ${data.err}`, 502);
|
|
301
302
|
}
|
|
@@ -361,8 +362,9 @@ var require_style_ir = __commonJS({
|
|
|
361
362
|
"../packages/figma-core/dist/converter/style-ir.js"(exports) {
|
|
362
363
|
"use strict";
|
|
363
364
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
364
|
-
exports.StyleIr = void 0;
|
|
365
|
+
exports.StyleIr = exports.IMAGE_FILL_TOKEN = void 0;
|
|
365
366
|
var color_1 = require_color();
|
|
367
|
+
exports.IMAGE_FILL_TOKEN = "__FIGMA_IMAGE__";
|
|
366
368
|
var ALIGN_MAIN = {
|
|
367
369
|
MIN: "flex-start",
|
|
368
370
|
CENTER: "center",
|
|
@@ -375,7 +377,7 @@ var require_style_ir = __commonJS({
|
|
|
375
377
|
MAX: "flex-end",
|
|
376
378
|
BASELINE: "baseline"
|
|
377
379
|
};
|
|
378
|
-
var StyleIr = class {
|
|
380
|
+
var StyleIr = class _StyleIr {
|
|
379
381
|
/** Convert a Figma node subtree into the Style IR tree. */
|
|
380
382
|
convert(root) {
|
|
381
383
|
return this.walk(root, void 0);
|
|
@@ -392,10 +394,22 @@ var require_style_ir = __commonJS({
|
|
|
392
394
|
this.applyEffects(node, css);
|
|
393
395
|
}
|
|
394
396
|
this.applyOpacity(node, css);
|
|
397
|
+
this.applyBlend(node, css);
|
|
395
398
|
const isText = node.type === "TEXT";
|
|
396
399
|
if (isText)
|
|
397
400
|
this.applyText(node, css);
|
|
398
401
|
const tag = isText ? "p" : "div";
|
|
402
|
+
if (asset?.kind === "vector") {
|
|
403
|
+
return {
|
|
404
|
+
id: node.id,
|
|
405
|
+
name: node.name,
|
|
406
|
+
figmaType: node.type,
|
|
407
|
+
tag,
|
|
408
|
+
css,
|
|
409
|
+
asset,
|
|
410
|
+
children: []
|
|
411
|
+
};
|
|
412
|
+
}
|
|
399
413
|
const visibleChildren = (node.children ?? []).filter((c) => c.visible !== false);
|
|
400
414
|
const maskChild = visibleChildren.find((c) => c.isMask);
|
|
401
415
|
if (maskChild) {
|
|
@@ -405,17 +419,79 @@ var require_style_ir = __commonJS({
|
|
|
405
419
|
this.applyRadius(maskChild, css);
|
|
406
420
|
}
|
|
407
421
|
const children = visibleChildren.map((c) => this.walk(c, node));
|
|
422
|
+
const runs = isText ? this.textRuns(node) : void 0;
|
|
408
423
|
return {
|
|
409
424
|
id: node.id,
|
|
410
425
|
name: node.name,
|
|
411
426
|
figmaType: node.type,
|
|
412
427
|
tag,
|
|
413
428
|
...isText ? { text: node.characters ?? "" } : {},
|
|
429
|
+
...runs ? { runs } : {},
|
|
414
430
|
css,
|
|
415
431
|
...asset ? { asset } : {},
|
|
416
432
|
children
|
|
417
433
|
};
|
|
418
434
|
}
|
|
435
|
+
/**
|
|
436
|
+
* Split a text node into styled runs when it has per-character overrides
|
|
437
|
+
* (a bold word, a colored span). Returns undefined for plain single-style
|
|
438
|
+
* text (the common case) so the renderer emits one string. `css` per run holds
|
|
439
|
+
* ONLY the overridden props; the <p> base style supplies the rest.
|
|
440
|
+
*/
|
|
441
|
+
textRuns(node) {
|
|
442
|
+
const chars = node.characters ?? "";
|
|
443
|
+
const overrides = node.characterStyleOverrides;
|
|
444
|
+
const table = node.styleOverrideTable;
|
|
445
|
+
if (!chars || !overrides?.length || !table)
|
|
446
|
+
return void 0;
|
|
447
|
+
if (!overrides.some((id) => id && table[String(id)]))
|
|
448
|
+
return void 0;
|
|
449
|
+
const raw = [];
|
|
450
|
+
let curId = -1;
|
|
451
|
+
for (let i = 0; i < chars.length; i++) {
|
|
452
|
+
const id = overrides[i] ?? 0;
|
|
453
|
+
if (id !== curId || raw.length === 0) {
|
|
454
|
+
raw.push({ id, text: "" });
|
|
455
|
+
curId = id;
|
|
456
|
+
}
|
|
457
|
+
raw[raw.length - 1].text += chars[i];
|
|
458
|
+
}
|
|
459
|
+
if (raw.length <= 1)
|
|
460
|
+
return void 0;
|
|
461
|
+
return raw.map((r) => ({
|
|
462
|
+
text: r.text,
|
|
463
|
+
css: r.id && table[String(r.id)] ? this.overrideCss(table[String(r.id)]) : {}
|
|
464
|
+
}));
|
|
465
|
+
}
|
|
466
|
+
/** Map an override style entry (partial TypeStyle + optional fills) to CSS. */
|
|
467
|
+
overrideCss(o) {
|
|
468
|
+
const css = {};
|
|
469
|
+
if (o.fontWeight)
|
|
470
|
+
css["font-weight"] = String(o.fontWeight);
|
|
471
|
+
if (o.fontSize)
|
|
472
|
+
css["font-size"] = (0, color_1.px)(o.fontSize);
|
|
473
|
+
if (o.fontFamily)
|
|
474
|
+
css["font-family"] = `'${o.fontFamily}', sans-serif`;
|
|
475
|
+
if (o.italic)
|
|
476
|
+
css["font-style"] = "italic";
|
|
477
|
+
if (o.letterSpacing)
|
|
478
|
+
css["letter-spacing"] = (0, color_1.px)(o.letterSpacing);
|
|
479
|
+
if (o.lineHeightPx)
|
|
480
|
+
css["line-height"] = (0, color_1.px)(o.lineHeightPx);
|
|
481
|
+
if (o.textDecoration === "UNDERLINE")
|
|
482
|
+
css["text-decoration"] = "underline";
|
|
483
|
+
if (o.textDecoration === "STRIKETHROUGH")
|
|
484
|
+
css["text-decoration"] = "line-through";
|
|
485
|
+
if (o.textCase === "UPPER")
|
|
486
|
+
css["text-transform"] = "uppercase";
|
|
487
|
+
if (o.textCase === "LOWER")
|
|
488
|
+
css["text-transform"] = "lowercase";
|
|
489
|
+
const fills = this.visiblePaints(o.fills);
|
|
490
|
+
const p = fills[fills.length - 1];
|
|
491
|
+
if (p?.type === "SOLID" && p.color)
|
|
492
|
+
css.color = (0, color_1.figmaColorToCss)(p.color, p.opacity ?? 1);
|
|
493
|
+
return css;
|
|
494
|
+
}
|
|
419
495
|
// ---- layout -------------------------------------------------------------
|
|
420
496
|
applyLayout(node, parent, css) {
|
|
421
497
|
const auto = node.layoutMode === "HORIZONTAL" || node.layoutMode === "VERTICAL";
|
|
@@ -457,8 +533,60 @@ var require_style_ir = __commonJS({
|
|
|
457
533
|
const box = node.absoluteBoundingBox;
|
|
458
534
|
if (!box)
|
|
459
535
|
return;
|
|
460
|
-
|
|
461
|
-
|
|
536
|
+
this.applyAxisSize(node, parent, css, "H", box.width);
|
|
537
|
+
this.applyAxisSize(node, parent, css, "V", box.height);
|
|
538
|
+
if (node.minWidth != null)
|
|
539
|
+
css["min-width"] = (0, color_1.px)(node.minWidth);
|
|
540
|
+
if (node.maxWidth != null)
|
|
541
|
+
css["max-width"] = (0, color_1.px)(node.maxWidth);
|
|
542
|
+
if (node.minHeight != null)
|
|
543
|
+
css["min-height"] = (0, color_1.px)(node.minHeight);
|
|
544
|
+
if (node.maxHeight != null)
|
|
545
|
+
css["max-height"] = (0, color_1.px)(node.maxHeight);
|
|
546
|
+
}
|
|
547
|
+
/**
|
|
548
|
+
* Size one axis. Inside an auto-layout parent we honor Figma's sizing intent —
|
|
549
|
+
* FILL → grow (flex-1) or stretch the cross axis, HUG → let content size it,
|
|
550
|
+
* FIXED → px — instead of freezing every box to its pixel dimensions. Outside
|
|
551
|
+
* auto-layout (absolute children, root) we keep the exact px.
|
|
552
|
+
*/
|
|
553
|
+
applyAxisSize(node, parent, css, axis, boxDim) {
|
|
554
|
+
const dim = axis === "H" ? "width" : "height";
|
|
555
|
+
const parentMainHorizontal = parent?.layoutMode === "HORIZONTAL";
|
|
556
|
+
const parentAuto = parentMainHorizontal || parent?.layoutMode === "VERTICAL";
|
|
557
|
+
const sizing = this.axisSizing(node, axis, parentMainHorizontal);
|
|
558
|
+
if (!parentAuto || !sizing) {
|
|
559
|
+
css[dim] = (0, color_1.px)(boxDim);
|
|
560
|
+
return;
|
|
561
|
+
}
|
|
562
|
+
const isMainAxis = axis === "H" === parentMainHorizontal;
|
|
563
|
+
if (sizing === "FILL") {
|
|
564
|
+
if (isMainAxis)
|
|
565
|
+
css.flex = "1 1 0%";
|
|
566
|
+
else
|
|
567
|
+
css["align-self"] = "stretch";
|
|
568
|
+
return;
|
|
569
|
+
}
|
|
570
|
+
if (sizing === "HUG") {
|
|
571
|
+
const hasContent = (node.children?.length ?? 0) > 0 || node.type === "TEXT";
|
|
572
|
+
if (!hasContent)
|
|
573
|
+
css[dim] = (0, color_1.px)(boxDim);
|
|
574
|
+
return;
|
|
575
|
+
}
|
|
576
|
+
css[dim] = (0, color_1.px)(boxDim);
|
|
577
|
+
}
|
|
578
|
+
/** Resolve a node's sizing mode on an axis, falling back from the modern
|
|
579
|
+
* layoutSizing* fields to the legacy layoutGrow/layoutAlign. */
|
|
580
|
+
axisSizing(node, axis, parentMainHorizontal) {
|
|
581
|
+
const explicit = axis === "H" ? node.layoutSizingHorizontal : node.layoutSizingVertical;
|
|
582
|
+
if (explicit)
|
|
583
|
+
return explicit;
|
|
584
|
+
const isMainAxis = axis === "H" === parentMainHorizontal;
|
|
585
|
+
if (isMainAxis && node.layoutGrow === 1)
|
|
586
|
+
return "FILL";
|
|
587
|
+
if (!isMainAxis && node.layoutAlign === "STRETCH")
|
|
588
|
+
return "FILL";
|
|
589
|
+
return void 0;
|
|
462
590
|
}
|
|
463
591
|
// ---- paints -------------------------------------------------------------
|
|
464
592
|
visiblePaints(paints) {
|
|
@@ -472,22 +600,109 @@ var require_style_ir = __commonJS({
|
|
|
472
600
|
const fills = this.visiblePaints(node.fills);
|
|
473
601
|
if (!fills.length)
|
|
474
602
|
return;
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
603
|
+
if (fills.length === 1) {
|
|
604
|
+
const only = fills[0];
|
|
605
|
+
if (only.type === "SOLID" && only.color) {
|
|
606
|
+
css["background-color"] = (0, color_1.figmaColorToCss)(only.color, only.opacity ?? 1);
|
|
607
|
+
return;
|
|
608
|
+
}
|
|
609
|
+
if (only.type === "IMAGE" && asset?.kind === "image") {
|
|
610
|
+
css["background-image"] = `url(${exports.IMAGE_FILL_TOKEN})`;
|
|
611
|
+
this.applyImageSizing(only, css);
|
|
612
|
+
return;
|
|
613
|
+
}
|
|
614
|
+
}
|
|
615
|
+
const layers = [];
|
|
616
|
+
let hasImage = false;
|
|
617
|
+
for (let i = fills.length - 1; i >= 0; i--) {
|
|
618
|
+
const paint = fills[i];
|
|
619
|
+
if (paint.type === "IMAGE") {
|
|
620
|
+
if (asset?.kind !== "image")
|
|
621
|
+
continue;
|
|
622
|
+
layers.push(`url(${exports.IMAGE_FILL_TOKEN})`);
|
|
623
|
+
hasImage = true;
|
|
624
|
+
continue;
|
|
625
|
+
}
|
|
626
|
+
const layer = this.paintToLayer(paint);
|
|
627
|
+
if (layer)
|
|
628
|
+
layers.push(layer);
|
|
629
|
+
}
|
|
630
|
+
if (!layers.length)
|
|
631
|
+
return;
|
|
632
|
+
css["background-image"] = layers.join(", ");
|
|
633
|
+
if (hasImage) {
|
|
483
634
|
css["background-size"] = "cover";
|
|
484
635
|
css["background-position"] = "center";
|
|
485
636
|
}
|
|
486
637
|
}
|
|
487
|
-
|
|
638
|
+
/**
|
|
639
|
+
* Translate a Figma IMAGE paint's fit (scaleMode + crop transform) into CSS
|
|
640
|
+
* background sizing. Previously we always emitted cover/center, which only
|
|
641
|
+
* matches FILL-without-crop; FIT/TILE/STRETCH and cropped fills rendered the
|
|
642
|
+
* wrong region/scale.
|
|
643
|
+
*/
|
|
644
|
+
applyImageSizing(paint, css) {
|
|
645
|
+
const pct = (n) => `${Number(n.toFixed(3))}%`;
|
|
646
|
+
const m = paint.imageTransform;
|
|
647
|
+
if (m && m.length >= 2 && m[0].length >= 3) {
|
|
648
|
+
const sx = m[0][0];
|
|
649
|
+
const sy = m[1][1];
|
|
650
|
+
const tx = m[0][2];
|
|
651
|
+
const ty = m[1][2];
|
|
652
|
+
if (sx > 0 && sy > 0 && sx < 1 && sy < 1) {
|
|
653
|
+
css["background-size"] = `${pct(100 / sx)} ${pct(100 / sy)}`;
|
|
654
|
+
css["background-position"] = `${pct(tx / (1 - sx) * 100)} ${pct(ty / (1 - sy) * 100)}`;
|
|
655
|
+
css["background-repeat"] = "no-repeat";
|
|
656
|
+
return;
|
|
657
|
+
}
|
|
658
|
+
}
|
|
659
|
+
switch (paint.scaleMode) {
|
|
660
|
+
case "FIT":
|
|
661
|
+
css["background-size"] = "contain";
|
|
662
|
+
css["background-position"] = "center";
|
|
663
|
+
css["background-repeat"] = "no-repeat";
|
|
664
|
+
break;
|
|
665
|
+
case "STRETCH":
|
|
666
|
+
css["background-size"] = "100% 100%";
|
|
667
|
+
break;
|
|
668
|
+
case "TILE":
|
|
669
|
+
css["background-repeat"] = "repeat";
|
|
670
|
+
break;
|
|
671
|
+
case "FILL":
|
|
672
|
+
default:
|
|
673
|
+
css["background-size"] = "cover";
|
|
674
|
+
css["background-position"] = "center";
|
|
675
|
+
break;
|
|
676
|
+
}
|
|
677
|
+
}
|
|
678
|
+
/** A single Figma paint as one CSS `background-image` layer (or null if it
|
|
679
|
+
* carries no renderable color, e.g. an image handled separately). */
|
|
680
|
+
paintToLayer(paint) {
|
|
681
|
+
if (paint.type === "SOLID" && paint.color) {
|
|
682
|
+
const c = (0, color_1.figmaColorToCss)(paint.color, paint.opacity ?? 1);
|
|
683
|
+
return `linear-gradient(${c}, ${c})`;
|
|
684
|
+
}
|
|
685
|
+
if (paint.type === "GRADIENT_LINEAR")
|
|
686
|
+
return this.linearGradient(paint);
|
|
687
|
+
if (paint.type === "GRADIENT_RADIAL")
|
|
688
|
+
return this.radialGradient(paint);
|
|
689
|
+
if (paint.type === "GRADIENT_ANGULAR")
|
|
690
|
+
return this.angularGradient(paint);
|
|
691
|
+
if (paint.type === "GRADIENT_DIAMOND")
|
|
692
|
+
return this.radialGradient(paint);
|
|
693
|
+
return null;
|
|
694
|
+
}
|
|
695
|
+
/** Comma-joined `color pos%` stops shared by every gradient kind. */
|
|
696
|
+
gradientStops(paint) {
|
|
488
697
|
const stops = paint.gradientStops ?? [];
|
|
489
698
|
if (stops.length < 2)
|
|
490
699
|
return null;
|
|
700
|
+
return stops.map((s) => `${(0, color_1.figmaColorToCss)(s.color, paint.opacity ?? 1)} ${Math.round(s.position * 100)}%`).join(", ");
|
|
701
|
+
}
|
|
702
|
+
linearGradient(paint) {
|
|
703
|
+
const stopStr = this.gradientStops(paint);
|
|
704
|
+
if (!stopStr)
|
|
705
|
+
return null;
|
|
491
706
|
let angle = 180;
|
|
492
707
|
const handles = paint.gradientHandlePositions;
|
|
493
708
|
if (handles && handles.length >= 2) {
|
|
@@ -495,19 +710,101 @@ var require_style_ir = __commonJS({
|
|
|
495
710
|
const dy = handles[1].y - handles[0].y;
|
|
496
711
|
angle = Math.round(Math.atan2(dy, dx) * 180 / Math.PI + 90);
|
|
497
712
|
}
|
|
498
|
-
const stopStr = stops.map((s) => `${(0, color_1.figmaColorToCss)(s.color, paint.opacity ?? 1)} ${Math.round(s.position * 100)}%`).join(", ");
|
|
499
713
|
return `linear-gradient(${angle}deg, ${stopStr})`;
|
|
500
714
|
}
|
|
715
|
+
/**
|
|
716
|
+
* Radial gradient. Figma's handles are normalized box coordinates: [0] is the
|
|
717
|
+
* center, [1] the end of one radius, [2] the end of the perpendicular radius.
|
|
718
|
+
* We map the center to a percentage position and each radius to a percentage
|
|
719
|
+
* of the box (an axis-aligned ellipse — CSS can't rotate a radial gradient, so
|
|
720
|
+
* a rotated Figma radial is approximated). Falls back to a centered circle.
|
|
721
|
+
*/
|
|
722
|
+
radialGradient(paint) {
|
|
723
|
+
const stopStr = this.gradientStops(paint);
|
|
724
|
+
if (!stopStr)
|
|
725
|
+
return null;
|
|
726
|
+
const h = paint.gradientHandlePositions;
|
|
727
|
+
if (!h || h.length < 3) {
|
|
728
|
+
return `radial-gradient(circle at center, ${stopStr})`;
|
|
729
|
+
}
|
|
730
|
+
const pct = (n) => `${Number((n * 100).toFixed(2))}%`;
|
|
731
|
+
const cx = h[0].x;
|
|
732
|
+
const cy = h[0].y;
|
|
733
|
+
const rx = Math.hypot(h[1].x - cx, h[1].y - cy);
|
|
734
|
+
const ry = Math.hypot(h[2].x - cx, h[2].y - cy);
|
|
735
|
+
return `radial-gradient(ellipse ${pct(rx)} ${pct(ry)} at ${pct(cx)} ${pct(cy)}, ${stopStr})`;
|
|
736
|
+
}
|
|
737
|
+
/**
|
|
738
|
+
* Angular (conic) gradient. Handle [0] is the center and [0]->[1] sets the
|
|
739
|
+
* start angle. CSS conic accepts percentage stops, matching Figma's 0..1.
|
|
740
|
+
*/
|
|
741
|
+
angularGradient(paint) {
|
|
742
|
+
const stopStr = this.gradientStops(paint);
|
|
743
|
+
if (!stopStr)
|
|
744
|
+
return null;
|
|
745
|
+
let angle = 0;
|
|
746
|
+
let cx = 0.5;
|
|
747
|
+
let cy = 0.5;
|
|
748
|
+
const h = paint.gradientHandlePositions;
|
|
749
|
+
if (h && h.length >= 2) {
|
|
750
|
+
cx = h[0].x;
|
|
751
|
+
cy = h[0].y;
|
|
752
|
+
angle = Math.round(Math.atan2(h[1].y - cy, h[1].x - cx) * 180 / Math.PI + 90);
|
|
753
|
+
}
|
|
754
|
+
const pct = (n) => `${Number((n * 100).toFixed(2))}%`;
|
|
755
|
+
return `conic-gradient(from ${angle}deg at ${pct(cx)} ${pct(cy)}, ${stopStr})`;
|
|
756
|
+
}
|
|
501
757
|
applyStroke(node, css) {
|
|
502
758
|
const strokes = this.visiblePaints(node.strokes);
|
|
503
|
-
if (!strokes.length
|
|
759
|
+
if (!strokes.length)
|
|
504
760
|
return;
|
|
505
761
|
const paint = strokes[strokes.length - 1];
|
|
506
|
-
if (paint.type
|
|
507
|
-
|
|
762
|
+
if (paint.type !== "SOLID" || !paint.color)
|
|
763
|
+
return;
|
|
764
|
+
const color = (0, color_1.figmaColorToCss)(paint.color, paint.opacity ?? 1);
|
|
765
|
+
if (node.type === "LINE") {
|
|
766
|
+
if (!node.strokeWeight)
|
|
767
|
+
return;
|
|
768
|
+
const box = node.absoluteBoundingBox;
|
|
769
|
+
const vertical = !!box && box.height > box.width;
|
|
770
|
+
css[vertical ? "border-left" : "border-top"] = `${(0, color_1.px)(node.strokeWeight)} solid ${color}`;
|
|
771
|
+
return;
|
|
772
|
+
}
|
|
773
|
+
const ind = node.individualStrokeWeights;
|
|
774
|
+
if (ind && (ind.top !== ind.right || ind.right !== ind.bottom || ind.bottom !== ind.left)) {
|
|
775
|
+
const side = (w2, name) => {
|
|
776
|
+
if (w2 > 0)
|
|
777
|
+
css[`border-${name}`] = `${(0, color_1.px)(w2)} solid ${color}`;
|
|
778
|
+
};
|
|
779
|
+
side(ind.top, "top");
|
|
780
|
+
side(ind.right, "right");
|
|
781
|
+
side(ind.bottom, "bottom");
|
|
782
|
+
side(ind.left, "left");
|
|
783
|
+
return;
|
|
784
|
+
}
|
|
785
|
+
if (!node.strokeWeight)
|
|
786
|
+
return;
|
|
787
|
+
const w = (0, color_1.px)(node.strokeWeight);
|
|
788
|
+
switch (node.strokeAlign) {
|
|
789
|
+
case "OUTSIDE":
|
|
790
|
+
css.outline = `${w} solid ${color}`;
|
|
791
|
+
css["outline-offset"] = "0px";
|
|
792
|
+
break;
|
|
793
|
+
case "CENTER":
|
|
794
|
+
css.outline = `${w} solid ${color}`;
|
|
795
|
+
css["outline-offset"] = `${(0, color_1.px)(-(node.strokeWeight / 2))}`;
|
|
796
|
+
break;
|
|
797
|
+
case "INSIDE":
|
|
798
|
+
default:
|
|
799
|
+
css.border = `${w} solid ${color}`;
|
|
800
|
+
break;
|
|
508
801
|
}
|
|
509
802
|
}
|
|
510
803
|
applyRadius(node, css) {
|
|
804
|
+
if (node.type === "ELLIPSE") {
|
|
805
|
+
css["border-radius"] = "50%";
|
|
806
|
+
return;
|
|
807
|
+
}
|
|
511
808
|
if (node.rectangleCornerRadii) {
|
|
512
809
|
const [tl, tr, br, bl] = node.rectangleCornerRadii;
|
|
513
810
|
if (tl || tr || br || bl) {
|
|
@@ -581,25 +878,163 @@ var require_style_ir = __commonJS({
|
|
|
581
878
|
css["white-space"] = "pre";
|
|
582
879
|
else if (s.textAutoResize === "HEIGHT")
|
|
583
880
|
css["white-space"] = "pre-wrap";
|
|
881
|
+
if (s.textAlignVertical === "CENTER" || s.textAlignVertical === "BOTTOM") {
|
|
882
|
+
css.display = "flex";
|
|
883
|
+
css["flex-direction"] = "column";
|
|
884
|
+
css["justify-content"] = s.textAlignVertical === "CENTER" ? "center" : "flex-end";
|
|
885
|
+
}
|
|
584
886
|
}
|
|
585
887
|
const fills = this.visiblePaints(node.fills);
|
|
586
888
|
const paint = fills[fills.length - 1];
|
|
587
889
|
if (paint?.type === "SOLID" && paint.color) {
|
|
588
890
|
css.color = (0, color_1.figmaColorToCss)(paint.color, paint.opacity ?? 1);
|
|
891
|
+
} else if (paint) {
|
|
892
|
+
const layer = this.paintToLayer(paint);
|
|
893
|
+
if (layer) {
|
|
894
|
+
css["background-image"] = layer;
|
|
895
|
+
css["-webkit-background-clip"] = "text";
|
|
896
|
+
css["background-clip"] = "text";
|
|
897
|
+
css.color = "transparent";
|
|
898
|
+
}
|
|
589
899
|
}
|
|
590
900
|
}
|
|
901
|
+
/** Map a Figma layer blend mode to CSS `mix-blend-mode` (no-op for NORMAL). */
|
|
902
|
+
applyBlend(node, css) {
|
|
903
|
+
const bm = node.blendMode;
|
|
904
|
+
if (!bm || bm === "NORMAL" || bm === "PASS_THROUGH")
|
|
905
|
+
return;
|
|
906
|
+
const css_ = bm.toLowerCase().replace(/_/g, "-");
|
|
907
|
+
const allowed = /* @__PURE__ */ new Set([
|
|
908
|
+
"multiply",
|
|
909
|
+
"screen",
|
|
910
|
+
"overlay",
|
|
911
|
+
"darken",
|
|
912
|
+
"lighten",
|
|
913
|
+
"color-dodge",
|
|
914
|
+
"color-burn",
|
|
915
|
+
"hard-light",
|
|
916
|
+
"soft-light",
|
|
917
|
+
"difference",
|
|
918
|
+
"exclusion",
|
|
919
|
+
"hue",
|
|
920
|
+
"saturation",
|
|
921
|
+
"color",
|
|
922
|
+
"luminosity"
|
|
923
|
+
]);
|
|
924
|
+
if (allowed.has(css_))
|
|
925
|
+
css["mix-blend-mode"] = css_;
|
|
926
|
+
}
|
|
591
927
|
// ---- assets -------------------------------------------------------------
|
|
592
928
|
detectAsset(node) {
|
|
929
|
+
const imagePaint = [...this.visiblePaints(node.fills)].reverse().find((p) => p.type === "IMAGE" && p.imageRef);
|
|
593
930
|
const vectorTypes = ["VECTOR", "STAR", "LINE", "ELLIPSE", "REGULAR_POLYGON", "BOOLEAN_OPERATION"];
|
|
594
|
-
if (vectorTypes.includes(node.type))
|
|
931
|
+
if (vectorTypes.includes(node.type)) {
|
|
932
|
+
if (node.type === "ELLIPSE" && imagePaint) {
|
|
933
|
+
return { kind: "image", imageRef: imagePaint.imageRef };
|
|
934
|
+
}
|
|
935
|
+
if (this.isCssRenderableVector(node))
|
|
936
|
+
return void 0;
|
|
595
937
|
return { kind: "vector" };
|
|
596
|
-
|
|
938
|
+
}
|
|
597
939
|
if (imagePaint)
|
|
598
940
|
return { kind: "image", imageRef: imagePaint.imageRef };
|
|
941
|
+
if (this.isVectorOnlyContainer(node))
|
|
942
|
+
return { kind: "vector" };
|
|
599
943
|
return void 0;
|
|
600
944
|
}
|
|
945
|
+
/**
|
|
946
|
+
* Whether a vector-typed node is faithfully renderable with CSS, so it can
|
|
947
|
+
* skip server-side rasterization.
|
|
948
|
+
*
|
|
949
|
+
* - ELLIPSE / LINE encode their geometry in the node type, so CSS always
|
|
950
|
+
* reproduces them (circle/oval via border-radius:50%, line via a border).
|
|
951
|
+
* - VECTOR / BOOLEAN_OPERATION have arbitrary geometry the IR can't see, so
|
|
952
|
+
* they only qualify with a positive "rounded rectangle" signal (corner
|
|
953
|
+
* radius or a background blur — i.e. a panel) plus at least one paint we can
|
|
954
|
+
* render (a solid/gradient fill or a stroke-only outline).
|
|
955
|
+
*
|
|
956
|
+
* A rotated node never qualifies: the IR emits no transform, so the baked
|
|
957
|
+
* raster (which already carries the rotation) is the faithful fallback.
|
|
958
|
+
*/
|
|
959
|
+
isCssRenderableVector(node) {
|
|
960
|
+
if (this.hasRotation(node))
|
|
961
|
+
return false;
|
|
962
|
+
const fills = this.visiblePaints(node.fills);
|
|
963
|
+
if (fills.some((p) => p.type === "IMAGE"))
|
|
964
|
+
return false;
|
|
965
|
+
if (node.type === "ELLIPSE" || node.type === "LINE")
|
|
966
|
+
return true;
|
|
967
|
+
if (node.type === "VECTOR" || node.type === "BOOLEAN_OPERATION") {
|
|
968
|
+
const hasPaintableFill = fills.some((p) => p.type === "SOLID" || p.type === "GRADIENT_LINEAR");
|
|
969
|
+
if (!hasPaintableFill)
|
|
970
|
+
return false;
|
|
971
|
+
const hasRadius = (node.cornerRadius ?? 0) > 0 || (node.rectangleCornerRadii?.some((r) => r > 0) ?? false);
|
|
972
|
+
const hasBackgroundBlur = (node.effects ?? []).some((e) => e.type === "BACKGROUND_BLUR" && e.visible !== false);
|
|
973
|
+
return hasRadius || hasBackgroundBlur;
|
|
974
|
+
}
|
|
975
|
+
return false;
|
|
976
|
+
}
|
|
977
|
+
/**
|
|
978
|
+
* Whether a container should collapse into a single rendered SVG: its entire
|
|
979
|
+
* visible subtree is vector paths (no text, no image fills, no plain boxes)
|
|
980
|
+
* AND at least one of those paths actually needs rasterizing (a subtree of
|
|
981
|
+
* only CSS-renderable shapes is cheaper kept as elements).
|
|
982
|
+
*/
|
|
983
|
+
isVectorOnlyContainer(node) {
|
|
984
|
+
if (!_StyleIr.CONTAINER_TYPES.includes(node.type))
|
|
985
|
+
return false;
|
|
986
|
+
if (!node.absoluteBoundingBox)
|
|
987
|
+
return false;
|
|
988
|
+
const isAutoLayout = node.layoutMode === "HORIZONTAL" || node.layoutMode === "VERTICAL";
|
|
989
|
+
const childCount = (node.children ?? []).filter((c) => c.visible !== false).length;
|
|
990
|
+
if (isAutoLayout && childCount < _StyleIr.SVG_COLLAPSE_AUTOLAYOUT_THRESHOLD)
|
|
991
|
+
return false;
|
|
992
|
+
const scan = this.collapseScan(node);
|
|
993
|
+
return scan.pure && scan.hasRaster;
|
|
994
|
+
}
|
|
995
|
+
/**
|
|
996
|
+
* Classify a subtree for the collapse decision. `pure` is true only when every
|
|
997
|
+
* visible node is a vector shape; `hasRaster` is true when at least one of
|
|
998
|
+
* them would hit /v1/images (i.e. isn't reproducible as a CSS box).
|
|
999
|
+
*/
|
|
1000
|
+
collapseScan(node) {
|
|
1001
|
+
const NOT_PURE = { pure: false, hasRaster: false };
|
|
1002
|
+
if (node.visible === false)
|
|
1003
|
+
return { pure: true, hasRaster: false };
|
|
1004
|
+
if (node.type === "TEXT")
|
|
1005
|
+
return NOT_PURE;
|
|
1006
|
+
const hasImageFill = this.visiblePaints(node.fills).some((p) => p.type === "IMAGE" && p.imageRef);
|
|
1007
|
+
if (hasImageFill)
|
|
1008
|
+
return NOT_PURE;
|
|
1009
|
+
const vectorTypes = ["VECTOR", "STAR", "LINE", "ELLIPSE", "REGULAR_POLYGON", "BOOLEAN_OPERATION"];
|
|
1010
|
+
if (vectorTypes.includes(node.type)) {
|
|
1011
|
+
return { pure: true, hasRaster: !this.isCssRenderableVector(node) };
|
|
1012
|
+
}
|
|
1013
|
+
if (_StyleIr.CONTAINER_TYPES.includes(node.type)) {
|
|
1014
|
+
const kids = (node.children ?? []).filter((c) => c.visible !== false);
|
|
1015
|
+
if (!kids.length)
|
|
1016
|
+
return NOT_PURE;
|
|
1017
|
+
let hasRaster = false;
|
|
1018
|
+
for (const kid of kids) {
|
|
1019
|
+
const r = this.collapseScan(kid);
|
|
1020
|
+
if (!r.pure)
|
|
1021
|
+
return NOT_PURE;
|
|
1022
|
+
hasRaster = hasRaster || r.hasRaster;
|
|
1023
|
+
}
|
|
1024
|
+
return { pure: true, hasRaster };
|
|
1025
|
+
}
|
|
1026
|
+
return NOT_PURE;
|
|
1027
|
+
}
|
|
1028
|
+
/** True when the node is rotated beyond a tiny floating-point tolerance
|
|
1029
|
+
* (~0.5°). Figma reports rotation in radians; axis-aligned nodes carry
|
|
1030
|
+
* values like 8.7e-8 that must read as "not rotated". */
|
|
1031
|
+
hasRotation(node) {
|
|
1032
|
+
return typeof node.rotation === "number" && Math.abs(node.rotation) > 0.01;
|
|
1033
|
+
}
|
|
601
1034
|
};
|
|
602
1035
|
exports.StyleIr = StyleIr;
|
|
1036
|
+
StyleIr.CONTAINER_TYPES = ["GROUP", "FRAME", "INSTANCE", "COMPONENT"];
|
|
1037
|
+
StyleIr.SVG_COLLAPSE_AUTOLAYOUT_THRESHOLD = 10;
|
|
603
1038
|
}
|
|
604
1039
|
});
|
|
605
1040
|
|
|
@@ -612,6 +1047,111 @@ var require_tailwind_mapper = __commonJS({
|
|
|
612
1047
|
function arb(value) {
|
|
613
1048
|
return value.replace(/\s+/g, "_");
|
|
614
1049
|
}
|
|
1050
|
+
var SPACING = [
|
|
1051
|
+
["0", 0],
|
|
1052
|
+
["px", 1],
|
|
1053
|
+
["0.5", 2],
|
|
1054
|
+
["1", 4],
|
|
1055
|
+
["1.5", 6],
|
|
1056
|
+
["2", 8],
|
|
1057
|
+
["2.5", 10],
|
|
1058
|
+
["3", 12],
|
|
1059
|
+
["3.5", 14],
|
|
1060
|
+
["4", 16],
|
|
1061
|
+
["5", 20],
|
|
1062
|
+
["6", 24],
|
|
1063
|
+
["7", 28],
|
|
1064
|
+
["8", 32],
|
|
1065
|
+
["9", 36],
|
|
1066
|
+
["10", 40],
|
|
1067
|
+
["11", 44],
|
|
1068
|
+
["12", 48],
|
|
1069
|
+
["14", 56],
|
|
1070
|
+
["16", 64],
|
|
1071
|
+
["20", 80],
|
|
1072
|
+
["24", 96],
|
|
1073
|
+
["28", 112],
|
|
1074
|
+
["32", 128],
|
|
1075
|
+
["36", 144],
|
|
1076
|
+
["40", 160],
|
|
1077
|
+
["44", 176],
|
|
1078
|
+
["48", 192],
|
|
1079
|
+
["52", 208],
|
|
1080
|
+
["56", 224],
|
|
1081
|
+
["60", 240],
|
|
1082
|
+
["64", 256],
|
|
1083
|
+
["72", 288],
|
|
1084
|
+
["80", 320],
|
|
1085
|
+
["96", 384]
|
|
1086
|
+
];
|
|
1087
|
+
var RADIUS = [
|
|
1088
|
+
["-none", 0],
|
|
1089
|
+
["-sm", 2],
|
|
1090
|
+
["", 4],
|
|
1091
|
+
["-md", 6],
|
|
1092
|
+
["-lg", 8],
|
|
1093
|
+
["-xl", 12],
|
|
1094
|
+
["-2xl", 16],
|
|
1095
|
+
["-3xl", 24],
|
|
1096
|
+
["-full", 9999]
|
|
1097
|
+
];
|
|
1098
|
+
var FONT_SIZE = [
|
|
1099
|
+
["xs", 12],
|
|
1100
|
+
["sm", 14],
|
|
1101
|
+
["base", 16],
|
|
1102
|
+
["lg", 18],
|
|
1103
|
+
["xl", 20],
|
|
1104
|
+
["2xl", 24],
|
|
1105
|
+
["3xl", 30],
|
|
1106
|
+
["4xl", 36],
|
|
1107
|
+
["5xl", 48],
|
|
1108
|
+
["6xl", 60],
|
|
1109
|
+
["7xl", 72],
|
|
1110
|
+
["8xl", 96],
|
|
1111
|
+
["9xl", 128]
|
|
1112
|
+
];
|
|
1113
|
+
var FONT_WEIGHT = {
|
|
1114
|
+
"100": "thin",
|
|
1115
|
+
"200": "extralight",
|
|
1116
|
+
"300": "light",
|
|
1117
|
+
"400": "normal",
|
|
1118
|
+
"500": "medium",
|
|
1119
|
+
"600": "semibold",
|
|
1120
|
+
"700": "bold",
|
|
1121
|
+
"800": "extrabold",
|
|
1122
|
+
"900": "black"
|
|
1123
|
+
};
|
|
1124
|
+
function pxNum(value) {
|
|
1125
|
+
const m = /^(-?\d*\.?\d+)px$/.exec(value.trim());
|
|
1126
|
+
return m ? parseFloat(m[1]) : null;
|
|
1127
|
+
}
|
|
1128
|
+
function nearest(n, scale) {
|
|
1129
|
+
let best = null;
|
|
1130
|
+
let bestPx = 0;
|
|
1131
|
+
let bestD = Infinity;
|
|
1132
|
+
for (const [tok, px] of scale) {
|
|
1133
|
+
const d = Math.abs(px - n);
|
|
1134
|
+
if (d < bestD) {
|
|
1135
|
+
bestD = d;
|
|
1136
|
+
best = tok;
|
|
1137
|
+
bestPx = px;
|
|
1138
|
+
}
|
|
1139
|
+
}
|
|
1140
|
+
if (best === null)
|
|
1141
|
+
return null;
|
|
1142
|
+
return bestD <= 0.5 ? best : null;
|
|
1143
|
+
}
|
|
1144
|
+
function spacingClass(prefix, value, round) {
|
|
1145
|
+
if (round) {
|
|
1146
|
+
const n = pxNum(value);
|
|
1147
|
+
if (n !== null && n >= 0) {
|
|
1148
|
+
const tok = nearest(n, SPACING);
|
|
1149
|
+
if (tok !== null)
|
|
1150
|
+
return `${prefix}-${tok}`;
|
|
1151
|
+
}
|
|
1152
|
+
}
|
|
1153
|
+
return `${prefix}-[${arb(value)}]`;
|
|
1154
|
+
}
|
|
615
1155
|
var JUSTIFY = {
|
|
616
1156
|
"flex-start": "justify-start",
|
|
617
1157
|
center: "justify-center",
|
|
@@ -630,7 +1170,8 @@ var require_tailwind_mapper = __commonJS({
|
|
|
630
1170
|
right: "text-right",
|
|
631
1171
|
justify: "text-justify"
|
|
632
1172
|
};
|
|
633
|
-
function cssToTailwind(css) {
|
|
1173
|
+
function cssToTailwind(css, opts = {}) {
|
|
1174
|
+
const round = opts.round ?? false;
|
|
634
1175
|
const classes = [];
|
|
635
1176
|
const leftover = {};
|
|
636
1177
|
const push = (c) => {
|
|
@@ -663,16 +1204,16 @@ var require_tailwind_mapper = __commonJS({
|
|
|
663
1204
|
ITEMS[value] ? push(ITEMS[value]) : leftover[prop] = value;
|
|
664
1205
|
break;
|
|
665
1206
|
case "gap":
|
|
666
|
-
push(
|
|
1207
|
+
push(spacingClass("gap", value, round));
|
|
667
1208
|
break;
|
|
668
1209
|
case "padding":
|
|
669
|
-
applyPadding(value, push, leftover);
|
|
1210
|
+
applyPadding(value, push, leftover, round);
|
|
670
1211
|
break;
|
|
671
1212
|
case "width":
|
|
672
|
-
push(
|
|
1213
|
+
push(spacingClass("w", value, round));
|
|
673
1214
|
break;
|
|
674
1215
|
case "height":
|
|
675
|
-
push(
|
|
1216
|
+
push(spacingClass("h", value, round));
|
|
676
1217
|
break;
|
|
677
1218
|
case "position":
|
|
678
1219
|
if (value === "absolute")
|
|
@@ -683,10 +1224,10 @@ var require_tailwind_mapper = __commonJS({
|
|
|
683
1224
|
leftover[prop] = value;
|
|
684
1225
|
break;
|
|
685
1226
|
case "left":
|
|
686
|
-
push(
|
|
1227
|
+
push(spacingClass("left", value, round));
|
|
687
1228
|
break;
|
|
688
1229
|
case "top":
|
|
689
|
-
push(
|
|
1230
|
+
push(spacingClass("top", value, round));
|
|
690
1231
|
break;
|
|
691
1232
|
case "overflow":
|
|
692
1233
|
if (value === "hidden")
|
|
@@ -713,7 +1254,14 @@ var require_tailwind_mapper = __commonJS({
|
|
|
713
1254
|
applyBorder(value, push, leftover);
|
|
714
1255
|
break;
|
|
715
1256
|
case "border-radius":
|
|
716
|
-
value.includes(" ")
|
|
1257
|
+
if (value.includes(" ")) {
|
|
1258
|
+
leftover[prop] = value;
|
|
1259
|
+
} else if (round && value === "50%") {
|
|
1260
|
+
push("rounded-full");
|
|
1261
|
+
} else {
|
|
1262
|
+
const tok = round ? snapRadius(value) : null;
|
|
1263
|
+
push(tok !== null ? `rounded${tok}` : `rounded-[${arb(value)}]`);
|
|
1264
|
+
}
|
|
717
1265
|
break;
|
|
718
1266
|
case "box-shadow":
|
|
719
1267
|
push(`shadow-[${arb(value)}]`);
|
|
@@ -731,11 +1279,13 @@ var require_tailwind_mapper = __commonJS({
|
|
|
731
1279
|
case "color":
|
|
732
1280
|
push(`text-[${arb(value)}]`);
|
|
733
1281
|
break;
|
|
734
|
-
case "font-size":
|
|
735
|
-
|
|
1282
|
+
case "font-size": {
|
|
1283
|
+
const tok = round ? nearest(pxNum(value) ?? -1, FONT_SIZE) : null;
|
|
1284
|
+
push(tok ? `text-${tok}` : `text-[${arb(value)}]`);
|
|
736
1285
|
break;
|
|
1286
|
+
}
|
|
737
1287
|
case "font-weight":
|
|
738
|
-
push(`font-[${arb(value)}]`);
|
|
1288
|
+
push(round && FONT_WEIGHT[value] ? `font-${FONT_WEIGHT[value]}` : `font-[${arb(value)}]`);
|
|
739
1289
|
break;
|
|
740
1290
|
case "line-height":
|
|
741
1291
|
push(`leading-[${arb(value)}]`);
|
|
@@ -768,16 +1318,29 @@ var require_tailwind_mapper = __commonJS({
|
|
|
768
1318
|
}
|
|
769
1319
|
return { classes, leftover };
|
|
770
1320
|
}
|
|
771
|
-
function
|
|
1321
|
+
function snapRadius(value) {
|
|
1322
|
+
const n = pxNum(value);
|
|
1323
|
+
if (n === null || n < 0)
|
|
1324
|
+
return null;
|
|
1325
|
+
return nearest(n, RADIUS);
|
|
1326
|
+
}
|
|
1327
|
+
function applyPadding(value, push, leftover, round) {
|
|
772
1328
|
const parts = value.split(/\s+/);
|
|
773
1329
|
if (parts.length === 4) {
|
|
774
1330
|
const [t, r, b, l] = parts;
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
1331
|
+
if (round && t === r && r === b && b === l) {
|
|
1332
|
+
push(spacingClass("p", t, round));
|
|
1333
|
+
} else if (round && t === b && l === r) {
|
|
1334
|
+
push(spacingClass("py", t, round));
|
|
1335
|
+
push(spacingClass("px", r, round));
|
|
1336
|
+
} else {
|
|
1337
|
+
push(spacingClass("pt", t, round));
|
|
1338
|
+
push(spacingClass("pr", r, round));
|
|
1339
|
+
push(spacingClass("pb", b, round));
|
|
1340
|
+
push(spacingClass("pl", l, round));
|
|
1341
|
+
}
|
|
779
1342
|
} else if (parts.length === 1) {
|
|
780
|
-
push(
|
|
1343
|
+
push(spacingClass("p", parts[0], round));
|
|
781
1344
|
} else {
|
|
782
1345
|
leftover.padding = value;
|
|
783
1346
|
}
|
|
@@ -808,6 +1371,7 @@ var require_html_renderer = __commonJS({
|
|
|
808
1371
|
"use strict";
|
|
809
1372
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
810
1373
|
exports.HtmlRenderer = void 0;
|
|
1374
|
+
var style_ir_1 = require_style_ir();
|
|
811
1375
|
var tailwind_mapper_1 = require_tailwind_mapper();
|
|
812
1376
|
function escapeHtml(s) {
|
|
813
1377
|
return s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
|
@@ -815,14 +1379,19 @@ var require_html_renderer = __commonJS({
|
|
|
815
1379
|
function styleString(css) {
|
|
816
1380
|
return Object.entries(css).map(([k, v]) => `${k}: ${v}`).join("; ");
|
|
817
1381
|
}
|
|
1382
|
+
function svgPlaceholder(markup) {
|
|
1383
|
+
return `data:image/svg+xml,${encodeURIComponent(markup)}`;
|
|
1384
|
+
}
|
|
1385
|
+
var IMAGE_PLACEHOLDER = svgPlaceholder("<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100' preserveAspectRatio='xMidYMid slice'><rect width='100' height='100' fill='#e5e7eb'/><circle cx='35' cy='34' r='9' fill='#c4cad3'/><path d='M16 78l22-26 15 15 16-16 17 27z' fill='#c4cad3'/></svg>");
|
|
1386
|
+
var VECTOR_PLACEHOLDER = svgPlaceholder("<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' preserveAspectRatio='xMidYMid meet'><rect width='24' height='24' rx='4' fill='#eef0f3'/><path d='M7 14l3.5-4 2.5 3 2-2.5L18 14z' fill='#b9c0cb'/><circle cx='9' cy='8.5' r='1.6' fill='#b9c0cb'/></svg>");
|
|
818
1387
|
var HtmlRenderer = class _HtmlRenderer {
|
|
819
1388
|
/** Render the Style IR tree into an HTML fragment string. */
|
|
820
1389
|
renderFragment(node, opts = {}) {
|
|
821
|
-
return this.render(node, opts.mode ?? "tailwind", 0);
|
|
1390
|
+
return this.render(node, opts.mode ?? "tailwind", 0, opts.round ?? true);
|
|
822
1391
|
}
|
|
823
1392
|
/** Render a standalone, previewable HTML document (Tailwind Play CDN). */
|
|
824
1393
|
renderDocument(node, opts = {}) {
|
|
825
|
-
const body = this.render(node, opts.mode ?? "tailwind", 2);
|
|
1394
|
+
const body = this.render(node, opts.mode ?? "tailwind", 2, opts.round ?? true);
|
|
826
1395
|
return this.wrapDocument(body, node.name || "Figma Export", this.fontLinks(node));
|
|
827
1396
|
}
|
|
828
1397
|
/** Wrap an arbitrary HTML fragment (e.g. LLM output) into a previewable document. */
|
|
@@ -867,21 +1436,30 @@ ${bodyHtml}
|
|
|
867
1436
|
}
|
|
868
1437
|
return links.join("\n");
|
|
869
1438
|
}
|
|
870
|
-
render(node, mode, depth) {
|
|
1439
|
+
render(node, mode, depth, round) {
|
|
871
1440
|
const indent = " ".repeat(depth);
|
|
872
|
-
const isVectorAsset = node.asset?.kind === "vector"
|
|
873
|
-
const isImageAsset = node.asset?.kind === "image"
|
|
1441
|
+
const isVectorAsset = node.asset?.kind === "vector";
|
|
1442
|
+
const isImageAsset = node.asset?.kind === "image";
|
|
874
1443
|
const extraStyle = {};
|
|
1444
|
+
let baseCss = node.css;
|
|
875
1445
|
if (isImageAsset) {
|
|
876
|
-
|
|
1446
|
+
const imageUrl = node.assetSrc ? `url(${node.assetSrc})` : `url(${IMAGE_PLACEHOLDER})`;
|
|
1447
|
+
const stack = node.css["background-image"];
|
|
1448
|
+
extraStyle["background-image"] = stack ? stack.split(`url(${style_ir_1.IMAGE_FILL_TOKEN})`).join(imageUrl) : imageUrl;
|
|
1449
|
+
if (!node.assetSrc) {
|
|
1450
|
+
extraStyle["background-size"] = "cover";
|
|
1451
|
+
extraStyle["background-position"] = "center";
|
|
1452
|
+
}
|
|
1453
|
+
const { ["background-image"]: _omit, ...rest } = node.css;
|
|
1454
|
+
baseCss = rest;
|
|
877
1455
|
}
|
|
878
1456
|
const attrs = [];
|
|
879
1457
|
if (mode === "inline") {
|
|
880
|
-
const css = { ...
|
|
1458
|
+
const css = { ...baseCss, ...extraStyle };
|
|
881
1459
|
if (Object.keys(css).length)
|
|
882
1460
|
attrs.push(`style="${escapeHtml(styleString(css))}"`);
|
|
883
1461
|
} else {
|
|
884
|
-
const { classes, leftover } = (0, tailwind_mapper_1.cssToTailwind)(
|
|
1462
|
+
const { classes, leftover } = (0, tailwind_mapper_1.cssToTailwind)(baseCss, { round });
|
|
885
1463
|
const style = { ...leftover, ...extraStyle };
|
|
886
1464
|
if (classes.length)
|
|
887
1465
|
attrs.push(`class="${classes.join(" ")}"`);
|
|
@@ -891,7 +1469,15 @@ ${bodyHtml}
|
|
|
891
1469
|
attrs.push(`data-figma-name="${escapeHtml(node.name)}"`);
|
|
892
1470
|
const attrStr = attrs.length ? " " + attrs.join(" ") : "";
|
|
893
1471
|
if (isVectorAsset) {
|
|
894
|
-
|
|
1472
|
+
const src = node.assetSrc ?? VECTOR_PLACEHOLDER;
|
|
1473
|
+
return `${indent}<img${attrStr} src="${src}" alt="${escapeHtml(node.name)}" />`;
|
|
1474
|
+
}
|
|
1475
|
+
if (node.runs && node.runs.length) {
|
|
1476
|
+
const spans = node.runs.map((r) => {
|
|
1477
|
+
const s = Object.keys(r.css).length ? ` style="${escapeHtml(styleString(r.css))}"` : "";
|
|
1478
|
+
return `<span${s}>${escapeHtml(r.text)}</span>`;
|
|
1479
|
+
}).join("");
|
|
1480
|
+
return `${indent}<${node.tag}${attrStr}>${spans}</${node.tag}>`;
|
|
895
1481
|
}
|
|
896
1482
|
if (node.text !== void 0) {
|
|
897
1483
|
return `${indent}<${node.tag}${attrStr}>${escapeHtml(node.text)}</${node.tag}>`;
|
|
@@ -899,7 +1485,7 @@ ${bodyHtml}
|
|
|
899
1485
|
if (!node.children.length) {
|
|
900
1486
|
return `${indent}<${node.tag}${attrStr}></${node.tag}>`;
|
|
901
1487
|
}
|
|
902
|
-
const childHtml = node.children.map((c) => this.render(c, mode, depth + 1)).join("\n");
|
|
1488
|
+
const childHtml = node.children.map((c) => this.render(c, mode, depth + 1, round)).join("\n");
|
|
903
1489
|
return `${indent}<${node.tag}${attrStr}>
|
|
904
1490
|
${childHtml}
|
|
905
1491
|
${indent}</${node.tag}>`;
|
|
@@ -931,45 +1517,213 @@ ${indent}</${node.tag}>`;
|
|
|
931
1517
|
}
|
|
932
1518
|
});
|
|
933
1519
|
|
|
1520
|
+
// ../packages/figma-core/dist/converter/compact-design.js
|
|
1521
|
+
var require_compact_design = __commonJS({
|
|
1522
|
+
"../packages/figma-core/dist/converter/compact-design.js"(exports) {
|
|
1523
|
+
"use strict";
|
|
1524
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
1525
|
+
exports.toCompactDesign = toCompactDesign2;
|
|
1526
|
+
exports.stableStringify = stableStringify;
|
|
1527
|
+
var MIN_TEMPLATE_NODES = 3;
|
|
1528
|
+
var POSITIONAL_KEYS = /* @__PURE__ */ new Set(["left", "top", "width", "height"]);
|
|
1529
|
+
function toCompactDesign2(root) {
|
|
1530
|
+
const styles = {};
|
|
1531
|
+
const styleIds = /* @__PURE__ */ new Map();
|
|
1532
|
+
let styleSeq = 0;
|
|
1533
|
+
const styleRef = (css) => {
|
|
1534
|
+
if (!css || Object.keys(css).length === 0)
|
|
1535
|
+
return void 0;
|
|
1536
|
+
const key = stableStringify(css);
|
|
1537
|
+
let id = styleIds.get(key);
|
|
1538
|
+
if (!id) {
|
|
1539
|
+
id = `s${++styleSeq}`;
|
|
1540
|
+
styleIds.set(key, id);
|
|
1541
|
+
styles[id] = css;
|
|
1542
|
+
}
|
|
1543
|
+
return id;
|
|
1544
|
+
};
|
|
1545
|
+
const hashCount = /* @__PURE__ */ new Map();
|
|
1546
|
+
const build = (node) => {
|
|
1547
|
+
const children = node.children.map(build);
|
|
1548
|
+
const compact = { tag: node.tag };
|
|
1549
|
+
if (node.name)
|
|
1550
|
+
compact.name = node.name;
|
|
1551
|
+
if (node.figmaType && node.figmaType !== "FRAME")
|
|
1552
|
+
compact.type = node.figmaType;
|
|
1553
|
+
const box = {};
|
|
1554
|
+
const rest = {};
|
|
1555
|
+
for (const [k, v] of Object.entries(node.css)) {
|
|
1556
|
+
if (POSITIONAL_KEYS.has(k))
|
|
1557
|
+
box[k] = v;
|
|
1558
|
+
else
|
|
1559
|
+
rest[k] = v;
|
|
1560
|
+
}
|
|
1561
|
+
if (Object.keys(box).length)
|
|
1562
|
+
compact.box = box;
|
|
1563
|
+
const sref = styleRef(rest);
|
|
1564
|
+
if (sref)
|
|
1565
|
+
compact.style = sref;
|
|
1566
|
+
if (node.text !== void 0)
|
|
1567
|
+
compact.text = node.text;
|
|
1568
|
+
if (node.asset)
|
|
1569
|
+
compact.asset = { kind: node.asset.kind };
|
|
1570
|
+
if (children.length)
|
|
1571
|
+
compact.children = children.map((c) => c.compact);
|
|
1572
|
+
const hash = stableStringify({
|
|
1573
|
+
t: compact.tag,
|
|
1574
|
+
ty: compact.type,
|
|
1575
|
+
b: compact.box,
|
|
1576
|
+
s: compact.style,
|
|
1577
|
+
x: compact.text,
|
|
1578
|
+
a: compact.asset?.kind,
|
|
1579
|
+
c: children.map((c) => c.hash)
|
|
1580
|
+
});
|
|
1581
|
+
hashCount.set(hash, (hashCount.get(hash) ?? 0) + 1);
|
|
1582
|
+
const size = 1 + children.reduce((n, c) => n + c.size, 0);
|
|
1583
|
+
return { compact, children, hash, size };
|
|
1584
|
+
};
|
|
1585
|
+
const built = build(root);
|
|
1586
|
+
const elements = {};
|
|
1587
|
+
const elementIds = /* @__PURE__ */ new Map();
|
|
1588
|
+
let elemSeq = 0;
|
|
1589
|
+
const templatize = (b) => {
|
|
1590
|
+
if (b.compact.children) {
|
|
1591
|
+
b.compact.children = b.children.map(templatize);
|
|
1592
|
+
}
|
|
1593
|
+
if (b.size >= MIN_TEMPLATE_NODES && (hashCount.get(b.hash) ?? 0) >= 2) {
|
|
1594
|
+
let id = elementIds.get(b.hash);
|
|
1595
|
+
if (!id) {
|
|
1596
|
+
id = `e${++elemSeq}`;
|
|
1597
|
+
elementIds.set(b.hash, id);
|
|
1598
|
+
elements[id] = b.compact;
|
|
1599
|
+
}
|
|
1600
|
+
return { ref: id };
|
|
1601
|
+
}
|
|
1602
|
+
return b.compact;
|
|
1603
|
+
};
|
|
1604
|
+
const rootCompact = templatize(built);
|
|
1605
|
+
return { name: root.name, globalVars: { styles }, elements, root: rootCompact };
|
|
1606
|
+
}
|
|
1607
|
+
function stableStringify(value) {
|
|
1608
|
+
return JSON.stringify(value, (_k, v) => v && typeof v === "object" && !Array.isArray(v) ? Object.keys(v).sort().reduce((acc, k) => {
|
|
1609
|
+
acc[k] = v[k];
|
|
1610
|
+
return acc;
|
|
1611
|
+
}, {}) : v);
|
|
1612
|
+
}
|
|
1613
|
+
}
|
|
1614
|
+
});
|
|
1615
|
+
|
|
934
1616
|
// ../packages/figma-core/dist/assets/asset-resolver.js
|
|
935
1617
|
var require_asset_resolver = __commonJS({
|
|
936
1618
|
"../packages/figma-core/dist/assets/asset-resolver.js"(exports) {
|
|
937
1619
|
"use strict";
|
|
938
1620
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
939
1621
|
exports.AssetResolver = void 0;
|
|
1622
|
+
var errors_1 = require_errors();
|
|
940
1623
|
var AssetResolver = class {
|
|
941
1624
|
constructor(figma, cache, logger) {
|
|
942
1625
|
this.figma = figma;
|
|
943
1626
|
this.cache = cache;
|
|
944
1627
|
this.logger = logger;
|
|
945
1628
|
this.fetchConcurrency = 8;
|
|
1629
|
+
this.renderBatchSize = 20;
|
|
1630
|
+
this.renderMaxAttempts = 4;
|
|
1631
|
+
this.renderMaxBackoffMs = 4e3;
|
|
946
1632
|
}
|
|
947
1633
|
async resolve(root, fileKey, auth, scale = 2) {
|
|
948
1634
|
const vectors = [];
|
|
949
1635
|
const images = [];
|
|
950
1636
|
this.collect(root, vectors, images);
|
|
951
1637
|
if (!vectors.length && !images.length) {
|
|
952
|
-
return {
|
|
1638
|
+
return {
|
|
1639
|
+
vectors: 0,
|
|
1640
|
+
images: 0,
|
|
1641
|
+
embedded: 0,
|
|
1642
|
+
fromCache: 0,
|
|
1643
|
+
unresolvedVectors: 0,
|
|
1644
|
+
unresolvedImages: 0,
|
|
1645
|
+
rateLimited: false
|
|
1646
|
+
};
|
|
953
1647
|
}
|
|
954
1648
|
let fromCache = 0;
|
|
955
1649
|
fromCache += await this.fillFromCache(vectors, fileKey, "svg", 1);
|
|
956
1650
|
fromCache += await this.fillImageFillsFromCache(images, fileKey);
|
|
957
1651
|
const vMissing = vectors.filter((n) => !n.assetSrc);
|
|
958
|
-
|
|
959
|
-
if (vMissing.length) {
|
|
960
|
-
try {
|
|
961
|
-
svgUrls = await this.figma.renderImages(fileKey, vMissing.map((n) => n.id), "svg", auth);
|
|
962
|
-
await this.retryMissing(vMissing, svgUrls, fileKey, "svg", auth, 1);
|
|
963
|
-
} catch (err) {
|
|
964
|
-
this.logger.warn(`Vector render failed (keeping cached vectors + image fills): ${err.message}`);
|
|
965
|
-
}
|
|
966
|
-
}
|
|
1652
|
+
const { urls: svgUrls, rateLimited } = vMissing.length ? await this.renderVectors(vMissing.map((n) => n.id), fileKey, auth) : { urls: {}, rateLimited: false };
|
|
967
1653
|
const iMissing = images.filter((n) => !n.assetSrc && n.asset?.imageRef);
|
|
968
1654
|
let embedded = 0;
|
|
969
1655
|
embedded += await this.embed(vMissing, svgUrls, "image/svg+xml", fileKey, "svg", 1);
|
|
970
1656
|
embedded += await this.embedImageFills(iMissing, fileKey, auth);
|
|
1657
|
+
const unresolvedVectors = vectors.filter((n) => !n.assetSrc).length;
|
|
1658
|
+
const unresolvedImages = images.filter((n) => !n.assetSrc).length;
|
|
971
1659
|
this.logger.log(`Assets: ${vectors.length} vectors, ${images.length} image fills, ${fromCache} from cache, ${embedded} newly embedded`);
|
|
972
|
-
|
|
1660
|
+
if (unresolvedVectors || unresolvedImages) {
|
|
1661
|
+
this.logger.warn(`Assets unresolved: ${unresolvedVectors}/${vectors.length} vectors, ${unresolvedImages}/${images.length} image fills` + (rateLimited ? " (Figma render endpoint rate-limited; they fall back to placeholders)" : ""));
|
|
1662
|
+
}
|
|
1663
|
+
return {
|
|
1664
|
+
vectors: vectors.length,
|
|
1665
|
+
images: images.length,
|
|
1666
|
+
embedded,
|
|
1667
|
+
fromCache,
|
|
1668
|
+
unresolvedVectors,
|
|
1669
|
+
unresolvedImages,
|
|
1670
|
+
rateLimited
|
|
1671
|
+
};
|
|
1672
|
+
}
|
|
1673
|
+
/**
|
|
1674
|
+
* Render vector node ids via /v1/images in small batches. Each batch is
|
|
1675
|
+
* retried with capped exponential backoff on a 429 and isolated so an
|
|
1676
|
+
* exhausted batch leaves only its own ids unresolved. Returns the merged
|
|
1677
|
+
* id -> url map and whether any batch was rate-limited.
|
|
1678
|
+
*/
|
|
1679
|
+
async renderVectors(ids, fileKey, auth) {
|
|
1680
|
+
const urls = {};
|
|
1681
|
+
let rateLimited = false;
|
|
1682
|
+
for (let i = 0; i < ids.length; i += this.renderBatchSize) {
|
|
1683
|
+
const batch = ids.slice(i, i + this.renderBatchSize);
|
|
1684
|
+
try {
|
|
1685
|
+
const rendered = await this.withRenderRetry(() => this.figma.renderImages(fileKey, batch, "svg", auth), (was429) => {
|
|
1686
|
+
if (was429)
|
|
1687
|
+
rateLimited = true;
|
|
1688
|
+
});
|
|
1689
|
+
for (const [id, url] of Object.entries(rendered)) {
|
|
1690
|
+
if (url)
|
|
1691
|
+
urls[id] = url;
|
|
1692
|
+
}
|
|
1693
|
+
} catch (err) {
|
|
1694
|
+
if (err instanceof errors_1.FigmaApiError && err.status === 429)
|
|
1695
|
+
rateLimited = true;
|
|
1696
|
+
this.logger.warn(`Vector render batch [${i}, ${i + batch.length}) failed after retries: ${err.message}`);
|
|
1697
|
+
}
|
|
1698
|
+
}
|
|
1699
|
+
return { urls, rateLimited };
|
|
1700
|
+
}
|
|
1701
|
+
/**
|
|
1702
|
+
* Run a render call, retrying on a Figma 429 with capped exponential backoff.
|
|
1703
|
+
* The backoff is deliberately capped (`renderMaxBackoffMs`) so a multi-hour
|
|
1704
|
+
* `Retry-After` can never block the request — we exhaust a few quick attempts
|
|
1705
|
+
* and then surface the failure instead of waiting.
|
|
1706
|
+
*/
|
|
1707
|
+
async withRenderRetry(fn, onRateLimit) {
|
|
1708
|
+
let lastErr;
|
|
1709
|
+
for (let attempt = 0; attempt < this.renderMaxAttempts; attempt++) {
|
|
1710
|
+
try {
|
|
1711
|
+
return await fn();
|
|
1712
|
+
} catch (err) {
|
|
1713
|
+
lastErr = err;
|
|
1714
|
+
const is429 = err instanceof errors_1.FigmaApiError && err.status === 429;
|
|
1715
|
+
if (!is429 || attempt === this.renderMaxAttempts - 1)
|
|
1716
|
+
throw err;
|
|
1717
|
+
onRateLimit(true);
|
|
1718
|
+
const backoff = Math.min(this.renderMaxBackoffMs, 500 * 2 ** attempt);
|
|
1719
|
+
this.logger.warn(`Figma render rate-limited; retrying in ${backoff}ms (attempt ${attempt + 1}/${this.renderMaxAttempts})`);
|
|
1720
|
+
await this.sleep(backoff);
|
|
1721
|
+
}
|
|
1722
|
+
}
|
|
1723
|
+
throw lastErr;
|
|
1724
|
+
}
|
|
1725
|
+
sleep(ms) {
|
|
1726
|
+
return new Promise((resolve2) => setTimeout(resolve2, ms));
|
|
973
1727
|
}
|
|
974
1728
|
assetKey(fileKey, id, format, scale) {
|
|
975
1729
|
return `asset:${fileKey}:${id}:${format}:${scale}`;
|
|
@@ -1057,22 +1811,6 @@ var require_asset_resolver = __commonJS({
|
|
|
1057
1811
|
for (const child of node.children)
|
|
1058
1812
|
this.collect(child, vectors, images);
|
|
1059
1813
|
}
|
|
1060
|
-
async retryMissing(nodes, urlMap, fileKey, format, auth, scale) {
|
|
1061
|
-
const missing = nodes.filter((n) => !urlMap[n.id]).map((n) => n.id);
|
|
1062
|
-
if (!missing.length)
|
|
1063
|
-
return;
|
|
1064
|
-
try {
|
|
1065
|
-
const retry = await this.figma.renderImages(fileKey, missing, format, auth, scale);
|
|
1066
|
-
for (const [id, url] of Object.entries(retry)) {
|
|
1067
|
-
if (url)
|
|
1068
|
-
urlMap[id] = url;
|
|
1069
|
-
}
|
|
1070
|
-
const stillMissing = missing.filter((id) => !urlMap[id]).length;
|
|
1071
|
-
this.logger.log(`Retried ${missing.length} missing ${format} assets; ${stillMissing} still unrendered`);
|
|
1072
|
-
} catch (err) {
|
|
1073
|
-
this.logger.warn(`Asset retry (${format}) failed: ${err.message}`);
|
|
1074
|
-
}
|
|
1075
|
-
}
|
|
1076
1814
|
async embed(nodes, urlMap, mime, fileKey, format, scale) {
|
|
1077
1815
|
let count = 0;
|
|
1078
1816
|
for (let i = 0; i < nodes.length; i += this.fetchConcurrency) {
|
|
@@ -1580,16 +2318,22 @@ var require_convert = __commonJS({
|
|
|
1580
2318
|
const node = await this.client.fetchNode(target2.fileKey, target2.nodeId, auth);
|
|
1581
2319
|
const ir = this.styleIr.convert(node);
|
|
1582
2320
|
let assetWarning;
|
|
1583
|
-
if (options.assets
|
|
2321
|
+
if (options.assets === true) {
|
|
1584
2322
|
try {
|
|
1585
|
-
await this.assets.resolve(ir, target2.fileKey, auth, options.assetScale ?? 2);
|
|
2323
|
+
const r = await this.assets.resolve(ir, target2.fileKey, auth, options.assetScale ?? 2);
|
|
2324
|
+
const unresolved = r.unresolvedVectors + r.unresolvedImages;
|
|
2325
|
+
if (unresolved > 0) {
|
|
2326
|
+
assetWarning = `${unresolved} asset(s) could not be rendered (${r.unresolvedVectors} vectors, ${r.unresolvedImages} image fills) and fall back to placeholders` + (r.rateLimited ? ". Figma\u2019s render endpoint is rate-limited \u2014 retry later for full fidelity." : ".");
|
|
2327
|
+
this.logger.warn(assetWarning);
|
|
2328
|
+
}
|
|
1586
2329
|
} catch (err) {
|
|
1587
2330
|
assetWarning = `Assets not fully resolved: ${err.message}`;
|
|
1588
2331
|
this.logger.warn(assetWarning);
|
|
1589
2332
|
}
|
|
1590
2333
|
}
|
|
1591
2334
|
const mode = options.mode ?? "tailwind";
|
|
1592
|
-
|
|
2335
|
+
const round = options.round ?? true;
|
|
2336
|
+
let fragment = this.renderer.renderFragment(ir, { mode, round });
|
|
1593
2337
|
let usedLlm = false;
|
|
1594
2338
|
if (options.llm && this.restructure?.enabled) {
|
|
1595
2339
|
fragment = await this.restructure.restructure(fragment);
|
|
@@ -1695,7 +2439,7 @@ var require_dist = __commonJS({
|
|
|
1695
2439
|
for (var p in m) if (p !== "default" && !Object.prototype.hasOwnProperty.call(exports2, p)) __createBinding(exports2, m, p);
|
|
1696
2440
|
};
|
|
1697
2441
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
1698
|
-
exports.createFigmaCore = exports.countNodes = exports.FigmaConverter = exports.stripHeavyAssets = exports.stripCodeFence = exports.runAgent = exports.RefineAgent = exports.LlmRestructure = exports.ollamaConfigFromEnv = exports.OllamaClient = exports.AssetResolver = exports.px = exports.figmaColorToCss = exports.cssToTailwind = exports.HtmlRenderer = exports.StyleIr = exports.resolveFigmaTarget = exports.normalizeNodeId = exports.parseFigmaUrl = exports.FigmaClient = exports.NoopLogger = exports.ConsoleLogger = exports.DiskCache = exports.MemoryCache = exports.FigmaInputError = exports.FigmaApiError = void 0;
|
|
2442
|
+
exports.createFigmaCore = exports.countNodes = exports.FigmaConverter = exports.stripHeavyAssets = exports.stripCodeFence = exports.runAgent = exports.RefineAgent = exports.LlmRestructure = exports.ollamaConfigFromEnv = exports.OllamaClient = exports.AssetResolver = exports.stableStringify = exports.toCompactDesign = exports.px = exports.figmaColorToCss = exports.cssToTailwind = exports.HtmlRenderer = exports.IMAGE_FILL_TOKEN = exports.StyleIr = exports.resolveFigmaTarget = exports.normalizeNodeId = exports.parseFigmaUrl = exports.FigmaClient = exports.NoopLogger = exports.ConsoleLogger = exports.DiskCache = exports.MemoryCache = exports.FigmaInputError = exports.FigmaApiError = void 0;
|
|
1699
2443
|
var errors_1 = require_errors();
|
|
1700
2444
|
Object.defineProperty(exports, "FigmaApiError", { enumerable: true, get: function() {
|
|
1701
2445
|
return errors_1.FigmaApiError;
|
|
@@ -1735,6 +2479,9 @@ var require_dist = __commonJS({
|
|
|
1735
2479
|
Object.defineProperty(exports, "StyleIr", { enumerable: true, get: function() {
|
|
1736
2480
|
return style_ir_1.StyleIr;
|
|
1737
2481
|
} });
|
|
2482
|
+
Object.defineProperty(exports, "IMAGE_FILL_TOKEN", { enumerable: true, get: function() {
|
|
2483
|
+
return style_ir_1.IMAGE_FILL_TOKEN;
|
|
2484
|
+
} });
|
|
1738
2485
|
var html_renderer_1 = require_html_renderer();
|
|
1739
2486
|
Object.defineProperty(exports, "HtmlRenderer", { enumerable: true, get: function() {
|
|
1740
2487
|
return html_renderer_1.HtmlRenderer;
|
|
@@ -1750,6 +2497,13 @@ var require_dist = __commonJS({
|
|
|
1750
2497
|
Object.defineProperty(exports, "px", { enumerable: true, get: function() {
|
|
1751
2498
|
return color_1.px;
|
|
1752
2499
|
} });
|
|
2500
|
+
var compact_design_1 = require_compact_design();
|
|
2501
|
+
Object.defineProperty(exports, "toCompactDesign", { enumerable: true, get: function() {
|
|
2502
|
+
return compact_design_1.toCompactDesign;
|
|
2503
|
+
} });
|
|
2504
|
+
Object.defineProperty(exports, "stableStringify", { enumerable: true, get: function() {
|
|
2505
|
+
return compact_design_1.stableStringify;
|
|
2506
|
+
} });
|
|
1753
2507
|
var asset_resolver_1 = require_asset_resolver();
|
|
1754
2508
|
Object.defineProperty(exports, "AssetResolver", { enumerable: true, get: function() {
|
|
1755
2509
|
return asset_resolver_1.AssetResolver;
|
|
@@ -1946,7 +2700,9 @@ function remoteExtractStyles(cfg, body) {
|
|
|
1946
2700
|
var INLINE_LIMIT = 1e5;
|
|
1947
2701
|
var core;
|
|
1948
2702
|
var corePat;
|
|
2703
|
+
var coreOverride;
|
|
1949
2704
|
function getCore(pat) {
|
|
2705
|
+
if (coreOverride) return coreOverride;
|
|
1950
2706
|
if (!core || corePat !== pat) {
|
|
1951
2707
|
core = (0, import_core.createFigmaCore)({ figmaToken: pat });
|
|
1952
2708
|
corePat = pat;
|
|
@@ -1976,6 +2732,7 @@ async function convertFigmaToHtml(args, overrides = {}) {
|
|
|
1976
2732
|
document: args.document ?? true,
|
|
1977
2733
|
assets: args.assets,
|
|
1978
2734
|
assetScale: args.assetScale,
|
|
2735
|
+
round: args.round,
|
|
1979
2736
|
llm: args.llm
|
|
1980
2737
|
});
|
|
1981
2738
|
result = { name: r.name, nodeCount: r.nodeCount, mode: r.mode, llm: r.llm, html: r.html };
|
|
@@ -1986,6 +2743,7 @@ async function convertFigmaToHtml(args, overrides = {}) {
|
|
|
1986
2743
|
document: args.document ?? true,
|
|
1987
2744
|
assets: args.assets,
|
|
1988
2745
|
assetScale: args.assetScale,
|
|
2746
|
+
round: args.round,
|
|
1989
2747
|
llm: args.llm
|
|
1990
2748
|
});
|
|
1991
2749
|
result = { name: r.name, nodeCount: r.nodeCount, mode: r.mode, llm: r.llm, html: r.html, previewUrl: r.previewUrl };
|
|
@@ -2013,23 +2771,33 @@ async function getFigmaData(args, overrides = {}) {
|
|
|
2013
2771
|
} else {
|
|
2014
2772
|
ir = await remoteExtractStyles(cfg, target(args));
|
|
2015
2773
|
}
|
|
2016
|
-
const json = JSON.stringify(ir, null, 2);
|
|
2017
2774
|
const nodeCount = countIr(ir);
|
|
2775
|
+
const compact = (0, import_core.toCompactDesign)(ir);
|
|
2776
|
+
const json = JSON.stringify(compact);
|
|
2777
|
+
const legend = [
|
|
2778
|
+
"Compact design format (token-optimized):",
|
|
2779
|
+
"- `globalVars.styles`: id -> CSS declaration map. A node's full CSS = { ...globalVars.styles[node.style], ...node.box }.",
|
|
2780
|
+
"- `node.box`: per-node left/top/width/height (kept inline because unique).",
|
|
2781
|
+
'- `elements`: id -> a subtree that repeats; a node `{ref:"e1"}` is an instance of elements.e1.',
|
|
2782
|
+
"- `root`: the node tree (each node: tag, optional type/name/text/asset/style/box/children)."
|
|
2783
|
+
].join("\n");
|
|
2018
2784
|
if (json.length <= INLINE_LIMIT) {
|
|
2019
|
-
return [
|
|
2785
|
+
return [
|
|
2786
|
+
`Compact design for "${compact.name}" (${nodeCount} nodes, ${describeConfig(cfg)}):`,
|
|
2787
|
+
legend,
|
|
2788
|
+
"",
|
|
2789
|
+
json
|
|
2790
|
+
].join("\n");
|
|
2020
2791
|
}
|
|
2021
|
-
const filePath = await writeOut(cfg.outDir, `${sanitize(
|
|
2792
|
+
const filePath = await writeOut(cfg.outDir, `${sanitize(compact.name)}.design.json`, json);
|
|
2022
2793
|
return [
|
|
2023
|
-
`
|
|
2794
|
+
`Compact design for "${compact.name}" is large (${nodeCount} nodes, ${(json.length / 1024).toFixed(1)} KB) \u2014 written to a file.`,
|
|
2024
2795
|
`- file: ${filePath}`,
|
|
2025
|
-
`-
|
|
2796
|
+
`- ${Object.keys(compact.globalVars.styles).length} shared styles, ${Object.keys(compact.elements).length} repeated-subtree templates`,
|
|
2026
2797
|
"",
|
|
2027
|
-
|
|
2028
|
-
...(ir.children || []).map(
|
|
2029
|
-
(c, i) => ` ${i}. "${c.name}" <${c.tag}> ${c.figmaType} (${(c.children || []).length} children)`
|
|
2030
|
-
),
|
|
2798
|
+
legend,
|
|
2031
2799
|
"",
|
|
2032
|
-
"Read the file for the full tree
|
|
2800
|
+
"Read the file for the full tree."
|
|
2033
2801
|
].join("\n");
|
|
2034
2802
|
}
|
|
2035
2803
|
function countIr(node) {
|
|
@@ -2059,7 +2827,7 @@ function buildServer() {
|
|
|
2059
2827
|
"get_figma_data",
|
|
2060
2828
|
{
|
|
2061
2829
|
title: "Get Figma design data",
|
|
2062
|
-
description: "Fetch
|
|
2830
|
+
description: "Fetch a Figma file/node as a compact, token-efficient design tree for an agent to reason about and generate code from. Styles are deduped into globalVars.styles (nodes carry short `style` refs), per-node position/size is inline as `box`, and repeated subtrees are hoisted into `elements` ({ref}). Small results are returned inline; large ones are written to a JSON file and summarised.",
|
|
2063
2831
|
inputSchema: targetShape
|
|
2064
2832
|
},
|
|
2065
2833
|
safe((a) => getFigmaData(a))
|
|
@@ -2075,6 +2843,9 @@ function buildServer() {
|
|
|
2075
2843
|
document: z.boolean().optional().describe("Emit a full HTML document (default true) vs a fragment."),
|
|
2076
2844
|
assets: z.boolean().optional().describe("Export & inline vectors/images as data URIs. Default false (placeholders shown instead)."),
|
|
2077
2845
|
assetScale: z.number().min(1).max(4).optional().describe("Raster export scale (1-4). Default 2."),
|
|
2846
|
+
round: z.boolean().optional().describe(
|
|
2847
|
+
"Snap on-grid values to idiomatic Tailwind scale tokens (p-4, gap-6, rounded-lg, text-2xl); off-grid values stay exact. Default true. Set false for exact arbitrary values everywhere."
|
|
2848
|
+
),
|
|
2078
2849
|
llm: z.boolean().optional().describe("Run the LLM restructure pass (needs OLLAMA_* config). Default false."),
|
|
2079
2850
|
outFile: z.string().optional().describe("Override the output file name (within the output dir).")
|
|
2080
2851
|
}
|
|
@@ -2218,6 +2989,7 @@ Common flags (convert/data):
|
|
|
2218
2989
|
--node <id> Node id ("1-23" or "1:23")
|
|
2219
2990
|
--mode <m> tailwind | inline (convert)
|
|
2220
2991
|
--assets Render & inline assets (convert; off by default, placeholders shown)
|
|
2992
|
+
--no-round Keep exact arbitrary values (default snaps on-grid values to Tailwind tokens)
|
|
2221
2993
|
--llm Run LLM restructure (convert; needs OLLAMA_* env)
|
|
2222
2994
|
--out <file> Output file name (convert)
|
|
2223
2995
|
|
|
@@ -2239,6 +3011,8 @@ async function main() {
|
|
|
2239
3011
|
...targetFrom(positionals[1], flags),
|
|
2240
3012
|
mode: flags.mode === "inline" ? "inline" : flags.mode === "tailwind" ? "tailwind" : void 0,
|
|
2241
3013
|
assets: flags.assets ? true : flags["no-assets"] ? false : void 0,
|
|
3014
|
+
round: flags["no-round"] ? false : void 0,
|
|
3015
|
+
// default true (in core)
|
|
2242
3016
|
llm: flags.llm === true,
|
|
2243
3017
|
outFile: typeof flags.out === "string" ? flags.out : void 0
|
|
2244
3018
|
});
|