@clovis500c/figma-bridge 1.8.0 → 1.12.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/README.md +45 -10
- package/dist/server.js +1953 -121
- package/package.json +5 -2
- package/plugin/code.js +574 -20
- package/plugin/manifest.json +2 -1
- package/plugin/ui.html +7 -1
package/dist/server.js
CHANGED
|
@@ -47,7 +47,7 @@ var __esm = (fn, res, err) => () => {
|
|
|
47
47
|
var __require = /* @__PURE__ */ createRequire(import.meta.url);
|
|
48
48
|
|
|
49
49
|
// package.json
|
|
50
|
-
var version = "1.
|
|
50
|
+
var version = "1.12.0";
|
|
51
51
|
var init_package = () => {};
|
|
52
52
|
|
|
53
53
|
// node_modules/zod/v3/helpers/util.js
|
|
@@ -24772,6 +24772,7 @@ var init_wrapper = __esm(() => {
|
|
|
24772
24772
|
});
|
|
24773
24773
|
|
|
24774
24774
|
// src/bridge.ts
|
|
24775
|
+
import { AsyncLocalStorage } from "node:async_hooks";
|
|
24775
24776
|
import { randomUUID } from "node:crypto";
|
|
24776
24777
|
import { createServer } from "node:http";
|
|
24777
24778
|
import { connect } from "node:net";
|
|
@@ -24998,6 +24999,10 @@ class Hub {
|
|
|
24998
24999
|
ws.data.session.fileName = msg.fileName;
|
|
24999
25000
|
if (typeof msg.page === "string")
|
|
25000
25001
|
ws.data.session.page = msg.page;
|
|
25002
|
+
if (typeof msg.editorType === "string")
|
|
25003
|
+
ws.data.session.editorType = msg.editorType;
|
|
25004
|
+
if (typeof msg.pageId === "string")
|
|
25005
|
+
ws.data.session.pageId = msg.pageId;
|
|
25001
25006
|
}
|
|
25002
25007
|
} else if (ws.data.role === "agent") {
|
|
25003
25008
|
if (msg.t === "req")
|
|
@@ -25025,6 +25030,9 @@ class Hub {
|
|
|
25025
25030
|
page: String(s.page || ""),
|
|
25026
25031
|
channel,
|
|
25027
25032
|
version: msg.version,
|
|
25033
|
+
editorType: typeof s.editorType === "string" ? s.editorType : undefined,
|
|
25034
|
+
fileKey: typeof s.fileKey === "string" && s.fileKey ? s.fileKey : undefined,
|
|
25035
|
+
pageId: typeof s.pageId === "string" ? s.pageId : undefined,
|
|
25028
25036
|
connectedAt: Date.now()
|
|
25029
25037
|
};
|
|
25030
25038
|
const previous = this.plugins.get(session.id);
|
|
@@ -25216,7 +25224,7 @@ class Bridge {
|
|
|
25216
25224
|
throw new BridgeError(this.lastError ?? `The bridge could not open or join port ${this.opts.port}.`, "OFFLINE");
|
|
25217
25225
|
}
|
|
25218
25226
|
try {
|
|
25219
|
-
return await this.send(method, params, timeoutMs);
|
|
25227
|
+
return await this.send(method, params, timeoutMs, await this.selectionFor(method));
|
|
25220
25228
|
} catch (e) {
|
|
25221
25229
|
const retryable = e instanceof BridgeError && (e.code === "NO_SESSION" || e.code === "OFFLINE");
|
|
25222
25230
|
if (!retryable || Date.now() > deadline)
|
|
@@ -25225,10 +25233,17 @@ class Bridge {
|
|
|
25225
25233
|
}
|
|
25226
25234
|
}
|
|
25227
25235
|
}
|
|
25228
|
-
|
|
25236
|
+
async selectionFor(method) {
|
|
25237
|
+
const file = method === "$sessions" ? undefined : callTarget.getStore()?.file;
|
|
25238
|
+
if (!file)
|
|
25239
|
+
return this.selection;
|
|
25240
|
+
const s = await this.find(file);
|
|
25241
|
+
return { id: s.id, fileName: s.fileName };
|
|
25242
|
+
}
|
|
25243
|
+
send(method, params, timeoutMs, sel) {
|
|
25229
25244
|
return new Promise((resolve, reject) => {
|
|
25230
25245
|
const id = randomUUID();
|
|
25231
|
-
const msg = { t: "req", id, method, params, sel
|
|
25246
|
+
const msg = { t: "req", id, method, params, sel, timeoutMs };
|
|
25232
25247
|
const timer = setTimeout(() => {
|
|
25233
25248
|
this.pending.delete(id);
|
|
25234
25249
|
reject(new BridgeError(`${method} got no answer after ${Math.round(timeoutMs / 1000)} s. Figma may be frozen by a long synchronous script.`, "TIMEOUT"));
|
|
@@ -25263,6 +25278,18 @@ class Bridge {
|
|
|
25263
25278
|
return this.request("$sessions", {}, 5000, 1500);
|
|
25264
25279
|
}
|
|
25265
25280
|
async select(name) {
|
|
25281
|
+
const hit = await this.find(name);
|
|
25282
|
+
this.selection = { id: hit.id, fileName: hit.fileName };
|
|
25283
|
+
return hit;
|
|
25284
|
+
}
|
|
25285
|
+
async current() {
|
|
25286
|
+
const file = callTarget.getStore()?.file;
|
|
25287
|
+
if (file)
|
|
25288
|
+
return this.find(file);
|
|
25289
|
+
const list = await this.sessions().catch(() => []);
|
|
25290
|
+
return list.find((s) => s.id === this.selection.id) ?? (list.length === 1 ? list[0] : null);
|
|
25291
|
+
}
|
|
25292
|
+
async find(name) {
|
|
25266
25293
|
const list = await this.sessions();
|
|
25267
25294
|
const q = name.trim().toLowerCase();
|
|
25268
25295
|
const hit = list.find((s) => s.id === name) ?? list.find((s) => s.fileName.toLowerCase() === q) ?? (list.filter((s) => s.fileName.toLowerCase().includes(q)).length === 1 ? list.find((s) => s.fileName.toLowerCase().includes(q)) : undefined);
|
|
@@ -25270,15 +25297,15 @@ class Bridge {
|
|
|
25270
25297
|
const names = list.map((s) => `"${s.fileName}"`).join(", ") || "none";
|
|
25271
25298
|
throw new BridgeError(`No unique connected file matches "${name}". Connected: ${names}.`, "NOT_FOUND");
|
|
25272
25299
|
}
|
|
25273
|
-
this.selection = { id: hit.id, fileName: hit.fileName };
|
|
25274
25300
|
return hit;
|
|
25275
25301
|
}
|
|
25276
25302
|
}
|
|
25277
|
-
var BRIDGE_NAME = "figma-bridge", PROTOCOL_VERSION = 1, CHUNK_CHARS, MAX_MESSAGE_CHARS, PARTIAL_TTL_MS = 60000, INFLIGHT_TTL_MS = 180000, HANDSHAKE_MS = 2500, PING_MS = 20000, IDLE_MS = 120000, BridgeError, mb = (chars) => `${(chars / (1 << 20)).toFixed(1)} MB`, sleep = (ms) => new Promise((r) => setTimeout(r, ms)), NO_PLUGIN_HELP;
|
|
25303
|
+
var BRIDGE_NAME = "figma-bridge", PROTOCOL_VERSION = 1, CHUNK_CHARS, MAX_MESSAGE_CHARS, PARTIAL_TTL_MS = 60000, INFLIGHT_TTL_MS = 180000, HANDSHAKE_MS = 2500, PING_MS = 20000, IDLE_MS = 120000, callTarget, BridgeError, mb = (chars) => `${(chars / (1 << 20)).toFixed(1)} MB`, sleep = (ms) => new Promise((r) => setTimeout(r, ms)), NO_PLUGIN_HELP;
|
|
25278
25304
|
var init_bridge = __esm(() => {
|
|
25279
25305
|
init_wrapper();
|
|
25280
25306
|
CHUNK_CHARS = 1 << 20;
|
|
25281
25307
|
MAX_MESSAGE_CHARS = 256 << 20;
|
|
25308
|
+
callTarget = new AsyncLocalStorage;
|
|
25282
25309
|
BridgeError = class BridgeError extends Error {
|
|
25283
25310
|
code;
|
|
25284
25311
|
details;
|
|
@@ -26799,9 +26826,1647 @@ var init_codegen = __esm(() => {
|
|
|
26799
26826
|
RADII = { 2: "rounded-sm", 4: "rounded", 6: "rounded-md", 8: "rounded-lg", 12: "rounded-xl", 16: "rounded-2xl", 24: "rounded-3xl" };
|
|
26800
26827
|
});
|
|
26801
26828
|
|
|
26829
|
+
// src/diagram.ts
|
|
26830
|
+
function parseDiagram(source) {
|
|
26831
|
+
const lines = source.replace(/%%.*$/gm, "").split(/\n|;/).map((l) => l.trim()).filter(Boolean);
|
|
26832
|
+
const d = { direction: "TB", nodes: [], edges: [], subgraphs: [] };
|
|
26833
|
+
const byId = new Map;
|
|
26834
|
+
const stack = [];
|
|
26835
|
+
const node = (ref) => {
|
|
26836
|
+
const m = /^([A-Za-z0-9_\-.$]+)\s*(.*)$/s.exec(ref.trim());
|
|
26837
|
+
if (!m)
|
|
26838
|
+
return null;
|
|
26839
|
+
const id = m[1];
|
|
26840
|
+
const rest = m[2].trim();
|
|
26841
|
+
let n = byId.get(id);
|
|
26842
|
+
if (!n) {
|
|
26843
|
+
n = { id, label: id, shape: "rounded" };
|
|
26844
|
+
byId.set(id, n);
|
|
26845
|
+
d.nodes.push(n);
|
|
26846
|
+
}
|
|
26847
|
+
if (rest) {
|
|
26848
|
+
for (const [re, shape] of SHAPES) {
|
|
26849
|
+
const s = re.exec(rest);
|
|
26850
|
+
if (s) {
|
|
26851
|
+
n.label = unquote(s[1]);
|
|
26852
|
+
n.shape = shape;
|
|
26853
|
+
break;
|
|
26854
|
+
}
|
|
26855
|
+
}
|
|
26856
|
+
}
|
|
26857
|
+
for (const g of stack)
|
|
26858
|
+
if (!g.nodes.includes(id))
|
|
26859
|
+
g.nodes.push(id);
|
|
26860
|
+
return id;
|
|
26861
|
+
};
|
|
26862
|
+
for (const line of lines) {
|
|
26863
|
+
const header = /^(?:flowchart|graph)\s*(TB|TD|BT|LR|RL)?\b/i.exec(line);
|
|
26864
|
+
if (header) {
|
|
26865
|
+
const dir = (header[1] ?? "TB").toUpperCase();
|
|
26866
|
+
d.direction = dir === "TD" ? "TB" : dir;
|
|
26867
|
+
continue;
|
|
26868
|
+
}
|
|
26869
|
+
const sub = /^subgraph\s+([^\s[]+)(?:\s*\[(.*)\])?\s*(.*)$/i.exec(line);
|
|
26870
|
+
if (sub) {
|
|
26871
|
+
const g = { id: sub[1], label: unquote(sub[2] ?? (sub[3] || sub[1])), nodes: [] };
|
|
26872
|
+
d.subgraphs.push(g);
|
|
26873
|
+
stack.push(g);
|
|
26874
|
+
continue;
|
|
26875
|
+
}
|
|
26876
|
+
if (/^end$/i.test(line)) {
|
|
26877
|
+
stack.pop();
|
|
26878
|
+
continue;
|
|
26879
|
+
}
|
|
26880
|
+
if (/^(direction|classDef|class|style|linkStyle|click)\b/.test(line))
|
|
26881
|
+
continue;
|
|
26882
|
+
const parts = [];
|
|
26883
|
+
const links = [];
|
|
26884
|
+
let rest = line;
|
|
26885
|
+
for (;; ) {
|
|
26886
|
+
const m = EDGE.exec(rest);
|
|
26887
|
+
if (!m) {
|
|
26888
|
+
parts.push(rest);
|
|
26889
|
+
break;
|
|
26890
|
+
}
|
|
26891
|
+
parts.push(rest.slice(0, m.index));
|
|
26892
|
+
let op = m[1];
|
|
26893
|
+
let label = m[2];
|
|
26894
|
+
const inline = /^(--|==|-\.)\s+(.+?)\s+(-->|==>|\.->)$/.exec(op);
|
|
26895
|
+
if (inline) {
|
|
26896
|
+
label = inline[2];
|
|
26897
|
+
op = inline[1] === "--" ? "-->" : inline[1] === "==" ? "==>" : "-.->";
|
|
26898
|
+
}
|
|
26899
|
+
links.push({ op, label: label?.trim() || undefined });
|
|
26900
|
+
rest = rest.slice(m.index + m[0].length);
|
|
26901
|
+
}
|
|
26902
|
+
const groups = parts.map((p) => p.split(/\s+&\s+/).map(node).filter((x) => !!x));
|
|
26903
|
+
links.forEach((l, i) => {
|
|
26904
|
+
for (const from of groups[i] ?? []) {
|
|
26905
|
+
for (const to of groups[i + 1] ?? []) {
|
|
26906
|
+
d.edges.push({ from, to, label: l.label, dashed: l.op.includes("."), thick: l.op.includes("="), arrow: />$/.test(l.op) });
|
|
26907
|
+
}
|
|
26908
|
+
}
|
|
26909
|
+
});
|
|
26910
|
+
}
|
|
26911
|
+
return d;
|
|
26912
|
+
}
|
|
26913
|
+
function layoutDiagram(d, opts = {}) {
|
|
26914
|
+
const size = opts.size ?? defaultSize;
|
|
26915
|
+
const ids = d.nodes.map((n) => n.id);
|
|
26916
|
+
const out = new Map(ids.map((i) => [i, []]));
|
|
26917
|
+
const inn = new Map(ids.map((i) => [i, []]));
|
|
26918
|
+
const state = new Map;
|
|
26919
|
+
const forward = [];
|
|
26920
|
+
const adj = new Map(ids.map((i) => [i, []]));
|
|
26921
|
+
for (const e of d.edges)
|
|
26922
|
+
if (e.from !== e.to)
|
|
26923
|
+
adj.get(e.from).push(e.to);
|
|
26924
|
+
const dfs = (v) => {
|
|
26925
|
+
state.set(v, 1);
|
|
26926
|
+
for (const w of adj.get(v)) {
|
|
26927
|
+
if (state.get(w) === 1)
|
|
26928
|
+
continue;
|
|
26929
|
+
forward.push([v, w]);
|
|
26930
|
+
if (!state.get(w))
|
|
26931
|
+
dfs(w);
|
|
26932
|
+
}
|
|
26933
|
+
state.set(v, 2);
|
|
26934
|
+
};
|
|
26935
|
+
for (const v of ids)
|
|
26936
|
+
if (!state.get(v))
|
|
26937
|
+
dfs(v);
|
|
26938
|
+
for (const [a, b] of forward) {
|
|
26939
|
+
out.get(a).push(b);
|
|
26940
|
+
inn.get(b).push(a);
|
|
26941
|
+
}
|
|
26942
|
+
const layer = new Map;
|
|
26943
|
+
const visit = (v) => {
|
|
26944
|
+
if (layer.has(v))
|
|
26945
|
+
return layer.get(v);
|
|
26946
|
+
layer.set(v, 0);
|
|
26947
|
+
const l = Math.max(0, ...inn.get(v).map((u) => visit(u) + 1));
|
|
26948
|
+
layer.set(v, l);
|
|
26949
|
+
return l;
|
|
26950
|
+
};
|
|
26951
|
+
for (const v of ids)
|
|
26952
|
+
visit(v);
|
|
26953
|
+
const layers = [];
|
|
26954
|
+
for (const v of ids)
|
|
26955
|
+
(layers[layer.get(v)] ??= []).push(v);
|
|
26956
|
+
const pos = new Map;
|
|
26957
|
+
const index = () => layers.forEach((l) => l.forEach((v, i) => pos.set(v, i)));
|
|
26958
|
+
index();
|
|
26959
|
+
for (let sweep = 0;sweep < 4; sweep++) {
|
|
26960
|
+
const down = sweep % 2 === 0;
|
|
26961
|
+
const order = down ? layers.slice(1) : layers.slice(0, -1).reverse();
|
|
26962
|
+
for (const l of order) {
|
|
26963
|
+
const bary = (v) => {
|
|
26964
|
+
const ns = down ? inn.get(v) : out.get(v);
|
|
26965
|
+
return ns.length ? ns.reduce((a, u) => a + pos.get(u), 0) / ns.length : pos.get(v);
|
|
26966
|
+
};
|
|
26967
|
+
l.sort((a, b) => bary(a) - bary(b));
|
|
26968
|
+
l.forEach((v, i) => pos.set(v, i));
|
|
26969
|
+
}
|
|
26970
|
+
}
|
|
26971
|
+
const horizontal = d.direction === "LR" || d.direction === "RL";
|
|
26972
|
+
const gap = opts.gap ?? 60;
|
|
26973
|
+
const layerGap = opts.layerGap ?? 100;
|
|
26974
|
+
const sizes = new Map(d.nodes.map((n) => [n.id, size(n)]));
|
|
26975
|
+
const along = (v) => horizontal ? sizes.get(v).h : sizes.get(v).w;
|
|
26976
|
+
const across = (v) => horizontal ? sizes.get(v).w : sizes.get(v).h;
|
|
26977
|
+
const spans = layers.map((l) => l.reduce((a, v) => a + along(v), 0) + gap * Math.max(0, l.length - 1));
|
|
26978
|
+
const widest = Math.max(0, ...spans);
|
|
26979
|
+
const positions = new Map;
|
|
26980
|
+
let depth = 0;
|
|
26981
|
+
layers.forEach((l, li) => {
|
|
26982
|
+
const thickness = Math.max(...l.map(across));
|
|
26983
|
+
let cursor = (widest - spans[li]) / 2;
|
|
26984
|
+
for (const v of l) {
|
|
26985
|
+
const s = sizes.get(v);
|
|
26986
|
+
const main = cursor;
|
|
26987
|
+
const cross = depth + (thickness - across(v)) / 2;
|
|
26988
|
+
positions.set(v, horizontal ? { x: cross, y: main, w: s.w, h: s.h } : { x: main, y: cross, w: s.w, h: s.h });
|
|
26989
|
+
cursor += along(v) + gap;
|
|
26990
|
+
}
|
|
26991
|
+
depth += thickness + layerGap;
|
|
26992
|
+
});
|
|
26993
|
+
const total = Math.max(0, depth - layerGap);
|
|
26994
|
+
if (d.direction === "BT" || d.direction === "RL") {
|
|
26995
|
+
for (const p of positions.values()) {
|
|
26996
|
+
if (horizontal)
|
|
26997
|
+
p.x = total - p.x - p.w;
|
|
26998
|
+
else
|
|
26999
|
+
p.y = total - p.y - p.h;
|
|
27000
|
+
}
|
|
27001
|
+
}
|
|
27002
|
+
return { positions, width: horizontal ? total : widest, height: horizontal ? widest : total };
|
|
27003
|
+
}
|
|
27004
|
+
function defaultSize(n) {
|
|
27005
|
+
const lines = n.label.split(`
|
|
27006
|
+
`);
|
|
27007
|
+
const longest = Math.max(...lines.map((l) => l.length));
|
|
27008
|
+
const w = Math.min(320, Math.max(n.shape === "diamond" ? 180 : 160, longest * 9 + 48));
|
|
27009
|
+
const h = Math.max(n.shape === "diamond" ? 120 : 80, lines.length * 22 + 40);
|
|
27010
|
+
return n.shape === "ellipse" ? { w, h: Math.max(h, 100) } : { w, h };
|
|
27011
|
+
}
|
|
27012
|
+
function diagramSpec(spec) {
|
|
27013
|
+
const d = parseDiagram(String(spec.source ?? spec.mermaid ?? ""));
|
|
27014
|
+
if (!d.nodes.length)
|
|
27015
|
+
throw new Error('diagram: no nodes found. Example: source:"flowchart LR\\n A[Start] --> B{OK?}\\n B -->|yes| C(Done)"');
|
|
27016
|
+
const stickies = spec.as === "stickies";
|
|
27017
|
+
const STICKY = { w: 240, h: 240 };
|
|
27018
|
+
const lay = layoutDiagram(d, stickies ? { size: () => STICKY, gap: 80, layerGap: 120 } : {});
|
|
27019
|
+
const pad = 80;
|
|
27020
|
+
const head = 60;
|
|
27021
|
+
const inGroup = new Map;
|
|
27022
|
+
for (const g of d.subgraphs)
|
|
27023
|
+
for (const id of g.nodes)
|
|
27024
|
+
if (!inGroup.has(id))
|
|
27025
|
+
inGroup.set(id, g);
|
|
27026
|
+
const nodeSpec = (n, dx, dy) => {
|
|
27027
|
+
const p = lay.positions.get(n.id);
|
|
27028
|
+
const base = { name: n.label.split(`
|
|
27029
|
+
`)[0], key: `${spec.key ?? "diagram"}:${n.id}`, x: Math.round(p.x + dx), y: Math.round(p.y + dy) };
|
|
27030
|
+
return stickies ? { ...base, type: "sticky", text: n.label, color: spec.color ?? "yellow" } : { ...base, type: "shape", shape: n.shape, text: n.label, w: Math.round(p.w), h: Math.round(p.h), ...spec.color ? { fill: spec.color } : {} };
|
|
27031
|
+
};
|
|
27032
|
+
const children = [];
|
|
27033
|
+
for (const g of d.subgraphs) {
|
|
27034
|
+
const members = g.nodes.filter((id) => inGroup.get(id) === g).map((id) => d.nodes.find((n) => n.id === id));
|
|
27035
|
+
if (!members.length)
|
|
27036
|
+
continue;
|
|
27037
|
+
const ps = members.map((n) => lay.positions.get(n.id));
|
|
27038
|
+
const x0 = Math.min(...ps.map((p) => p.x)) - 40;
|
|
27039
|
+
const y0 = Math.min(...ps.map((p) => p.y)) - head;
|
|
27040
|
+
const x1 = Math.max(...ps.map((p) => p.x + p.w)) + 40;
|
|
27041
|
+
const y1 = Math.max(...ps.map((p) => p.y + p.h)) + 40;
|
|
27042
|
+
children.push({
|
|
27043
|
+
type: "section",
|
|
27044
|
+
name: g.label,
|
|
27045
|
+
x: Math.round(x0 + pad),
|
|
27046
|
+
y: Math.round(y0 + pad + head),
|
|
27047
|
+
w: Math.round(x1 - x0),
|
|
27048
|
+
h: Math.round(y1 - y0),
|
|
27049
|
+
children: members.map((n) => nodeSpec(n, -x0, -y0))
|
|
27050
|
+
});
|
|
27051
|
+
}
|
|
27052
|
+
for (const n of d.nodes)
|
|
27053
|
+
if (!inGroup.has(n.id))
|
|
27054
|
+
children.push(nodeSpec(n, pad, pad + head));
|
|
27055
|
+
for (const e of d.edges) {
|
|
27056
|
+
children.push({
|
|
27057
|
+
type: "connector",
|
|
27058
|
+
from: `${spec.key ?? "diagram"}:${e.from}`,
|
|
27059
|
+
to: `${spec.key ?? "diagram"}:${e.to}`,
|
|
27060
|
+
...e.label ? { label: e.label } : {},
|
|
27061
|
+
...e.dashed ? { dashed: true } : {},
|
|
27062
|
+
...e.thick ? { strokeWidth: 4 } : {},
|
|
27063
|
+
...e.arrow ? {} : { endArrow: "none" },
|
|
27064
|
+
line: spec.line ?? "elbowed"
|
|
27065
|
+
});
|
|
27066
|
+
}
|
|
27067
|
+
return {
|
|
27068
|
+
type: "section",
|
|
27069
|
+
name: spec.name ?? "Diagram",
|
|
27070
|
+
...spec.x !== undefined ? { x: spec.x } : {},
|
|
27071
|
+
...spec.y !== undefined ? { y: spec.y } : {},
|
|
27072
|
+
w: Math.round(lay.width + pad * 2),
|
|
27073
|
+
h: Math.round(lay.height + pad * 2 + head),
|
|
27074
|
+
children
|
|
27075
|
+
};
|
|
27076
|
+
}
|
|
27077
|
+
var SHAPES, unquote = (s) => s.trim().replace(/^"(.*)"$/s, "$1").replace(/<br\s*\/?>/gi, `
|
|
27078
|
+
`), EDGE;
|
|
27079
|
+
var init_diagram = __esm(() => {
|
|
27080
|
+
SHAPES = [
|
|
27081
|
+
[/^\(\((.*)\)\)$/s, "ellipse"],
|
|
27082
|
+
[/^\(\[(.*)\]\)$/s, "rounded"],
|
|
27083
|
+
[/^\[\[(.*)\]\]$/s, "process"],
|
|
27084
|
+
[/^\[\((.*)\)\]$/s, "database"],
|
|
27085
|
+
[/^\{\{(.*)\}\}$/s, "hexagon"],
|
|
27086
|
+
[/^\[\/(.*)\/\]$/s, "parallelogram"],
|
|
27087
|
+
[/^\[\\(.*)\\\]$/s, "parallelogramleft"],
|
|
27088
|
+
[/^\[\/(.*)\\\]$/s, "trapezoid"],
|
|
27089
|
+
[/^>(.*)\]$/s, "chevron"],
|
|
27090
|
+
[/^\((.*)\)$/s, "rounded"],
|
|
27091
|
+
[/^\{(.*)\}$/s, "diamond"],
|
|
27092
|
+
[/^\[(.*)\]$/s, "square"]
|
|
27093
|
+
];
|
|
27094
|
+
EDGE = /\s*(<?-{2,}>|<?-\.+->|<?={2,}>|-{3,}|={3,}|-\.+-|--\s+[^-]+?\s+-->|==\s+[^=]+?\s+==>|-\.\s+[^.]+?\s+\.->)\s*(?:\|([^|]*)\|)?\s*/;
|
|
27095
|
+
});
|
|
27096
|
+
|
|
27097
|
+
// src/guides.ts
|
|
27098
|
+
function registerGuides(server, docs) {
|
|
27099
|
+
for (const p of PROMPTS) {
|
|
27100
|
+
const argsSchema = {};
|
|
27101
|
+
for (const [k, d] of Object.entries(p.args))
|
|
27102
|
+
argsSchema[k] = string2().optional().describe(d);
|
|
27103
|
+
server.registerPrompt(p.name, { title: p.title, description: p.description, argsSchema }, (args) => text(p.render(args ?? {})));
|
|
27104
|
+
}
|
|
27105
|
+
server.registerResource("build-spec", RESOURCES.build, { title: "build spec reference", description: "Every node type and property the build tool accepts, with examples.", mimeType: "text/markdown" }, async (uri) => ({ contents: [{ uri: uri.href, mimeType: "text/markdown", text: buildSpecMarkdown(docs.buildSpec) }] }));
|
|
27106
|
+
server.registerResource("workflow", RESOURCES.workflow, { title: "Figma Bridge workflow", description: "How to work with Figma Bridge: the workflow, rules and every tool.", mimeType: "text/markdown" }, async (uri) => ({ contents: [{ uri: uri.href, mimeType: "text/markdown", text: workflowMarkdown(docs.instructions, docs.tools()) }] }));
|
|
27107
|
+
}
|
|
27108
|
+
function buildSpecMarkdown(spec) {
|
|
27109
|
+
const lines = spec.split(`
|
|
27110
|
+
`);
|
|
27111
|
+
const out = ["# build spec reference", "", lines[0], ""];
|
|
27112
|
+
for (const line of lines.slice(1)) {
|
|
27113
|
+
const m = /^([A-Z]{2,}(?: [A-Z]{2,})*)((?: \([^)]*\))?(?: files)?):\s*(.*)$/.exec(line);
|
|
27114
|
+
if (m && !/^Example/.test(line)) {
|
|
27115
|
+
const title = m[1] + m[2];
|
|
27116
|
+
out.push(`## ${title[0]}${title.slice(1).toLowerCase()}`, "", m[3], "");
|
|
27117
|
+
} else if (/^Example:/.test(line))
|
|
27118
|
+
out.push("## Example", "", "```json", line.replace(/^Example:\s*/, ""), "```", "");
|
|
27119
|
+
else
|
|
27120
|
+
out.push(line, "");
|
|
27121
|
+
}
|
|
27122
|
+
return out.join(`
|
|
27123
|
+
`).replace(/\n{3,}/g, `
|
|
27124
|
+
|
|
27125
|
+
`);
|
|
27126
|
+
}
|
|
27127
|
+
function workflowMarkdown(instructions, tools) {
|
|
27128
|
+
const first = (d) => (/^(.*?[.!?])(\s|$)/s.exec(d.replace(/\s+/g, " "))?.[1] ?? d).slice(0, 220);
|
|
27129
|
+
return [
|
|
27130
|
+
"# Working with Figma Bridge",
|
|
27131
|
+
"",
|
|
27132
|
+
...instructions.map((l) => /^\d\./.test(l) ? l : `- ${l}`),
|
|
27133
|
+
"",
|
|
27134
|
+
"## Tools",
|
|
27135
|
+
"",
|
|
27136
|
+
...tools.map((t) => `- **${t.name}**${t.title ? ` (${t.title})` : ""}: ${first(t.description)}`),
|
|
27137
|
+
"",
|
|
27138
|
+
"## Prompts",
|
|
27139
|
+
"",
|
|
27140
|
+
...PROMPTS.map((p) => `- **${p.name}**: ${p.description}`),
|
|
27141
|
+
"",
|
|
27142
|
+
`Full build spec: ${RESOURCES.build}`,
|
|
27143
|
+
""
|
|
27144
|
+
].join(`
|
|
27145
|
+
`);
|
|
27146
|
+
}
|
|
27147
|
+
var RESOURCES, text = (t) => ({ messages: [{ role: "user", content: { type: "text", text: t.trim() } }] }), opt = (v, fallback) => v && v.trim() ? v.trim() : fallback, PROMPTS;
|
|
27148
|
+
var init_guides = __esm(() => {
|
|
27149
|
+
init_zod();
|
|
27150
|
+
RESOURCES = {
|
|
27151
|
+
build: "figma-bridge://docs/build-spec",
|
|
27152
|
+
workflow: "figma-bridge://docs/workflow"
|
|
27153
|
+
};
|
|
27154
|
+
PROMPTS = [
|
|
27155
|
+
{
|
|
27156
|
+
name: "new-screen",
|
|
27157
|
+
title: "Design a new screen",
|
|
27158
|
+
description: "Design a screen from a description, reusing the file's design system.",
|
|
27159
|
+
args: { description: "What the screen is for and what it contains", platform: "desktop (1440), tablet (834) or mobile (390); default desktop" },
|
|
27160
|
+
render: (a) => `
|
|
27161
|
+
Design a new ${opt(a.platform, "desktop")} screen in the connected Figma file: ${opt(a.description, "(ask me what the screen is for)")}.
|
|
27162
|
+
1. get_context, then get_design_system. Reuse its color and text styles, variables and components (style:, var:, {component}) instead of raw values.
|
|
27163
|
+
2. Build it with build: one call per section (header, hero, content, footer…), auto-layout everywhere, frames named after what they are, width ${a.platform === "mobile" ? 390 : a.platform === "tablet" ? 834 : 1440}.
|
|
27164
|
+
3. screenshot {returnImage:true} the result and look at it critically: spacing rhythm, alignment, hierarchy, contrast.
|
|
27165
|
+
4. audit the frame and fix every error and warning.
|
|
27166
|
+
5. Tell me what you built and what you would improve next.`
|
|
27167
|
+
},
|
|
27168
|
+
{
|
|
27169
|
+
name: "apply-design-system",
|
|
27170
|
+
title: "Apply the design system",
|
|
27171
|
+
description: "Make a screen use the file's variables, styles and components.",
|
|
27172
|
+
args: { target: "Layer name or id (default: the selection)" },
|
|
27173
|
+
render: (a) => `
|
|
27174
|
+
Apply the file's design system to ${opt(a.target, "the selected layers (use wait_for_selection if nothing is selected)")}.
|
|
27175
|
+
1. get_design_system to see the variables, styles and components available, and describe the target.
|
|
27176
|
+
2. checkpoint {action:"save"} on the target first.
|
|
27177
|
+
3. audit {fix:true} on it: raw colors and numbers get bound to matching variables, matching text styles are applied, default layer names are fixed.
|
|
27178
|
+
4. For what remains (near-miss colors, text without a matching style, frames that should be component instances), fix it with build or run_script: prefer replacing ad-hoc elements by instances of the existing components.
|
|
27179
|
+
5. audit {scope:"design-system", nodeId} before and after, and report the score change and anything you could not map.`
|
|
27180
|
+
},
|
|
27181
|
+
{
|
|
27182
|
+
name: "reproduce-screenshot",
|
|
27183
|
+
title: "Reproduce a screenshot",
|
|
27184
|
+
description: "Rebuild a mockup or screenshot as an editable, auto-layout frame and check it pixel by pixel.",
|
|
27185
|
+
args: { image: "Local path or URL of the image", width: "Width of the frame in px (default: the image's width)" },
|
|
27186
|
+
render: (a) => `
|
|
27187
|
+
Reproduce ${opt(a.image, "the image I give you")} as an editable Figma frame${a.width ? ` ${a.width} px wide` : ""}.
|
|
27188
|
+
1. Look at the image carefully. get_design_system and reuse what matches.
|
|
27189
|
+
2. build it at the image's size, with auto-layout and real text (not images of text). Icons: search_icons, then {icon:"set:name"}.
|
|
27190
|
+
3. compare {nodeId, reference:"${opt(a.image, "<image>")}", returnImage:true}. Fix the largest regions first (layout and size before color and detail), then compare again.
|
|
27191
|
+
4. Stop when mismatchPercent no longer drops; report the final number and what still differs.`
|
|
27192
|
+
},
|
|
27193
|
+
{
|
|
27194
|
+
name: "import-website",
|
|
27195
|
+
title: "Import a website",
|
|
27196
|
+
description: "Turn a web page into editable Figma frames at several widths, then clean them up.",
|
|
27197
|
+
args: { url: "The page URL, or a local .html path", viewports: "Comma-separated widths, default 1440,390" },
|
|
27198
|
+
render: (a) => {
|
|
27199
|
+
const viewports = opt(a.viewports, "1440,390").split(",").map((v) => Number(v.trim())).filter((n) => n > 0);
|
|
27200
|
+
return `
|
|
27201
|
+
Import ${opt(a.url, "(ask me for the URL)")} into the connected Figma file.
|
|
27202
|
+
1. import_web {${/^https?:/.test(a.url ?? "") ? "url" : "path"}:"${opt(a.url, "<url>")}", viewports:${JSON.stringify(viewports)}}.
|
|
27203
|
+
2. For each frame, compare {nodeId: rootId, reference} and look at the regions with the largest mismatch: fix layout or spacing problems with run_script or build.
|
|
27204
|
+
3. Report fontSubstitutions: suggest installing the missing fonts or switching to the file's fonts.
|
|
27205
|
+
4. Rename layers that are still generic (Row, Column, Frame) after what they contain, and turn repeated cards or buttons into components if it helps.`;
|
|
27206
|
+
}
|
|
27207
|
+
},
|
|
27208
|
+
{
|
|
27209
|
+
name: "figma-to-code",
|
|
27210
|
+
title: "Figma to code",
|
|
27211
|
+
description: "Export a frame as code that uses the project's own components and tokens.",
|
|
27212
|
+
args: { projectPath: "Root folder of the app (where package.json is)", target: "Layer name or id (default: the selection)" },
|
|
27213
|
+
render: (a) => `
|
|
27214
|
+
Turn ${opt(a.target, "the selected frame")} into code for the project at ${opt(a.projectPath, "(ask me for the project folder)")}.
|
|
27215
|
+
1. export_code {projectPath, nodeId} without writing. Read the plan: the detected stack, the components used and the unmatched instances, the token sources.
|
|
27216
|
+
2. If important instances are unmatched, add figma-bridge.map.json entries for them (Figma component → code component and props) and export again.
|
|
27217
|
+
3. Review the code like a teammate would: semantics, accessibility (labels, alt text, buttons vs links), responsive behaviour. Fix names in Figma if class or component names are poor.
|
|
27218
|
+
4. When it looks right, export_code with write:true, then run the project's type-check or linter and fix what they report.`
|
|
27219
|
+
},
|
|
27220
|
+
{
|
|
27221
|
+
name: "figma-to-roblox",
|
|
27222
|
+
title: "Figma to Roblox",
|
|
27223
|
+
description: "Turn a frame into Roblox UI and place it in Studio with a Roblox Studio MCP.",
|
|
27224
|
+
args: { target: "Layer name or id (default: the selection)", mode: "scale, offset or hybrid (default hybrid)" },
|
|
27225
|
+
render: (a) => `
|
|
27226
|
+
Turn ${opt(a.target, "the selected frame")} into Roblox UI that looks the same, and put it in the open Roblox Studio place.
|
|
27227
|
+
1. export_roblox {${a.target ? `nodeId:"<id of ${a.target}>", ` : ""}mode:"${opt(a.mode, "hybrid")}"}. Read the warnings (approximations, font substitutions).
|
|
27228
|
+
2. If assets are not uploaded (placeholders rbxassetid://PENDING_n): upload each file in assets[] with the Roblox Studio MCP (e.g. upload_image), and replace each placeholder in the Luau with the returned id.
|
|
27229
|
+
3. Run the Luau with execute_luau: it creates the ScreenGui under StarterGui (replacing an earlier copy) and returns it.
|
|
27230
|
+
4. Take a Studio screenshot and compare it with screenshot {returnImage:true} of the Figma frame. Fix differences in the Luau (or in Figma, then export again).
|
|
27231
|
+
5. Report the result and anything Roblox could not reproduce.`
|
|
27232
|
+
},
|
|
27233
|
+
{
|
|
27234
|
+
name: "audit-and-fix",
|
|
27235
|
+
title: "Audit and fix",
|
|
27236
|
+
description: "Score the design system's health, apply safe fixes, and report what changed.",
|
|
27237
|
+
args: { target: "Layer name or id to limit the fixes (default: the current page)" },
|
|
27238
|
+
render: (a) => `
|
|
27239
|
+
Audit and improve this file${a.target ? ` (fixes limited to ${a.target})` : ""}.
|
|
27240
|
+
1. audit {scope:"design-system"} and summarize the score per category with the most important issues.
|
|
27241
|
+
2. checkpoint {action:"save"} on what you are going to change.
|
|
27242
|
+
3. audit {fix:true${a.target ? `, nodeId:"<id of ${a.target}>"` : ""}} and read the changes.
|
|
27243
|
+
4. Fix the remaining contrast errors and text without styles by hand where the right choice is clear; list the ones that need a design decision.
|
|
27244
|
+
5. audit {scope:"design-system"} again and report the score before and after.`
|
|
27245
|
+
}
|
|
27246
|
+
];
|
|
27247
|
+
});
|
|
27248
|
+
|
|
27249
|
+
// src/roblox/fonts.ts
|
|
27250
|
+
function robloxFamily(family, overrides = {}) {
|
|
27251
|
+
const key = family.trim().toLowerCase();
|
|
27252
|
+
const custom = Object.entries(overrides).find(([k]) => k.trim().toLowerCase() === key)?.[1];
|
|
27253
|
+
const name = custom ?? ROBLOX_FONTS[key] ?? DEFAULT_FONT;
|
|
27254
|
+
const url = /^rbxasset(id)?:\/\//.test(name) ? name : `rbxasset://fonts/families/${name.replace(/\s+/g, "")}.json`;
|
|
27255
|
+
const exact = custom !== undefined || name.toLowerCase() === key.replace(/\s+/g, "") || key === "gotham" && name === "GothamSSm";
|
|
27256
|
+
return { url, substituted: !exact };
|
|
27257
|
+
}
|
|
27258
|
+
function robloxWeight(weight) {
|
|
27259
|
+
let best = WEIGHTS2[3];
|
|
27260
|
+
for (const w of WEIGHTS2)
|
|
27261
|
+
if (Math.abs(w[0] - weight) < Math.abs(best[0] - weight))
|
|
27262
|
+
best = w;
|
|
27263
|
+
return { value: best[0], name: best[1] };
|
|
27264
|
+
}
|
|
27265
|
+
var ROBLOX_FONTS, DEFAULT_FONT = "BuilderSans", WEIGHTS2;
|
|
27266
|
+
var init_fonts = __esm(() => {
|
|
27267
|
+
ROBLOX_FONTS = {
|
|
27268
|
+
"builder sans": "BuilderSans",
|
|
27269
|
+
montserrat: "Montserrat",
|
|
27270
|
+
roboto: "Roboto",
|
|
27271
|
+
"roboto condensed": "RobotoCondensed",
|
|
27272
|
+
"roboto mono": "RobotoMono",
|
|
27273
|
+
"source sans pro": "SourceSansPro",
|
|
27274
|
+
"source sans 3": "SourceSansPro",
|
|
27275
|
+
nunito: "Nunito",
|
|
27276
|
+
oswald: "Oswald",
|
|
27277
|
+
merriweather: "Merriweather",
|
|
27278
|
+
ubuntu: "Ubuntu",
|
|
27279
|
+
"titillium web": "TitilliumWeb",
|
|
27280
|
+
"press start 2p": "PressStart2P",
|
|
27281
|
+
"fredoka one": "FredokaOne",
|
|
27282
|
+
fredoka: "FredokaOne",
|
|
27283
|
+
bangers: "Bangers",
|
|
27284
|
+
creepster: "Creepster",
|
|
27285
|
+
"luckiest guy": "LuckiestGuy",
|
|
27286
|
+
"permanent marker": "PermanentMarker",
|
|
27287
|
+
"patrick hand": "PatrickHand",
|
|
27288
|
+
"indie flower": "IndieFlower",
|
|
27289
|
+
"amatic sc": "AmaticSC",
|
|
27290
|
+
kalam: "Kalam",
|
|
27291
|
+
michroma: "Michroma",
|
|
27292
|
+
jura: "Jura",
|
|
27293
|
+
sarpanch: "Sarpanch",
|
|
27294
|
+
"special elite": "SpecialElite",
|
|
27295
|
+
"denk one": "DenkOne",
|
|
27296
|
+
inconsolata: "Inconsolata",
|
|
27297
|
+
arimo: "Arimo",
|
|
27298
|
+
gotham: "GothamSSm",
|
|
27299
|
+
"gotham ssm": "GothamSSm",
|
|
27300
|
+
inter: "BuilderSans",
|
|
27301
|
+
"sf pro": "BuilderSans",
|
|
27302
|
+
"sf pro text": "BuilderSans",
|
|
27303
|
+
"sf pro display": "BuilderSans",
|
|
27304
|
+
"open sans": "BuilderSans",
|
|
27305
|
+
lato: "BuilderSans",
|
|
27306
|
+
poppins: "Montserrat",
|
|
27307
|
+
"dm sans": "BuilderSans",
|
|
27308
|
+
manrope: "BuilderSans",
|
|
27309
|
+
"plus jakarta sans": "BuilderSans",
|
|
27310
|
+
"work sans": "BuilderSans",
|
|
27311
|
+
"ibm plex sans": "SourceSansPro",
|
|
27312
|
+
arial: "Arimo",
|
|
27313
|
+
helvetica: "Arimo",
|
|
27314
|
+
"helvetica neue": "Arimo",
|
|
27315
|
+
"segoe ui": "BuilderSans",
|
|
27316
|
+
"jetbrains mono": "RobotoMono",
|
|
27317
|
+
"fira code": "RobotoMono",
|
|
27318
|
+
"source code pro": "RobotoMono",
|
|
27319
|
+
"sf mono": "RobotoMono",
|
|
27320
|
+
"courier new": "RobotoMono",
|
|
27321
|
+
georgia: "Merriweather",
|
|
27322
|
+
"times new roman": "Merriweather",
|
|
27323
|
+
"playfair display": "Merriweather",
|
|
27324
|
+
"bebas neue": "Oswald",
|
|
27325
|
+
anton: "Oswald",
|
|
27326
|
+
"comic sans ms": "PatrickHand"
|
|
27327
|
+
};
|
|
27328
|
+
WEIGHTS2 = [
|
|
27329
|
+
[100, "Thin"],
|
|
27330
|
+
[200, "ExtraLight"],
|
|
27331
|
+
[300, "Light"],
|
|
27332
|
+
[400, "Regular"],
|
|
27333
|
+
[500, "Medium"],
|
|
27334
|
+
[600, "SemiBold"],
|
|
27335
|
+
[700, "Bold"],
|
|
27336
|
+
[800, "ExtraBold"],
|
|
27337
|
+
[900, "Heavy"]
|
|
27338
|
+
];
|
|
27339
|
+
});
|
|
27340
|
+
|
|
27341
|
+
// src/roblox/map.ts
|
|
27342
|
+
function color3(hex) {
|
|
27343
|
+
const h = hex.replace("#", "");
|
|
27344
|
+
const c = (i) => num(parseInt(h.slice(i, i + 2), 16) / 255);
|
|
27345
|
+
return { r: c(0), g: c(2), b: c(4) };
|
|
27346
|
+
}
|
|
27347
|
+
function visible(list) {
|
|
27348
|
+
return (list ?? []).filter((p) => p.opacity > 0);
|
|
27349
|
+
}
|
|
27350
|
+
function decide(n, opts) {
|
|
27351
|
+
const approximations = [];
|
|
27352
|
+
if (n.text)
|
|
27353
|
+
return { kind: "text", shadow: false, approximations };
|
|
27354
|
+
const raster = opts.rasterize;
|
|
27355
|
+
const fills = visible(n.fills);
|
|
27356
|
+
const strokes = visible(n.strokes);
|
|
27357
|
+
const effects = n.effects ?? [];
|
|
27358
|
+
const drop = effects.filter((e) => e.type === "DROP_SHADOW");
|
|
27359
|
+
const other = effects.filter((e) => e.type !== "DROP_SHADOW");
|
|
27360
|
+
const hasChildren = !!n.children?.some((c) => !c.hidden);
|
|
27361
|
+
if (n.vector || ["VECTOR", "BOOLEAN_OPERATION", "STAR", "POLYGON", "LINE"].includes(n.type)) {
|
|
27362
|
+
return raster === "none" ? { kind: "frame", shadow: false, approximations: ["vector shape skipped (rasterize is none)"] } : { kind: "picture", shadow: false, approximations };
|
|
27363
|
+
}
|
|
27364
|
+
const image = fills.length === 1 && fills[0].type === "IMAGE";
|
|
27365
|
+
if (image && !hasChildren && !strokes.length && !effects.length && raster !== "all")
|
|
27366
|
+
return { kind: "image", shadow: false, approximations };
|
|
27367
|
+
const reasons = [];
|
|
27368
|
+
if (fills.length > 1)
|
|
27369
|
+
reasons.push("several fills");
|
|
27370
|
+
if (fills.length === 1 && !["SOLID", "GRADIENT_LINEAR", "IMAGE"].includes(fills[0].type))
|
|
27371
|
+
reasons.push(`${fills[0].type.toLowerCase().replace("_", " ")} fill`);
|
|
27372
|
+
if (image && (strokes.length || effects.length))
|
|
27373
|
+
reasons.push("image with stroke or effects");
|
|
27374
|
+
if (strokes.length > 1 || strokes[0] && strokes[0].type !== "SOLID")
|
|
27375
|
+
reasons.push("complex stroke");
|
|
27376
|
+
if (n.dashed)
|
|
27377
|
+
reasons.push("dashed stroke");
|
|
27378
|
+
if (other.length)
|
|
27379
|
+
reasons.push(other.map((e) => e.type.toLowerCase().replace(/_/g, " ")).join(", "));
|
|
27380
|
+
if (n.type === "ELLIPSE" && Math.abs(n.w - n.h) > 0.5)
|
|
27381
|
+
reasons.push("oval");
|
|
27382
|
+
const styled = fills.length + strokes.length + effects.length > 0;
|
|
27383
|
+
if ((reasons.length || raster === "all" && styled) && raster !== "none") {
|
|
27384
|
+
return { kind: hasChildren ? "panel" : "picture", shadow: false, approximations };
|
|
27385
|
+
}
|
|
27386
|
+
if (reasons.length)
|
|
27387
|
+
approximations.push(`${reasons.join(", ")}: approximated (rasterize is none)`);
|
|
27388
|
+
const r = n.radius ?? [];
|
|
27389
|
+
if (r.length && r.some((v) => Math.abs(v - r[0]) > 0.5))
|
|
27390
|
+
approximations.push("different corner radii: used the largest");
|
|
27391
|
+
if (drop.length && raster === "none")
|
|
27392
|
+
approximations.push("drop shadow skipped (rasterize is none)");
|
|
27393
|
+
return { kind: "frame", shadow: drop.length > 0 && raster !== "none", approximations };
|
|
27394
|
+
}
|
|
27395
|
+
function pictureRequests(root, opts) {
|
|
27396
|
+
const out = [];
|
|
27397
|
+
const walk = (n) => {
|
|
27398
|
+
if (n.hidden)
|
|
27399
|
+
return;
|
|
27400
|
+
const d = decide(n, opts);
|
|
27401
|
+
if (d.kind === "picture")
|
|
27402
|
+
out.push({ id: n.id, mode: "full" });
|
|
27403
|
+
if (d.kind === "panel")
|
|
27404
|
+
out.push({ id: n.id, mode: "panel" });
|
|
27405
|
+
if (d.shadow)
|
|
27406
|
+
out.push({ id: n.id, mode: "shadow" });
|
|
27407
|
+
if (d.kind !== "picture")
|
|
27408
|
+
for (const c of n.children ?? [])
|
|
27409
|
+
walk(c);
|
|
27410
|
+
};
|
|
27411
|
+
walk(root);
|
|
27412
|
+
return out;
|
|
27413
|
+
}
|
|
27414
|
+
function imageHashes(root) {
|
|
27415
|
+
const out = new Set;
|
|
27416
|
+
const walk = (n) => {
|
|
27417
|
+
for (const f of n.fills ?? [])
|
|
27418
|
+
if (f.imageHash)
|
|
27419
|
+
out.add(f.imageHash);
|
|
27420
|
+
for (const c of n.children ?? [])
|
|
27421
|
+
walk(c);
|
|
27422
|
+
};
|
|
27423
|
+
walk(root);
|
|
27424
|
+
return [...out];
|
|
27425
|
+
}
|
|
27426
|
+
|
|
27427
|
+
class Mapper {
|
|
27428
|
+
opts;
|
|
27429
|
+
assets;
|
|
27430
|
+
warnings = new Set;
|
|
27431
|
+
fontSubstitutions = new Map;
|
|
27432
|
+
counts = { instances: 0, pictures: 0, skippedHidden: 0 };
|
|
27433
|
+
textFactor = 1;
|
|
27434
|
+
constructor(opts, assets) {
|
|
27435
|
+
this.opts = opts;
|
|
27436
|
+
this.assets = assets;
|
|
27437
|
+
}
|
|
27438
|
+
warn(n, msg) {
|
|
27439
|
+
if (this.warnings.size < 60)
|
|
27440
|
+
this.warnings.add(n ? `${n.name}: ${msg}` : msg);
|
|
27441
|
+
}
|
|
27442
|
+
map(root) {
|
|
27443
|
+
const [tw, th] = this.opts.targetResolution;
|
|
27444
|
+
const fullScreen = Math.abs(root.w / root.h - tw / th) < 0.1 * (tw / th) && root.w >= tw * 0.4;
|
|
27445
|
+
if (fullScreen && this.opts.mode !== "offset")
|
|
27446
|
+
this.textFactor = tw / root.w;
|
|
27447
|
+
const top = this.node(root, null, 0);
|
|
27448
|
+
const geo = this.opts.mode === "offset" ? [["AnchorPoint", V2(0.5, 0.5)], ["Position", U2(0.5, 0, 0.5, 0)], ["Size", U2(0, root.w, 0, root.h)]] : fullScreen ? [["AnchorPoint", V2(0.5, 0.5)], ["Position", U2(0.5, 0, 0.5, 0)], ["Size", U2(1, 0, 1, 0)]] : [["AnchorPoint", V2(0.5, 0.5)], ["Position", U2(0.5, 0, 0.5, 0)], ["Size", U2(root.w / tw, 0, root.h / th, 0)]];
|
|
27449
|
+
top.props = top.props.filter(([k]) => !["AnchorPoint", "Position", "Size", "LayoutOrder", "ZIndex"].includes(k));
|
|
27450
|
+
top.props.splice(1, 0, ...geo);
|
|
27451
|
+
top.children.push(this.inst("UIAspectRatioConstraint", "AspectRatio", [["AspectRatio", F(root.w / root.h)]]));
|
|
27452
|
+
if (this.opts.asRootFrame)
|
|
27453
|
+
return top;
|
|
27454
|
+
return this.inst("ScreenGui", root.name, [["ResetOnSpawn", B(false)], ["ZIndexBehavior", E("ZIndexBehavior", "Sibling")], ["IgnoreGuiInset", B(true)]], [top]);
|
|
27455
|
+
}
|
|
27456
|
+
inst(className, name, props = [], children = [], source) {
|
|
27457
|
+
this.counts.instances++;
|
|
27458
|
+
return { className, name, props: [["Name", S(name)], ...props], children, ...source ? { source } : {} };
|
|
27459
|
+
}
|
|
27460
|
+
geometry(n, parent, index, d) {
|
|
27461
|
+
const out = [];
|
|
27462
|
+
if (!parent)
|
|
27463
|
+
return out;
|
|
27464
|
+
const inFlow = !!parent.layout && !n.absolute;
|
|
27465
|
+
const useScale = this.opts.mode === "scale" || this.opts.mode === "hybrid" && !inFlow;
|
|
27466
|
+
const hugW = n.sizing?.h === "HUG" && (d.kind === "text" || !!n.layout);
|
|
27467
|
+
const hugH = n.sizing?.v === "HUG" && (d.kind === "text" || !!n.layout);
|
|
27468
|
+
if (inFlow) {
|
|
27469
|
+
const l = parent.layout;
|
|
27470
|
+
const horizontal = l.mode === "HORIZONTAL";
|
|
27471
|
+
const pw = parent.w - (l.padding[1] ?? 0) - (l.padding[3] ?? 0);
|
|
27472
|
+
const ph = parent.h - (l.padding[0] ?? 0) - (l.padding[2] ?? 0);
|
|
27473
|
+
const crossFill = horizontal ? n.sizing?.v === "FILL" || n.stretch : n.sizing?.h === "FILL" || n.stretch;
|
|
27474
|
+
const w = (horizontal ? false : crossFill) ? U(1, 0) : hugW ? U(0, 0) : useScale && l.mode !== "GRID" ? U(n.w / Math.max(1, pw), 0) : U(0, n.w);
|
|
27475
|
+
const h = (horizontal ? crossFill : false) ? U(1, 0) : hugH ? U(0, 0) : useScale && l.mode !== "GRID" ? U(n.h / Math.max(1, ph), 0) : U(0, n.h);
|
|
27476
|
+
out.push(["Size", { t: "UDim2", xs: w.s, xo: w.o, ys: h.s, yo: h.o }]);
|
|
27477
|
+
out.push(["LayoutOrder", I(index + 1)]);
|
|
27478
|
+
return out;
|
|
27479
|
+
}
|
|
27480
|
+
const c = n.constraints ?? { h: "MIN", v: "MIN" };
|
|
27481
|
+
const axis = (pos, size, total, k) => {
|
|
27482
|
+
const end = total - pos - size;
|
|
27483
|
+
if (k === "SCALE" || useScale) {
|
|
27484
|
+
if (k === "MAX")
|
|
27485
|
+
return { anchor: 1, p: [(pos + size) / total, 0], s: [size / total, 0] };
|
|
27486
|
+
if (k === "CENTER")
|
|
27487
|
+
return { anchor: 0.5, p: [(pos + size / 2) / total, 0], s: [size / total, 0] };
|
|
27488
|
+
return { anchor: 0, p: [pos / total, 0], s: [size / total, 0] };
|
|
27489
|
+
}
|
|
27490
|
+
if (k === "MAX")
|
|
27491
|
+
return { anchor: 1, p: [1, -end], s: [0, size] };
|
|
27492
|
+
if (k === "CENTER")
|
|
27493
|
+
return { anchor: 0.5, p: [0.5, pos + size / 2 - total / 2], s: [0, size] };
|
|
27494
|
+
if (k === "STRETCH")
|
|
27495
|
+
return { anchor: 0, p: [0, pos], s: [1, -(pos + end)] };
|
|
27496
|
+
return { anchor: 0, p: [0, pos], s: [0, size] };
|
|
27497
|
+
};
|
|
27498
|
+
const x = axis(n.x, n.w, Math.max(1, parent.w), c.h);
|
|
27499
|
+
const y = axis(n.y, n.h, Math.max(1, parent.h), c.v);
|
|
27500
|
+
if (x.anchor || y.anchor)
|
|
27501
|
+
out.push(["AnchorPoint", V2(x.anchor, y.anchor)]);
|
|
27502
|
+
out.push(["Position", U2(x.p[0], x.p[1], y.p[0], y.p[1])]);
|
|
27503
|
+
out.push(["Size", U2(hugW ? 0 : x.s[0], hugW ? 0 : x.s[1], hugH ? 0 : y.s[0], hugH ? 0 : y.s[1])]);
|
|
27504
|
+
out.push(["ZIndex", I(index + 1)]);
|
|
27505
|
+
return out;
|
|
27506
|
+
}
|
|
27507
|
+
automaticSize(n, d) {
|
|
27508
|
+
const hugW = n.sizing?.h === "HUG" && (d.kind === "text" || !!n.layout);
|
|
27509
|
+
const hugH = n.sizing?.v === "HUG" && (d.kind === "text" || !!n.layout);
|
|
27510
|
+
if (!hugW && !hugH)
|
|
27511
|
+
return [];
|
|
27512
|
+
return [["AutomaticSize", E("AutomaticSize", hugW && hugH ? "XY" : hugW ? "X" : "Y")]];
|
|
27513
|
+
}
|
|
27514
|
+
corner(n) {
|
|
27515
|
+
const r = Math.max(0, ...n.radius ?? []);
|
|
27516
|
+
if (!r && !(n.type === "ELLIPSE"))
|
|
27517
|
+
return null;
|
|
27518
|
+
const pill = n.type === "ELLIPSE" || r >= Math.min(n.w, n.h) / 2 - 0.5;
|
|
27519
|
+
return this.inst("UICorner", "UICorner", [["CornerRadius", pill ? U(0.5, 0) : U(0, r)]]);
|
|
27520
|
+
}
|
|
27521
|
+
stroke(n) {
|
|
27522
|
+
const s = visible(n.strokes)[0];
|
|
27523
|
+
if (!s || s.type !== "SOLID" || !n.strokeWeight)
|
|
27524
|
+
return null;
|
|
27525
|
+
return this.inst("UIStroke", "UIStroke", [
|
|
27526
|
+
["ApplyStrokeMode", E("ApplyStrokeMode", "Border")],
|
|
27527
|
+
["Color", C3(s.color)],
|
|
27528
|
+
["Thickness", F(n.strokeWeight)],
|
|
27529
|
+
["Transparency", F(1 - s.opacity)],
|
|
27530
|
+
["LineJoinMode", E("LineJoinMode", "Round")]
|
|
27531
|
+
]);
|
|
27532
|
+
}
|
|
27533
|
+
gradient(p) {
|
|
27534
|
+
let stops = [...p.stops ?? []].sort((a, b) => a.at - b.at);
|
|
27535
|
+
if (stops.length > 20) {
|
|
27536
|
+
this.warn(null, "a gradient has more than 20 stops: kept 20");
|
|
27537
|
+
stops = stops.filter((_, i) => i % Math.ceil(stops.length / 20) === 0);
|
|
27538
|
+
}
|
|
27539
|
+
if (stops[0].at > 0)
|
|
27540
|
+
stops.unshift({ ...stops[0], at: 0 });
|
|
27541
|
+
if (stops[stops.length - 1].at < 1)
|
|
27542
|
+
stops.push({ ...stops[stops.length - 1], at: 1 });
|
|
27543
|
+
stops[0] = { ...stops[0], at: 0 };
|
|
27544
|
+
stops[stops.length - 1] = { ...stops[stops.length - 1], at: 1 };
|
|
27545
|
+
return this.inst("UIGradient", "UIGradient", [
|
|
27546
|
+
["Color", { t: "ColorSequence", keys: stops.map((s) => ({ time: num(s.at), ...color3(s.color) })) }],
|
|
27547
|
+
["Transparency", { t: "NumberSequence", keys: stops.map((s) => ({ time: num(s.at), value: num(1 - s.alpha * p.opacity) })) }],
|
|
27548
|
+
["Rotation", F(p.angle ?? 0)]
|
|
27549
|
+
]);
|
|
27550
|
+
}
|
|
27551
|
+
layoutChildren(n) {
|
|
27552
|
+
const l = n.layout;
|
|
27553
|
+
const out = [];
|
|
27554
|
+
const [t, r, b, lft] = l.padding;
|
|
27555
|
+
if (t || r || b || lft)
|
|
27556
|
+
out.push(this.inst("UIPadding", "UIPadding", [["PaddingTop", U(0, t)], ["PaddingRight", U(0, r)], ["PaddingBottom", U(0, b)], ["PaddingLeft", U(0, lft)]]));
|
|
27557
|
+
if (l.mode === "GRID") {
|
|
27558
|
+
const kids = (n.children ?? []).filter((c) => !c.hidden && !c.absolute);
|
|
27559
|
+
const first = kids[0];
|
|
27560
|
+
if (kids.some((k) => Math.abs(k.w - (first?.w ?? 0)) > 1 || Math.abs(k.h - (first?.h ?? 0)) > 1))
|
|
27561
|
+
this.warn(n, "grid cells of different sizes: UIGridLayout uses the first cell's size");
|
|
27562
|
+
out.push(this.inst("UIGridLayout", "UIGridLayout", [
|
|
27563
|
+
["CellSize", U2(0, first?.w ?? 100, 0, first?.h ?? 100)],
|
|
27564
|
+
["CellPadding", U2(0, l.columnGap ?? 0, 0, l.rowGap ?? 0)],
|
|
27565
|
+
["FillDirectionMaxCells", I(l.columns ?? 0)],
|
|
27566
|
+
["SortOrder", E("SortOrder", "LayoutOrder")]
|
|
27567
|
+
]));
|
|
27568
|
+
return out;
|
|
27569
|
+
}
|
|
27570
|
+
const horizontal = l.mode === "HORIZONTAL";
|
|
27571
|
+
const main = l.primary === "CENTER" ? "Center" : l.primary === "MAX" ? horizontal ? "Right" : "Bottom" : horizontal ? "Left" : "Top";
|
|
27572
|
+
const cross = l.counter === "CENTER" ? "Center" : l.counter === "MAX" ? horizontal ? "Bottom" : "Right" : horizontal ? "Top" : "Left";
|
|
27573
|
+
const props = [
|
|
27574
|
+
["FillDirection", E("FillDirection", horizontal ? "Horizontal" : "Vertical")],
|
|
27575
|
+
["SortOrder", E("SortOrder", "LayoutOrder")],
|
|
27576
|
+
["Padding", U(0, l.primary === "SPACE_BETWEEN" ? 0 : l.gap)],
|
|
27577
|
+
["HorizontalAlignment", E("HorizontalAlignment", horizontal ? main === "Right" ? "Right" : main === "Center" ? "Center" : "Left" : cross === "Right" ? "Right" : cross === "Center" ? "Center" : "Left")],
|
|
27578
|
+
["VerticalAlignment", E("VerticalAlignment", horizontal ? cross === "Bottom" ? "Bottom" : cross === "Center" ? "Center" : "Top" : main === "Bottom" ? "Bottom" : main === "Center" ? "Center" : "Top")]
|
|
27579
|
+
];
|
|
27580
|
+
if (l.primary === "SPACE_BETWEEN")
|
|
27581
|
+
props.push([horizontal ? "HorizontalFlex" : "VerticalFlex", E("UIFlexAlignment", "SpaceBetween")]);
|
|
27582
|
+
if (l.wrap)
|
|
27583
|
+
props.push(["Wraps", B(true)]);
|
|
27584
|
+
if (l.counter === "BASELINE")
|
|
27585
|
+
this.warn(n, "baseline alignment: aligned to the top");
|
|
27586
|
+
out.push(this.inst("UIListLayout", "UIListLayout", props));
|
|
27587
|
+
return out;
|
|
27588
|
+
}
|
|
27589
|
+
assetUrl(key, n) {
|
|
27590
|
+
const a = this.assets(key);
|
|
27591
|
+
if (!a) {
|
|
27592
|
+
this.warn(n, "picture missing: left empty");
|
|
27593
|
+
return { url: "" };
|
|
27594
|
+
}
|
|
27595
|
+
return a;
|
|
27596
|
+
}
|
|
27597
|
+
textProps(n) {
|
|
27598
|
+
const t = n.text;
|
|
27599
|
+
const segs = t.segments.length ? t.segments : [{ text: t.characters, family: "Inter", style: "Regular", weight: 400, size: 14 }];
|
|
27600
|
+
const base = segs.reduce((a, s) => s.text.length > a.text.length ? s : a, segs[0]);
|
|
27601
|
+
const family = robloxFamily(base.family, this.opts.fonts);
|
|
27602
|
+
if (family.substituted)
|
|
27603
|
+
this.fontSubstitutions.set(base.family, family.url.replace(/^.*\/(\w+)\.json$/, "$1"));
|
|
27604
|
+
const weight = robloxWeight(base.weight);
|
|
27605
|
+
const italic = /italic|oblique/i.test(base.style);
|
|
27606
|
+
const size = Math.max(1, Math.round(base.size * this.textFactor));
|
|
27607
|
+
const props = [
|
|
27608
|
+
["BackgroundTransparency", F(1)],
|
|
27609
|
+
["FontFace", { t: "Font", family: family.url, weight: weight.value, weightName: weight.name, style: italic ? "Italic" : "Normal" }],
|
|
27610
|
+
["TextSize", I(size)],
|
|
27611
|
+
["TextColor3", C3(base.color ?? "#000000")]
|
|
27612
|
+
];
|
|
27613
|
+
const alpha = (base.alpha ?? 1) * (n.opacity ?? 1);
|
|
27614
|
+
if (alpha < 1)
|
|
27615
|
+
props.push(["TextTransparency", F(1 - alpha)]);
|
|
27616
|
+
const rich = segs.length > 1 && segs.some((s) => s.color !== base.color || s.weight !== base.weight || s.size !== base.size || s.family !== base.family || s.decoration || s.textCase || /italic/i.test(s.style) !== italic);
|
|
27617
|
+
const text = segs.map((s) => s.text).join("");
|
|
27618
|
+
if (rich) {
|
|
27619
|
+
props.push(["RichText", B(true)], ["Text", S(segs.map((s) => this.richRun(s, base)).join(""))]);
|
|
27620
|
+
} else {
|
|
27621
|
+
let value = text;
|
|
27622
|
+
if (base.textCase === "UPPER")
|
|
27623
|
+
value = value.toUpperCase();
|
|
27624
|
+
else if (base.textCase === "LOWER")
|
|
27625
|
+
value = value.toLowerCase();
|
|
27626
|
+
if (base.decoration)
|
|
27627
|
+
props.push(["RichText", B(true)], ["Text", S(this.richRun({ ...base, text: value, textCase: undefined }, { ...base, decoration: undefined }))]);
|
|
27628
|
+
else
|
|
27629
|
+
props.push(["Text", S(value)]);
|
|
27630
|
+
}
|
|
27631
|
+
props.push(["TextXAlignment", E("TextXAlignment", t.align === "CENTER" ? "Center" : t.align === "RIGHT" ? "Right" : "Left")]);
|
|
27632
|
+
props.push(["TextYAlignment", E("TextYAlignment", t.valign === "CENTER" ? "Center" : t.valign === "BOTTOM" ? "Bottom" : "Top")]);
|
|
27633
|
+
if (t.autoResize !== "WIDTH_AND_HEIGHT")
|
|
27634
|
+
props.push(["TextWrapped", B(true)]);
|
|
27635
|
+
if (base.lineHeight)
|
|
27636
|
+
props.push(["LineHeight", F(Math.max(1, Math.min(3, base.lineHeight / (base.size * 1.2))))]);
|
|
27637
|
+
if (t.maxLines)
|
|
27638
|
+
props.push(["TextTruncate", E("TextTruncate", "AtEnd")]);
|
|
27639
|
+
if (this.opts.textScaled)
|
|
27640
|
+
props.push(["TextScaled", B(true)]);
|
|
27641
|
+
if (segs.some((s) => s.letterSpacing))
|
|
27642
|
+
this.warn(n, "letter spacing has no Roblox equivalent: ignored");
|
|
27643
|
+
return props;
|
|
27644
|
+
}
|
|
27645
|
+
richRun(s, base) {
|
|
27646
|
+
let text = s.text.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """).replace(/'/g, "'");
|
|
27647
|
+
const attrs = [];
|
|
27648
|
+
if (s.color && s.color !== base.color)
|
|
27649
|
+
attrs.push(`color="${s.color}"`);
|
|
27650
|
+
if (s.size !== base.size)
|
|
27651
|
+
attrs.push(`size="${Math.round(s.size * this.textFactor)}"`);
|
|
27652
|
+
if (s.weight !== base.weight)
|
|
27653
|
+
attrs.push(`weight="${robloxWeight(s.weight).value}"`);
|
|
27654
|
+
if (s.family !== base.family)
|
|
27655
|
+
attrs.push(`family="${robloxFamily(s.family, this.opts.fonts).url}"`);
|
|
27656
|
+
if (s.alpha !== undefined && s.alpha < 1)
|
|
27657
|
+
attrs.push(`transparency="${num(1 - s.alpha)}"`);
|
|
27658
|
+
if (attrs.length)
|
|
27659
|
+
text = `<font ${attrs.join(" ")}>${text}</font>`;
|
|
27660
|
+
if (/italic/i.test(s.style) && !/italic/i.test(base.style))
|
|
27661
|
+
text = `<i>${text}</i>`;
|
|
27662
|
+
if (s.decoration === "UNDERLINE")
|
|
27663
|
+
text = `<u>${text}</u>`;
|
|
27664
|
+
if (s.decoration === "STRIKETHROUGH")
|
|
27665
|
+
text = `<s>${text}</s>`;
|
|
27666
|
+
if (s.textCase === "UPPER")
|
|
27667
|
+
text = `<uc>${text}</uc>`;
|
|
27668
|
+
if (s.textCase === "SMALL_CAPS" || s.textCase === "SMALL_CAPS_FORCED")
|
|
27669
|
+
text = `<sc>${text}</sc>`;
|
|
27670
|
+
return text;
|
|
27671
|
+
}
|
|
27672
|
+
node(n, parent, index) {
|
|
27673
|
+
if (n.hidden) {
|
|
27674
|
+
this.counts.skippedHidden++;
|
|
27675
|
+
return null;
|
|
27676
|
+
}
|
|
27677
|
+
const d = decide(n, this.opts);
|
|
27678
|
+
for (const a of d.approximations)
|
|
27679
|
+
this.warn(n, a);
|
|
27680
|
+
const button = !!parent && (n.clickable || BUTTON_NAME.test(n.name)) && d.kind !== "text";
|
|
27681
|
+
const geo = this.geometry(n, parent, index, d);
|
|
27682
|
+
const common = [...geo, ...this.automaticSize(n, d)];
|
|
27683
|
+
if (n.rotation)
|
|
27684
|
+
common.push(["Rotation", F(n.rotation)]);
|
|
27685
|
+
const children = [];
|
|
27686
|
+
let className = "Frame";
|
|
27687
|
+
let props = [];
|
|
27688
|
+
if (d.kind === "text") {
|
|
27689
|
+
className = n.clickable || BUTTON_NAME.test(n.name) ? "TextButton" : "TextLabel";
|
|
27690
|
+
props = this.textProps(n);
|
|
27691
|
+
if (className === "TextButton")
|
|
27692
|
+
props.push(["AutoButtonColor", B(false)]);
|
|
27693
|
+
const size = props.find(([k]) => k === "TextSize")[1].v;
|
|
27694
|
+
children.push(this.inst("UITextSizeConstraint", "UITextSizeConstraint", [["MaxTextSize", I(size)], ["MinTextSize", I(Math.max(1, Math.round(size * 0.5)))]]));
|
|
27695
|
+
return this.inst(className, n.name, [...common, ...props], children, n.id);
|
|
27696
|
+
}
|
|
27697
|
+
if (d.kind === "picture") {
|
|
27698
|
+
this.counts.pictures++;
|
|
27699
|
+
const a = this.assetUrl(`${n.id}:full`, n);
|
|
27700
|
+
className = button ? "ImageButton" : "ImageLabel";
|
|
27701
|
+
const pic = this.pictureGeometry(n, a, geo);
|
|
27702
|
+
props = [["BackgroundTransparency", F(1)], ["Image", { t: "Content", url: a.url }], ["ScaleType", E("ScaleType", "Stretch")]];
|
|
27703
|
+
if (n.opacity !== undefined && n.opacity < 1)
|
|
27704
|
+
props.push(["ImageTransparency", F(1 - n.opacity)]);
|
|
27705
|
+
if (button)
|
|
27706
|
+
props.push(["AutoButtonColor", B(false)]);
|
|
27707
|
+
children.push(this.inst("UIAspectRatioConstraint", "UIAspectRatioConstraint", [["AspectRatio", F((a.size?.w ?? n.w) / Math.max(1, a.size?.h ?? n.h))]]));
|
|
27708
|
+
return this.inst(className, n.name, [...pic, ...props], children, n.id);
|
|
27709
|
+
}
|
|
27710
|
+
const fills = visible(n.fills);
|
|
27711
|
+
const opacity = n.opacity ?? 1;
|
|
27712
|
+
const hasKids = !!n.children?.some((c) => !c.hidden);
|
|
27713
|
+
if (d.kind === "image") {
|
|
27714
|
+
const f = fills[0];
|
|
27715
|
+
const a = this.assetUrl(`image:${f.imageHash}`, n);
|
|
27716
|
+
className = button ? "ImageButton" : "ImageLabel";
|
|
27717
|
+
const scaleType = f.scaleMode === "FIT" ? "Fit" : f.scaleMode === "TILE" ? "Tile" : "Crop";
|
|
27718
|
+
props = [["BackgroundTransparency", F(1)], ["Image", { t: "Content", url: a.url }], ["ScaleType", E("ScaleType", scaleType)]];
|
|
27719
|
+
if (f.opacity * opacity < 1)
|
|
27720
|
+
props.push(["ImageTransparency", F(1 - f.opacity * opacity)]);
|
|
27721
|
+
if (button)
|
|
27722
|
+
props.push(["AutoButtonColor", B(false)]);
|
|
27723
|
+
const corner = this.corner(n);
|
|
27724
|
+
if (corner)
|
|
27725
|
+
children.push(corner);
|
|
27726
|
+
children.push(this.inst("UIAspectRatioConstraint", "UIAspectRatioConstraint", [["AspectRatio", F(n.w / Math.max(1, n.h))]]));
|
|
27727
|
+
return this.inst(className, n.name, [...common, ...props], children, n.id);
|
|
27728
|
+
}
|
|
27729
|
+
if (d.kind === "panel") {
|
|
27730
|
+
this.counts.pictures++;
|
|
27731
|
+
const a = this.assetUrl(`${n.id}:panel`, n);
|
|
27732
|
+
const scale = this.opts.scale ?? 2;
|
|
27733
|
+
const inset = Math.ceil((Math.max(0, ...n.radius ?? [0]) + (n.strokeWeight ?? 0) + 2) * scale);
|
|
27734
|
+
const pw = Math.round((a.size?.w ?? n.w) * scale);
|
|
27735
|
+
const ph = Math.round((a.size?.h ?? n.h) * scale);
|
|
27736
|
+
const ix = Math.min(inset, Math.floor(pw / 2) - 1);
|
|
27737
|
+
const iy = Math.min(inset, Math.floor(ph / 2) - 1);
|
|
27738
|
+
className = button ? "ImageButton" : "ImageLabel";
|
|
27739
|
+
props = [
|
|
27740
|
+
["BackgroundTransparency", F(1)],
|
|
27741
|
+
["Image", { t: "Content", url: a.url }],
|
|
27742
|
+
["ScaleType", E("ScaleType", "Slice")],
|
|
27743
|
+
["SliceCenter", { t: "Rect", x0: ix, y0: iy, x1: pw - ix, y1: ph - iy }],
|
|
27744
|
+
["SliceScale", F(1 / scale)]
|
|
27745
|
+
];
|
|
27746
|
+
if (opacity < 1)
|
|
27747
|
+
props.push(["ImageTransparency", F(1 - opacity)]);
|
|
27748
|
+
if (button)
|
|
27749
|
+
props.push(["AutoButtonColor", B(false)]);
|
|
27750
|
+
} else {
|
|
27751
|
+
className = button ? "TextButton" : hasKids && opacity < 1 ? "CanvasGroup" : "Frame";
|
|
27752
|
+
const f = fills[0];
|
|
27753
|
+
if (!f)
|
|
27754
|
+
props.push(["BackgroundTransparency", F(1)]);
|
|
27755
|
+
else if (f.type === "SOLID") {
|
|
27756
|
+
props.push(["BackgroundColor3", C3(f.color)]);
|
|
27757
|
+
const t = 1 - f.opacity * (className === "CanvasGroup" ? 1 : opacity);
|
|
27758
|
+
if (t > 0)
|
|
27759
|
+
props.push(["BackgroundTransparency", F(t)]);
|
|
27760
|
+
} else if (f.type === "GRADIENT_LINEAR") {
|
|
27761
|
+
props.push(["BackgroundColor3", C3("#FFFFFF")]);
|
|
27762
|
+
children.push(this.gradient(f));
|
|
27763
|
+
} else if (f.type === "IMAGE") {
|
|
27764
|
+
props.push(["BackgroundTransparency", F(1)]);
|
|
27765
|
+
const a = this.assetUrl(`image:${f.imageHash}`, n);
|
|
27766
|
+
const bg = this.inst("ImageLabel", "Background", [["Size", U2(1, 0, 1, 0)], ["ZIndex", I(0)], ["BackgroundTransparency", F(1)], ["Image", { t: "Content", url: a.url }], ["ScaleType", E("ScaleType", f.scaleMode === "FIT" ? "Fit" : "Crop")]]);
|
|
27767
|
+
const corner = this.corner(n);
|
|
27768
|
+
if (corner)
|
|
27769
|
+
bg.children.push(corner);
|
|
27770
|
+
children.push(bg);
|
|
27771
|
+
}
|
|
27772
|
+
if (className === "CanvasGroup")
|
|
27773
|
+
props.push(["GroupTransparency", F(1 - opacity)]);
|
|
27774
|
+
if (button)
|
|
27775
|
+
props.push(["Text", S("")], ["AutoButtonColor", B(false)]);
|
|
27776
|
+
const corner = this.corner(n);
|
|
27777
|
+
if (corner)
|
|
27778
|
+
children.push(corner);
|
|
27779
|
+
const stroke = this.stroke(n);
|
|
27780
|
+
if (stroke)
|
|
27781
|
+
children.push(stroke);
|
|
27782
|
+
}
|
|
27783
|
+
props.push(["BorderSizePixel", I(0)]);
|
|
27784
|
+
if (n.clip)
|
|
27785
|
+
props.push(["ClipsDescendants", B(true)]);
|
|
27786
|
+
const kids = n.children ?? [];
|
|
27787
|
+
const flow = n.layout ? kids.filter((k) => !k.absolute) : kids;
|
|
27788
|
+
const loose = n.layout ? kids.filter((k) => k.absolute) : [];
|
|
27789
|
+
const me = { w: n.w, h: n.h, layout: n.layout };
|
|
27790
|
+
const mapped = (list, p) => list.map((k, i) => this.node(k, p, i)).filter((x) => !!x);
|
|
27791
|
+
const flowMapped = mapped(flow, me);
|
|
27792
|
+
if (n.layout && n.layout.mode !== "GRID") {
|
|
27793
|
+
for (const k of flow) {
|
|
27794
|
+
const target = k.grow && !k.hidden ? flowMapped.find((c) => c.source === k.id) : undefined;
|
|
27795
|
+
if (target)
|
|
27796
|
+
target.children.push(this.inst("UIFlexItem", "UIFlexItem", [["FlexMode", E("UIFlexMode", "Fill")]]));
|
|
27797
|
+
}
|
|
27798
|
+
}
|
|
27799
|
+
if (n.layout && loose.length) {
|
|
27800
|
+
const contentZ = kids.findIndex((k) => !k.absolute) + 1;
|
|
27801
|
+
const content = this.inst("Frame", "Content", [["Size", U2(1, 0, 1, 0)], ["ZIndex", I(contentZ)], ["BackgroundTransparency", F(1)], ["BorderSizePixel", I(0)]], [...this.layoutChildren(n), ...flowMapped]);
|
|
27802
|
+
const overlay = loose.map((k) => this.node(k, { w: n.w, h: n.h }, kids.indexOf(k))).filter((x) => !!x);
|
|
27803
|
+
children.push(content, ...overlay);
|
|
27804
|
+
} else {
|
|
27805
|
+
if (n.layout)
|
|
27806
|
+
children.push(...this.layoutChildren(n));
|
|
27807
|
+
children.push(...flowMapped);
|
|
27808
|
+
}
|
|
27809
|
+
const self = this.inst(className, n.name, [...common, ...props], children, n.id);
|
|
27810
|
+
if (!d.shadow)
|
|
27811
|
+
return self;
|
|
27812
|
+
this.counts.pictures++;
|
|
27813
|
+
const a = this.assetUrl(`${n.id}:shadow`, n);
|
|
27814
|
+
const scale = this.opts.scale ?? 2;
|
|
27815
|
+
const sh = (n.effects ?? []).filter((e) => e.type === "DROP_SHADOW");
|
|
27816
|
+
const reach = Math.max(...sh.map((e) => e.blur + (e.spread ?? 0) + Math.max(Math.abs(e.x ?? 0), Math.abs(e.y ?? 0))));
|
|
27817
|
+
const off = a.offset ?? { x: 0, y: 0 };
|
|
27818
|
+
const size = a.size ?? { w: n.w, h: n.h };
|
|
27819
|
+
const inset = Math.ceil((Math.max(0, ...n.radius ?? [0]) + reach) * scale);
|
|
27820
|
+
const pw = Math.round(size.w * scale);
|
|
27821
|
+
const ph = Math.round(size.h * scale);
|
|
27822
|
+
const ix = Math.min(inset, Math.floor(pw / 2) - 1);
|
|
27823
|
+
const iy = Math.min(inset, Math.floor(ph / 2) - 1);
|
|
27824
|
+
const shadow = this.inst("ImageLabel", "Shadow", [
|
|
27825
|
+
["Position", U2(0, off.x, 0, off.y)],
|
|
27826
|
+
["Size", U2(1, size.w - n.w, 1, size.h - n.h)],
|
|
27827
|
+
["ZIndex", I(1)],
|
|
27828
|
+
["BackgroundTransparency", F(1)],
|
|
27829
|
+
["Image", { t: "Content", url: a.url }],
|
|
27830
|
+
["ScaleType", E("ScaleType", "Slice")],
|
|
27831
|
+
["SliceCenter", { t: "Rect", x0: ix, y0: iy, x1: pw - ix, y1: ph - iy }],
|
|
27832
|
+
["SliceScale", F(1 / scale)]
|
|
27833
|
+
]);
|
|
27834
|
+
self.props = self.props.filter(([k]) => !["AnchorPoint", "Position", "Size", "LayoutOrder", "AutomaticSize"].includes(k));
|
|
27835
|
+
self.props.splice(1, 0, ["Size", U2(1, 0, 1, 0)], ["ZIndex", I(2)]);
|
|
27836
|
+
return this.inst("Frame", n.name, [...common.filter(([k]) => k !== "AutomaticSize"), ["BackgroundTransparency", F(1)], ["BorderSizePixel", I(0)]], [shadow, self], n.id);
|
|
27837
|
+
}
|
|
27838
|
+
pictureGeometry(n, a, geo) {
|
|
27839
|
+
if (!a.offset || !a.size || Math.abs(a.offset.x) < 0.5 && Math.abs(a.offset.y) < 0.5 && Math.abs(a.size.w - n.w) < 0.5 && Math.abs(a.size.h - n.h) < 0.5)
|
|
27840
|
+
return geo;
|
|
27841
|
+
return geo.map(([k, v]) => {
|
|
27842
|
+
if (k === "Size" && v.t === "UDim2")
|
|
27843
|
+
return [k, { ...v, xo: Math.round(v.xo + a.size.w - n.w), yo: Math.round(v.yo + a.size.h - n.h) }];
|
|
27844
|
+
if (k === "Position" && v.t === "UDim2")
|
|
27845
|
+
return [k, { ...v, xo: Math.round(v.xo + a.offset.x), yo: Math.round(v.yo + a.offset.y) }];
|
|
27846
|
+
return [k, v];
|
|
27847
|
+
});
|
|
27848
|
+
}
|
|
27849
|
+
}
|
|
27850
|
+
function mapToRoblox(root, opts, assets) {
|
|
27851
|
+
const m = new Mapper(opts, assets);
|
|
27852
|
+
const out = m.map(root);
|
|
27853
|
+
if (m.counts.skippedHidden)
|
|
27854
|
+
m.warn(null, `${m.counts.skippedHidden} hidden layer(s) skipped`);
|
|
27855
|
+
return { root: out, warnings: [...m.warnings], fontSubstitutions: Object.fromEntries(m.fontSubstitutions), counts: m.counts };
|
|
27856
|
+
}
|
|
27857
|
+
var num = (v) => Math.round(v * 1000) / 1000, S = (v) => ({ t: "string", v }), B = (v) => ({ t: "bool", v }), I = (v) => ({ t: "int", v: Math.round(v) }), F = (v) => ({ t: "float", v: num(v) }), E = (e, v) => ({ t: "enum", e, v }), U = (s, o) => ({ t: "UDim", s: num(s), o: Math.round(o) }), U2 = (xs, xo, ys, yo) => ({ t: "UDim2", xs: num(xs), xo: Math.round(xo), ys: num(ys), yo: Math.round(yo) }), V2 = (x, y) => ({ t: "Vector2", x: num(x), y: num(y) }), C3 = (hex) => ({ t: "Color3", ...color3(hex) }), BUTTON_NAME;
|
|
27858
|
+
var init_map2 = __esm(() => {
|
|
27859
|
+
init_fonts();
|
|
27860
|
+
BUTTON_NAME = /\b(button|btn|cta)\b/i;
|
|
27861
|
+
});
|
|
27862
|
+
|
|
27863
|
+
// src/roblox/output.ts
|
|
27864
|
+
function xmlProp(name, v) {
|
|
27865
|
+
switch (v.t) {
|
|
27866
|
+
case "string":
|
|
27867
|
+
return `<string name="${name}">${xml(v.v)}</string>`;
|
|
27868
|
+
case "bool":
|
|
27869
|
+
return `<bool name="${name}">${v.v}</bool>`;
|
|
27870
|
+
case "int":
|
|
27871
|
+
return `<int name="${name}">${v.v}</int>`;
|
|
27872
|
+
case "float":
|
|
27873
|
+
return `<float name="${name}">${n(v.v)}</float>`;
|
|
27874
|
+
case "Color3":
|
|
27875
|
+
return `<Color3 name="${name}"><R>${n(v.r)}</R><G>${n(v.g)}</G><B>${n(v.b)}</B></Color3>`;
|
|
27876
|
+
case "UDim":
|
|
27877
|
+
return `<UDim name="${name}"><S>${n(v.s)}</S><O>${v.o}</O></UDim>`;
|
|
27878
|
+
case "UDim2":
|
|
27879
|
+
return `<UDim2 name="${name}"><XS>${n(v.xs)}</XS><XO>${v.xo}</XO><YS>${n(v.ys)}</YS><YO>${v.yo}</YO></UDim2>`;
|
|
27880
|
+
case "Vector2":
|
|
27881
|
+
return `<Vector2 name="${name}"><X>${n(v.x)}</X><Y>${n(v.y)}</Y></Vector2>`;
|
|
27882
|
+
case "Rect":
|
|
27883
|
+
return `<Rect2D name="${name}"><min><X>${n(v.x0)}</X><Y>${n(v.y0)}</Y></min><max><X>${n(v.x1)}</X><Y>${n(v.y1)}</Y></max></Rect2D>`;
|
|
27884
|
+
case "enum": {
|
|
27885
|
+
const value = ENUMS[v.e]?.[v.v];
|
|
27886
|
+
if (value === undefined)
|
|
27887
|
+
throw new Error(`No enum value for ${v.e}.${v.v}`);
|
|
27888
|
+
return `<token name="${name}">${value}</token>`;
|
|
27889
|
+
}
|
|
27890
|
+
case "Content":
|
|
27891
|
+
return v.url ? `<Content name="${name}"><url>${xml(v.url)}</url></Content>` : `<Content name="${name}"><null></null></Content>`;
|
|
27892
|
+
case "ColorSequence":
|
|
27893
|
+
return `<ColorSequence name="${name}">${v.keys.map((k) => `${n(k.time)} ${n(k.r)} ${n(k.g)} ${n(k.b)} 0 `).join("")}</ColorSequence>`;
|
|
27894
|
+
case "NumberSequence":
|
|
27895
|
+
return `<NumberSequence name="${name}">${v.keys.map((k) => `${n(k.time)} ${n(k.value)} 0 `).join("")}</NumberSequence>`;
|
|
27896
|
+
case "Font":
|
|
27897
|
+
return `<Font name="${name}"><Family><url>${xml(v.family)}</url></Family><Weight>${v.weight}</Weight><Style>${v.style}</Style></Font>`;
|
|
27898
|
+
}
|
|
27899
|
+
}
|
|
27900
|
+
function toRbxmx(root) {
|
|
27901
|
+
let ref = 0;
|
|
27902
|
+
const item = (i, depth) => {
|
|
27903
|
+
const pad = "\t".repeat(depth);
|
|
27904
|
+
const props = i.props.map(([k, v]) => `${pad} ${xmlProp(k, v)}`).join(`
|
|
27905
|
+
`);
|
|
27906
|
+
const kids = i.children.map((c) => item(c, depth + 1)).join(`
|
|
27907
|
+
`);
|
|
27908
|
+
return `${pad}<Item class="${i.className}" referent="RBX${(ref++).toString(16).toUpperCase().padStart(8, "0")}">
|
|
27909
|
+
${pad} <Properties>
|
|
27910
|
+
${props}
|
|
27911
|
+
${pad} </Properties>${kids ? `
|
|
27912
|
+
${kids}` : ""}
|
|
27913
|
+
${pad}</Item>`;
|
|
27914
|
+
};
|
|
27915
|
+
return `<roblox xmlns:xmime="http://www.w3.org/2005/05/xmlmime" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="http://www.roblox.com/roblox.xsd" version="4">
|
|
27916
|
+
` + ` <External>null</External>
|
|
27917
|
+
<External>nil</External>
|
|
27918
|
+
` + item(root, 1) + `
|
|
27919
|
+
</roblox>
|
|
27920
|
+
`;
|
|
27921
|
+
}
|
|
27922
|
+
function luauString(s) {
|
|
27923
|
+
return `"${s.replace(/\\/g, "\\\\").replace(/"/g, "\\\"").replace(/\n/g, "\\n").replace(/\r/g, "\\r").replace(/\t/g, "\\t").replace(/[\x00-\x08\x0b\x0c\x0e-\x1f]/g, (c) => `\\${c.charCodeAt(0)}`)}"`;
|
|
27924
|
+
}
|
|
27925
|
+
function luauValue(v) {
|
|
27926
|
+
switch (v.t) {
|
|
27927
|
+
case "string":
|
|
27928
|
+
return luauString(v.v);
|
|
27929
|
+
case "bool":
|
|
27930
|
+
return String(v.v);
|
|
27931
|
+
case "int":
|
|
27932
|
+
return String(v.v);
|
|
27933
|
+
case "float":
|
|
27934
|
+
return n(v.v);
|
|
27935
|
+
case "Color3":
|
|
27936
|
+
return `Color3.fromRGB(${rgb(v.r)}, ${rgb(v.g)}, ${rgb(v.b)})`;
|
|
27937
|
+
case "UDim":
|
|
27938
|
+
return `UDim.new(${n(v.s)}, ${v.o})`;
|
|
27939
|
+
case "UDim2":
|
|
27940
|
+
if (!v.xo && !v.yo)
|
|
27941
|
+
return `UDim2.fromScale(${n(v.xs)}, ${n(v.ys)})`;
|
|
27942
|
+
if (!v.xs && !v.ys)
|
|
27943
|
+
return `UDim2.fromOffset(${v.xo}, ${v.yo})`;
|
|
27944
|
+
return `UDim2.new(${n(v.xs)}, ${v.xo}, ${n(v.ys)}, ${v.yo})`;
|
|
27945
|
+
case "Vector2":
|
|
27946
|
+
return `Vector2.new(${n(v.x)}, ${n(v.y)})`;
|
|
27947
|
+
case "Rect":
|
|
27948
|
+
return `Rect.new(${n(v.x0)}, ${n(v.y0)}, ${n(v.x1)}, ${n(v.y1)})`;
|
|
27949
|
+
case "enum":
|
|
27950
|
+
return `Enum.${v.e}.${v.v}`;
|
|
27951
|
+
case "Content":
|
|
27952
|
+
return luauString(v.url);
|
|
27953
|
+
case "ColorSequence":
|
|
27954
|
+
return `ColorSequence.new({
|
|
27955
|
+
${v.keys.map((k) => ` ColorSequenceKeypoint.new(${n(k.time)}, Color3.fromRGB(${rgb(k.r)}, ${rgb(k.g)}, ${rgb(k.b)})),`).join(`
|
|
27956
|
+
`)}
|
|
27957
|
+
})`;
|
|
27958
|
+
case "NumberSequence":
|
|
27959
|
+
return `NumberSequence.new({
|
|
27960
|
+
${v.keys.map((k) => ` NumberSequenceKeypoint.new(${n(k.time)}, ${n(k.value)}),`).join(`
|
|
27961
|
+
`)}
|
|
27962
|
+
})`;
|
|
27963
|
+
case "Font":
|
|
27964
|
+
return `Font.new(${luauString(v.family)}, Enum.FontWeight.${v.weightName}, Enum.FontStyle.${v.style})`;
|
|
27965
|
+
}
|
|
27966
|
+
}
|
|
27967
|
+
function identifier(name, className, used) {
|
|
27968
|
+
const words = name.normalize("NFKD").replace(/[\u0300-\u036f]/g, "").replace(/([a-z0-9])([A-Z])/g, "$1 $2").split(/[^A-Za-z0-9]+/).filter(Boolean).slice(0, 5);
|
|
27969
|
+
let id = words.map((w, i) => i ? w[0].toUpperCase() + w.slice(1).toLowerCase() : w.toLowerCase()).join("");
|
|
27970
|
+
if (!id || /^\d/.test(id))
|
|
27971
|
+
id = className[0].toLowerCase() + className.slice(1) + (id ? id[0].toUpperCase() + id.slice(1) : "");
|
|
27972
|
+
if (KEYWORDS.has(id))
|
|
27973
|
+
id += "Gui";
|
|
27974
|
+
let unique = id;
|
|
27975
|
+
for (let k = 2;used.has(unique); k++)
|
|
27976
|
+
unique = `${id}${k}`;
|
|
27977
|
+
used.add(unique);
|
|
27978
|
+
return unique;
|
|
27979
|
+
}
|
|
27980
|
+
function toLuau(root, opts = {}) {
|
|
27981
|
+
const used = new Set;
|
|
27982
|
+
const lines = [];
|
|
27983
|
+
const emit = (i, parentVar, depth) => {
|
|
27984
|
+
const v = identifier(i.name, i.className, used);
|
|
27985
|
+
if (!/^UI/.test(i.className) && depth <= 2 && parentVar)
|
|
27986
|
+
lines.push("", `-- ${i.name.replace(/\n/g, " ")}`);
|
|
27987
|
+
lines.push(`local ${v} = Instance.new(${luauString(i.className)})`);
|
|
27988
|
+
for (const [k, val] of i.props)
|
|
27989
|
+
lines.push(`${v}.${k} = ${luauValue(val)}`);
|
|
27990
|
+
for (const c of i.children)
|
|
27991
|
+
emit(c, v, depth + 1);
|
|
27992
|
+
if (parentVar)
|
|
27993
|
+
lines.push(`${v}.Parent = ${parentVar}`);
|
|
27994
|
+
return v;
|
|
27995
|
+
};
|
|
27996
|
+
const header = [
|
|
27997
|
+
`-- ${root.name}: generated by Figma Bridge${opts.source ? ` from "${opts.source.replace(/\n/g, " ")}"` : ""} (export_roblox).`,
|
|
27998
|
+
"-- Creates the UI under PARENT and returns it. Running it again replaces the previous copy.",
|
|
27999
|
+
`local PARENT = ${opts.parent ?? 'game:GetService("StarterGui")'}`,
|
|
28000
|
+
"",
|
|
28001
|
+
`local existing = PARENT:FindFirstChild(${luauString(root.name)})`,
|
|
28002
|
+
"if existing then",
|
|
28003
|
+
"\texisting:Destroy()",
|
|
28004
|
+
"end",
|
|
28005
|
+
""
|
|
28006
|
+
];
|
|
28007
|
+
const rootVar = emit(root, null, 0);
|
|
28008
|
+
return `${header.join(`
|
|
28009
|
+
`)}
|
|
28010
|
+
${lines.join(`
|
|
28011
|
+
`)}
|
|
28012
|
+
|
|
28013
|
+
${rootVar}.Parent = PARENT
|
|
28014
|
+
return ${rootVar}
|
|
28015
|
+
`.replace(/\n{3,}/g, `
|
|
28016
|
+
|
|
28017
|
+
`);
|
|
28018
|
+
}
|
|
28019
|
+
function substituteAssets(text, ids) {
|
|
28020
|
+
return text.replace(/rbxassetid:\/\/PENDING_\d+/g, (m) => ids[m] ? `rbxassetid://${ids[m]}` : m);
|
|
28021
|
+
}
|
|
28022
|
+
var ENUMS, n = (v) => String(Math.round(v * 1000) / 1000), xml = (s) => s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """), KEYWORDS, rgb = (v) => Math.round(v * 255);
|
|
28023
|
+
var init_output = __esm(() => {
|
|
28024
|
+
ENUMS = {
|
|
28025
|
+
ZIndexBehavior: { Global: 0, Sibling: 1 },
|
|
28026
|
+
FillDirection: { Horizontal: 0, Vertical: 1 },
|
|
28027
|
+
HorizontalAlignment: { Center: 0, Left: 1, Right: 2 },
|
|
28028
|
+
VerticalAlignment: { Center: 0, Top: 1, Bottom: 2 },
|
|
28029
|
+
SortOrder: { Name: 0, Custom: 1, LayoutOrder: 2 },
|
|
28030
|
+
UIFlexAlignment: { None: 0, Fill: 1, SpaceAround: 2, SpaceBetween: 3, SpaceEvenly: 4 },
|
|
28031
|
+
UIFlexMode: { None: 0, Grow: 1, Shrink: 2, Fill: 3, Custom: 4 },
|
|
28032
|
+
AutomaticSize: { None: 0, X: 1, Y: 2, XY: 3 },
|
|
28033
|
+
TextXAlignment: { Left: 0, Right: 1, Center: 2 },
|
|
28034
|
+
TextYAlignment: { Top: 0, Center: 1, Bottom: 2 },
|
|
28035
|
+
ScaleType: { Stretch: 0, Slice: 1, Tile: 2, Fit: 3, Crop: 4 },
|
|
28036
|
+
ApplyStrokeMode: { Contextual: 0, Border: 1 },
|
|
28037
|
+
LineJoinMode: { Round: 0, Bevel: 1, Miter: 2 },
|
|
28038
|
+
TextTruncate: { None: 0, AtEnd: 1, SplitWord: 2 }
|
|
28039
|
+
};
|
|
28040
|
+
KEYWORDS = new Set(["and", "break", "do", "else", "elseif", "end", "false", "for", "function", "if", "in", "local", "nil", "not", "or", "repeat", "return", "then", "true", "until", "while", "continue", "export", "type", "game", "workspace", "script", "PARENT", "existing"]);
|
|
28041
|
+
});
|
|
28042
|
+
|
|
28043
|
+
// src/roblox/upload.ts
|
|
28044
|
+
import { createHash } from "node:crypto";
|
|
28045
|
+
import { mkdirSync as mkdirSync2, readFileSync as readFileSync2, writeFileSync as writeFileSync2 } from "node:fs";
|
|
28046
|
+
import { dirname as dirname2 } from "node:path";
|
|
28047
|
+
function creatorFrom(id) {
|
|
28048
|
+
const m = /^(?:(user|group)[:/])?\s*(\d+)$/i.exec(id.trim());
|
|
28049
|
+
if (!m)
|
|
28050
|
+
throw new RobloxUploadError(`ROBLOX_CREATOR_ID must be a user id, "user:<id>" or "group:<id>", not "${id}".`, "BAD_CREATOR");
|
|
28051
|
+
return m[1]?.toLowerCase() === "group" ? { groupId: m[2] } : { userId: m[2] };
|
|
28052
|
+
}
|
|
28053
|
+
|
|
28054
|
+
class RobloxUploader {
|
|
28055
|
+
opts;
|
|
28056
|
+
cache = {};
|
|
28057
|
+
fetchImpl;
|
|
28058
|
+
sleep;
|
|
28059
|
+
base;
|
|
28060
|
+
constructor(opts) {
|
|
28061
|
+
this.opts = opts;
|
|
28062
|
+
this.fetchImpl = opts.fetchImpl ?? fetch;
|
|
28063
|
+
this.sleep = opts.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
|
|
28064
|
+
this.base = opts.base ?? process.env.ROBLOX_API_URL ?? "https://apis.roblox.com";
|
|
28065
|
+
try {
|
|
28066
|
+
this.cache = JSON.parse(readFileSync2(opts.cacheFile, "utf8"));
|
|
28067
|
+
} catch {}
|
|
28068
|
+
}
|
|
28069
|
+
static hash(bytes) {
|
|
28070
|
+
return createHash("sha256").update(bytes).digest("hex");
|
|
28071
|
+
}
|
|
28072
|
+
async request(url, init) {
|
|
28073
|
+
let delay = 1000;
|
|
28074
|
+
for (let attempt = 0;; attempt++) {
|
|
28075
|
+
const res = await this.fetchImpl(url, { ...init, headers: { ...init.headers, "x-api-key": this.opts.apiKey }, signal: AbortSignal.timeout(60000) });
|
|
28076
|
+
if (res.ok)
|
|
28077
|
+
return res.json();
|
|
28078
|
+
const retryAfter = Number(res.headers.get("retry-after"));
|
|
28079
|
+
if ((res.status === 429 || res.status >= 500) && attempt < 3) {
|
|
28080
|
+
await this.sleep(retryAfter > 0 ? retryAfter * 1000 : delay);
|
|
28081
|
+
delay *= 2;
|
|
28082
|
+
continue;
|
|
28083
|
+
}
|
|
28084
|
+
let detail = "";
|
|
28085
|
+
try {
|
|
28086
|
+
const body = await res.json();
|
|
28087
|
+
detail = body.message ?? body.errors?.[0]?.message ?? "";
|
|
28088
|
+
} catch {}
|
|
28089
|
+
if (res.status === 401 || res.status === 403)
|
|
28090
|
+
throw new RobloxUploadError(`Roblox refused the API key (${res.status}${detail ? `: ${detail}` : ""}). It needs the Assets API with Read and Write, for this creator.`, "FORBIDDEN");
|
|
28091
|
+
throw new RobloxUploadError(`Roblox Open Cloud error ${res.status}${detail ? `: ${detail}` : ""}`, "UPLOAD_FAILED");
|
|
28092
|
+
}
|
|
28093
|
+
}
|
|
28094
|
+
async upload(bytes, name) {
|
|
28095
|
+
const hash = RobloxUploader.hash(bytes);
|
|
28096
|
+
if (this.cache[hash])
|
|
28097
|
+
return { assetId: this.cache[hash], cached: true };
|
|
28098
|
+
const form = new FormData;
|
|
28099
|
+
form.append("request", JSON.stringify({
|
|
28100
|
+
assetType: "Image",
|
|
28101
|
+
displayName: name.slice(0, 50) || "Figma Bridge image",
|
|
28102
|
+
description: "Uploaded by Figma Bridge (export_roblox)",
|
|
28103
|
+
creationContext: { creator: creatorFrom(this.opts.creatorId) }
|
|
28104
|
+
}));
|
|
28105
|
+
form.append("fileContent", new Blob([new Uint8Array(bytes)], { type: "image/png" }), `${name.replace(/[^\w.-]+/g, "_") || "image"}.png`);
|
|
28106
|
+
let op = await this.request(`${this.base}/assets/v1/assets`, { method: "POST", body: form });
|
|
28107
|
+
for (let i = 0;!op.done; i++) {
|
|
28108
|
+
if (i >= 20)
|
|
28109
|
+
throw new RobloxUploadError(`Roblox is still processing "${name}": try again in a minute.`, "UPLOAD_PENDING");
|
|
28110
|
+
await this.sleep(Math.min(500 * 2 ** i, 4000));
|
|
28111
|
+
const id = String(op.operationId ?? op.path ?? "").replace(/^operations\//, "");
|
|
28112
|
+
if (!id)
|
|
28113
|
+
throw new RobloxUploadError("Roblox returned no operation to wait for.", "UPLOAD_FAILED");
|
|
28114
|
+
op = await this.request(`${this.base}/assets/v1/operations/${id}`, { method: "GET" });
|
|
28115
|
+
}
|
|
28116
|
+
const assetId = String(op.response?.assetId ?? "");
|
|
28117
|
+
if (!assetId)
|
|
28118
|
+
throw new RobloxUploadError(`Upload of "${name}" finished without an asset id${op.error?.message ? `: ${op.error.message}` : ""}.`, "UPLOAD_FAILED");
|
|
28119
|
+
this.cache[hash] = assetId;
|
|
28120
|
+
mkdirSync2(dirname2(this.opts.cacheFile), { recursive: true });
|
|
28121
|
+
writeFileSync2(this.opts.cacheFile, JSON.stringify(this.cache, null, 2));
|
|
28122
|
+
return { assetId, cached: false };
|
|
28123
|
+
}
|
|
28124
|
+
}
|
|
28125
|
+
var RobloxUploadError, UPLOAD_HELP;
|
|
28126
|
+
var init_upload = __esm(() => {
|
|
28127
|
+
RobloxUploadError = class RobloxUploadError extends Error {
|
|
28128
|
+
code;
|
|
28129
|
+
constructor(message, code) {
|
|
28130
|
+
super(message);
|
|
28131
|
+
this.code = code;
|
|
28132
|
+
}
|
|
28133
|
+
};
|
|
28134
|
+
UPLOAD_HELP = "To upload pictures automatically, create an Open Cloud API key at create.roblox.com → Open Cloud → API Keys with the " + "Assets API (Read and Write) and your experience's creator, then set ROBLOX_API_KEY and ROBLOX_CREATOR_ID (your user id, " + 'or "group:<id>") in the Figma Bridge env of your AI client, and call export_roblox with upload:true.';
|
|
28135
|
+
});
|
|
28136
|
+
|
|
28137
|
+
// src/roblox/export.ts
|
|
28138
|
+
import { mkdirSync as mkdirSync3, writeFileSync as writeFileSync3 } from "node:fs";
|
|
28139
|
+
import { homedir } from "node:os";
|
|
28140
|
+
import { join as join2 } from "node:path";
|
|
28141
|
+
async function exportRoblox(args, deps) {
|
|
28142
|
+
const env = deps.env ?? process.env;
|
|
28143
|
+
const opts = {
|
|
28144
|
+
mode: args.mode ?? "hybrid",
|
|
28145
|
+
targetResolution: args.targetResolution ?? [1920, 1080],
|
|
28146
|
+
rasterize: args.rasterize ?? "auto",
|
|
28147
|
+
asRootFrame: args.asRootFrame,
|
|
28148
|
+
textScaled: args.textScaled,
|
|
28149
|
+
fonts: args.fonts,
|
|
28150
|
+
scale: 2
|
|
28151
|
+
};
|
|
28152
|
+
const t = await deps.request("roblox_tree", { nodeId: args.nodeId }, 120000);
|
|
28153
|
+
const warnings = [];
|
|
28154
|
+
if (t.truncated)
|
|
28155
|
+
warnings.push(`Stopped after ${t.nodes} layers: export a smaller frame for the rest.`);
|
|
28156
|
+
const wanted = pictureRequests(t.tree, opts);
|
|
28157
|
+
const pictures = new Map;
|
|
28158
|
+
for (let i = 0;i < wanted.length; i += 20) {
|
|
28159
|
+
const r = await deps.request("roblox_images", { items: wanted.slice(i, i + 20).map((w) => ({ ...w, scale: opts.scale })) }, 120000);
|
|
28160
|
+
for (const img of r.images) {
|
|
28161
|
+
if (img.b64)
|
|
28162
|
+
pictures.set(`${img.id}:${img.mode}`, { b64: img.b64, offset: img.offset, size: img.size });
|
|
28163
|
+
else
|
|
28164
|
+
warnings.push(`Picture of ${img.id} failed: ${img.error}`);
|
|
28165
|
+
}
|
|
28166
|
+
}
|
|
28167
|
+
const assets = [];
|
|
28168
|
+
const files = new Map;
|
|
28169
|
+
const refs = new Map;
|
|
28170
|
+
const names = new Map;
|
|
28171
|
+
const layerNames = (n) => {
|
|
28172
|
+
names.set(n.id, n.name);
|
|
28173
|
+
for (const c of n.children ?? [])
|
|
28174
|
+
layerNames(c);
|
|
28175
|
+
};
|
|
28176
|
+
layerNames(t.tree);
|
|
28177
|
+
const add = (key, bytes, a, ext, ref) => {
|
|
28178
|
+
const placeholder = `rbxassetid://PENDING_${assets.length + 1}`;
|
|
28179
|
+
const file = `${String(assets.length + 1).padStart(2, "0")}-${safe(a.layer)}${a.mode && a.mode !== "full" ? `-${a.mode}` : ""}.${ext}`;
|
|
28180
|
+
assets.push({ placeholder, file, ...a });
|
|
28181
|
+
files.set(file, bytes);
|
|
28182
|
+
refs.set(key, { url: placeholder, ...ref });
|
|
28183
|
+
};
|
|
28184
|
+
for (const w of wanted) {
|
|
28185
|
+
const p = pictures.get(`${w.id}:${w.mode}`);
|
|
28186
|
+
if (!p)
|
|
28187
|
+
continue;
|
|
28188
|
+
add(`${w.id}:${w.mode}`, new Uint8Array(Buffer.from(p.b64, "base64")), { nodeId: w.id, layer: names.get(w.id) ?? w.id, kind: "picture", mode: w.mode }, "png", { offset: p.offset, size: p.size });
|
|
28189
|
+
}
|
|
28190
|
+
for (const hash of imageHashes(t.tree)) {
|
|
28191
|
+
const b64 = t.images[hash];
|
|
28192
|
+
if (!b64)
|
|
28193
|
+
continue;
|
|
28194
|
+
const bytes = new Uint8Array(Buffer.from(b64, "base64"));
|
|
28195
|
+
const info = imageInfo(bytes);
|
|
28196
|
+
if (info && info.format !== "png" && info.format !== "jpeg")
|
|
28197
|
+
warnings.push(`An image is ${info.format.toUpperCase()}: Roblox takes PNG, JPG, BMP or TGA, convert it before uploading.`);
|
|
28198
|
+
const owner = findImageOwner(t.tree, hash);
|
|
28199
|
+
add(`image:${hash}`, bytes, { nodeId: owner?.id ?? "", layer: owner?.name ?? "image", kind: "image" }, info?.format === "jpeg" ? "jpg" : info?.format ?? "png", {});
|
|
28200
|
+
}
|
|
28201
|
+
const mapped = mapToRoblox(t.tree, opts, (key) => refs.get(key));
|
|
28202
|
+
warnings.push(...mapped.warnings);
|
|
28203
|
+
let rbxmx = toRbxmx(mapped.root);
|
|
28204
|
+
let luau = toLuau(mapped.root, { parent: args.parent, source: `${t.tree.name}" in "${t.fileName}` });
|
|
28205
|
+
let uploaded = false;
|
|
28206
|
+
if (args.upload) {
|
|
28207
|
+
if (!env.ROBLOX_API_KEY || !env.ROBLOX_CREATOR_ID)
|
|
28208
|
+
warnings.push(`upload skipped: ROBLOX_API_KEY and ROBLOX_CREATOR_ID are not set. ${UPLOAD_HELP}`);
|
|
28209
|
+
else if (assets.length) {
|
|
28210
|
+
const uploader = new RobloxUploader({
|
|
28211
|
+
apiKey: env.ROBLOX_API_KEY,
|
|
28212
|
+
creatorId: env.ROBLOX_CREATOR_ID,
|
|
28213
|
+
cacheFile: join2(env.FIGMA_BRIDGE_HOME ?? join2(homedir(), ".figma-bridge"), "roblox-assets.json"),
|
|
28214
|
+
fetchImpl: deps.fetchImpl,
|
|
28215
|
+
sleep: deps.sleep
|
|
28216
|
+
});
|
|
28217
|
+
const ids = {};
|
|
28218
|
+
for (const a of assets) {
|
|
28219
|
+
const r = await uploader.upload(files.get(a.file), `${a.layer}${a.mode && a.mode !== "full" ? ` ${a.mode}` : ""}`);
|
|
28220
|
+
a.assetId = r.assetId;
|
|
28221
|
+
a.cached = r.cached;
|
|
28222
|
+
ids[a.placeholder] = r.assetId;
|
|
28223
|
+
}
|
|
28224
|
+
rbxmx = substituteAssets(rbxmx, ids);
|
|
28225
|
+
luau = substituteAssets(luau, ids);
|
|
28226
|
+
uploaded = true;
|
|
28227
|
+
}
|
|
28228
|
+
}
|
|
28229
|
+
const dir = args.outDir ?? join2(deps.outDir, `roblox-${safe(t.tree.name)}-${Date.now()}`);
|
|
28230
|
+
mkdirSync3(dir, { recursive: true });
|
|
28231
|
+
const base = safe(mapped.root.name);
|
|
28232
|
+
writeFileSync3(join2(dir, `${base}.rbxmx`), rbxmx);
|
|
28233
|
+
writeFileSync3(join2(dir, `${base}.luau`), luau);
|
|
28234
|
+
for (const [file, bytes] of files)
|
|
28235
|
+
writeFileSync3(join2(dir, file), bytes);
|
|
28236
|
+
writeFileSync3(join2(dir, "assets.json"), JSON.stringify(assets, null, 2));
|
|
28237
|
+
const pending = assets.filter((a) => !a.assetId);
|
|
28238
|
+
const next = pending.length ? `Upload the ${pending.length} picture(s) in ${dir} (assets[].file) with your Roblox Studio tools (e.g. upload_image), replace each placeholder (rbxassetid://PENDING_n) in the Luau with the returned id, run it with execute_luau, then check it with a Studio screenshot. Or set ROBLOX_API_KEY and ROBLOX_CREATOR_ID and call export_roblox with upload:true.` : "Run the Luau with execute_luau (it replaces a previous copy and returns the root), or insert the .rbxmx in Studio, then check it with a Studio screenshot.";
|
|
28239
|
+
return {
|
|
28240
|
+
result: {
|
|
28241
|
+
root: mapped.root.name,
|
|
28242
|
+
dir,
|
|
28243
|
+
rbxmx: join2(dir, `${base}.rbxmx`),
|
|
28244
|
+
luau: join2(dir, `${base}.luau`),
|
|
28245
|
+
assets: assets.map((a) => ({ ...a, file: join2(dir, a.file) })),
|
|
28246
|
+
uploaded,
|
|
28247
|
+
instances: mapped.counts.instances,
|
|
28248
|
+
layers: t.nodes,
|
|
28249
|
+
options: { mode: opts.mode, targetResolution: opts.targetResolution, rasterize: opts.rasterize },
|
|
28250
|
+
...Object.keys(mapped.fontSubstitutions).length ? { fontSubstitutions: mapped.fontSubstitutions } : {},
|
|
28251
|
+
...warnings.length ? { warnings: warnings.slice(0, 60) } : {},
|
|
28252
|
+
next
|
|
28253
|
+
},
|
|
28254
|
+
luau,
|
|
28255
|
+
rbxmx
|
|
28256
|
+
};
|
|
28257
|
+
}
|
|
28258
|
+
function findImageOwner(n, hash) {
|
|
28259
|
+
if ((n.fills ?? []).some((f) => f.imageHash === hash))
|
|
28260
|
+
return n;
|
|
28261
|
+
for (const c of n.children ?? []) {
|
|
28262
|
+
const hit = findImageOwner(c, hash);
|
|
28263
|
+
if (hit)
|
|
28264
|
+
return hit;
|
|
28265
|
+
}
|
|
28266
|
+
return null;
|
|
28267
|
+
}
|
|
28268
|
+
var safe = (s) => s.replace(/[^\w.-]+/g, "_").replace(/^_+|_+$/g, "").slice(0, 40) || "ui";
|
|
28269
|
+
var init_export = __esm(() => {
|
|
28270
|
+
init_image();
|
|
28271
|
+
init_map2();
|
|
28272
|
+
init_output();
|
|
28273
|
+
init_upload();
|
|
28274
|
+
});
|
|
28275
|
+
|
|
28276
|
+
// src/rest.ts
|
|
28277
|
+
function fileKeyFrom(input) {
|
|
28278
|
+
const m = /figma\.com\/(?:file|design|board|slides|proto|deck)\/([A-Za-z0-9]{10,})/.exec(input);
|
|
28279
|
+
if (m)
|
|
28280
|
+
return m[1];
|
|
28281
|
+
return /^[A-Za-z0-9]{10,}$/.test(input.trim()) ? input.trim() : null;
|
|
28282
|
+
}
|
|
28283
|
+
|
|
28284
|
+
class FigmaRest {
|
|
28285
|
+
token;
|
|
28286
|
+
fetchImpl;
|
|
28287
|
+
sleep;
|
|
28288
|
+
base;
|
|
28289
|
+
constructor(token, fetchImpl = fetch, sleep = (ms) => new Promise((r) => setTimeout(r, ms)), base = process.env.FIGMA_API_URL || "https://api.figma.com") {
|
|
28290
|
+
this.token = token;
|
|
28291
|
+
this.fetchImpl = fetchImpl;
|
|
28292
|
+
this.sleep = sleep;
|
|
28293
|
+
this.base = base;
|
|
28294
|
+
}
|
|
28295
|
+
async call(method, path, body) {
|
|
28296
|
+
let delay = 1000;
|
|
28297
|
+
for (let attempt = 0;; attempt++) {
|
|
28298
|
+
let res;
|
|
28299
|
+
try {
|
|
28300
|
+
res = await this.fetchImpl(this.base + path, {
|
|
28301
|
+
method,
|
|
28302
|
+
headers: { "X-Figma-Token": this.token, ...body !== undefined ? { "Content-Type": "application/json" } : {} },
|
|
28303
|
+
body: body !== undefined ? JSON.stringify(body) : undefined,
|
|
28304
|
+
signal: AbortSignal.timeout(60000)
|
|
28305
|
+
});
|
|
28306
|
+
} catch (e) {
|
|
28307
|
+
if (attempt >= 3)
|
|
28308
|
+
throw new RestError(`Figma API unreachable: ${e.message}`, "NETWORK");
|
|
28309
|
+
await this.sleep(delay);
|
|
28310
|
+
delay *= 2;
|
|
28311
|
+
continue;
|
|
28312
|
+
}
|
|
28313
|
+
if (res.ok)
|
|
28314
|
+
return res.status === 204 ? {} : await res.json();
|
|
28315
|
+
const retryAfter = Number(res.headers.get("retry-after"));
|
|
28316
|
+
if ((res.status === 429 || res.status >= 500) && attempt < 3) {
|
|
28317
|
+
await this.sleep(res.status === 429 && retryAfter > 0 ? Math.min(retryAfter, 120) * 1000 : delay);
|
|
28318
|
+
delay *= 2;
|
|
28319
|
+
continue;
|
|
28320
|
+
}
|
|
28321
|
+
let detail = "";
|
|
28322
|
+
try {
|
|
28323
|
+
const j = await res.json();
|
|
28324
|
+
detail = j.err ?? j.message ?? "";
|
|
28325
|
+
} catch {}
|
|
28326
|
+
if (res.status === 403)
|
|
28327
|
+
throw new RestError(`Figma API refused the request (403${detail ? `: ${detail}` : ""}). Check that FIGMA_TOKEN is valid, has the needed scopes and can open this file.`, "FORBIDDEN", 403);
|
|
28328
|
+
if (res.status === 404)
|
|
28329
|
+
throw new RestError(`Not found (404${detail ? `: ${detail}` : ""}): check the file key or id.`, "NOT_FOUND", 404);
|
|
28330
|
+
if (res.status === 429)
|
|
28331
|
+
throw new RestError("Figma API rate limit reached: try again in a minute.", "RATE_LIMITED", 429);
|
|
28332
|
+
throw new RestError(`Figma API error ${res.status}${detail ? `: ${detail}` : ""}`, "REST_ERROR", res.status);
|
|
28333
|
+
}
|
|
28334
|
+
}
|
|
28335
|
+
async comments(key) {
|
|
28336
|
+
const r = await this.call("GET", `/v1/files/${key}/comments?as_md=true`);
|
|
28337
|
+
return r.comments ?? [];
|
|
28338
|
+
}
|
|
28339
|
+
postComment(key, message, opts = {}) {
|
|
28340
|
+
const body = { message };
|
|
28341
|
+
if (opts.replyTo)
|
|
28342
|
+
body.comment_id = opts.replyTo;
|
|
28343
|
+
else if (opts.nodeId)
|
|
28344
|
+
body.client_meta = { node_id: opts.nodeId, node_offset: opts.offset ?? { x: 0, y: 0 } };
|
|
28345
|
+
return this.call("POST", `/v1/files/${key}/comments`, body);
|
|
28346
|
+
}
|
|
28347
|
+
deleteComment(key, id) {
|
|
28348
|
+
return this.call("DELETE", `/v1/files/${key}/comments/${id}`);
|
|
28349
|
+
}
|
|
28350
|
+
async versions(key, limit = 30) {
|
|
28351
|
+
const out = [];
|
|
28352
|
+
let before;
|
|
28353
|
+
while (out.length < limit) {
|
|
28354
|
+
const r = await this.call("GET", `/v1/files/${key}/versions?page_size=${Math.min(50, limit)}${before ? `&before=${before}` : ""}`);
|
|
28355
|
+
out.push(...r.versions ?? []);
|
|
28356
|
+
const next = r.pagination?.prev_page ? new URL(r.pagination.prev_page).searchParams.get("before") : null;
|
|
28357
|
+
if (!next || !r.versions?.length)
|
|
28358
|
+
break;
|
|
28359
|
+
before = next;
|
|
28360
|
+
}
|
|
28361
|
+
return out.slice(0, limit);
|
|
28362
|
+
}
|
|
28363
|
+
async nodes(key, ids, version) {
|
|
28364
|
+
const q = `ids=${encodeURIComponent(ids.join(","))}${version ? `&version=${encodeURIComponent(version)}` : ""}`;
|
|
28365
|
+
const r = await this.call("GET", `/v1/files/${key}/nodes?${q}`);
|
|
28366
|
+
const out = {};
|
|
28367
|
+
for (const [id, n] of Object.entries(r.nodes ?? {}))
|
|
28368
|
+
if (n?.document)
|
|
28369
|
+
out[id] = n.document;
|
|
28370
|
+
return out;
|
|
28371
|
+
}
|
|
28372
|
+
async pages(key, version) {
|
|
28373
|
+
const r = await this.call("GET", `/v1/files/${key}?depth=1${version ? `&version=${encodeURIComponent(version)}` : ""}`);
|
|
28374
|
+
return r.document?.children ?? [];
|
|
28375
|
+
}
|
|
28376
|
+
}
|
|
28377
|
+
function threadComments(list, includeResolved) {
|
|
28378
|
+
const replies = new Map;
|
|
28379
|
+
for (const c of list)
|
|
28380
|
+
if (c.parent_id)
|
|
28381
|
+
replies.set(c.parent_id, [...replies.get(c.parent_id) ?? [], c]);
|
|
28382
|
+
const brief = (c) => ({ id: c.id, author: c.user?.handle, message: c.message, createdAt: c.created_at });
|
|
28383
|
+
return list.filter((c) => !c.parent_id && (includeResolved || !c.resolved_at)).sort((a, b) => a.created_at.localeCompare(b.created_at)).map((c) => ({
|
|
28384
|
+
...brief(c),
|
|
28385
|
+
...c.order_id ? { number: Number(c.order_id) } : {},
|
|
28386
|
+
...c.resolved_at ? { resolvedAt: c.resolved_at } : {},
|
|
28387
|
+
...c.client_meta?.node_id ? { nodeId: c.client_meta.node_id } : {},
|
|
28388
|
+
replies: (replies.get(c.id) ?? []).sort((a, b) => a.created_at.localeCompare(b.created_at)).map(brief)
|
|
28389
|
+
}));
|
|
28390
|
+
}
|
|
28391
|
+
function flatten(root, prefix, out) {
|
|
28392
|
+
const seen = new Map;
|
|
28393
|
+
for (const c of root.children ?? []) {
|
|
28394
|
+
const n = (seen.get(c.name) ?? 0) + 1;
|
|
28395
|
+
seen.set(c.name, n);
|
|
28396
|
+
const path = `${prefix}${c.name}${n > 1 ? ` #${n}` : ""}`;
|
|
28397
|
+
out.set(path, c);
|
|
28398
|
+
flatten(c, `${path}/`, out);
|
|
28399
|
+
}
|
|
28400
|
+
}
|
|
28401
|
+
function diffTrees(before, after, max = 200) {
|
|
28402
|
+
const a = new Map;
|
|
28403
|
+
const b = new Map;
|
|
28404
|
+
flatten(before, "", a);
|
|
28405
|
+
flatten(after, "", b);
|
|
28406
|
+
const added = [];
|
|
28407
|
+
const removed = [];
|
|
28408
|
+
const changed = [];
|
|
28409
|
+
for (const [path, n] of b)
|
|
28410
|
+
if (!a.has(path))
|
|
28411
|
+
added.push({ path, type: n.type, id: n.id });
|
|
28412
|
+
for (const [path, n] of a)
|
|
28413
|
+
if (!b.has(path))
|
|
28414
|
+
removed.push({ path, type: n.type, id: n.id });
|
|
28415
|
+
for (const [path, now] of b) {
|
|
28416
|
+
const old = a.get(path);
|
|
28417
|
+
if (!old)
|
|
28418
|
+
continue;
|
|
28419
|
+
const c = [];
|
|
28420
|
+
if (old.type !== now.type)
|
|
28421
|
+
c.push(`type ${old.type} → ${now.type}`);
|
|
28422
|
+
if ((old.visible ?? true) !== (now.visible ?? true))
|
|
28423
|
+
c.push(now.visible === false ? "hidden" : "shown");
|
|
28424
|
+
if ((old.characters ?? "") !== (now.characters ?? ""))
|
|
28425
|
+
c.push(`text "${(old.characters ?? "").slice(0, 60)}" → "${(now.characters ?? "").slice(0, 60)}"`);
|
|
28426
|
+
const ob = old.absoluteBoundingBox;
|
|
28427
|
+
const nb = now.absoluteBoundingBox;
|
|
28428
|
+
if (ob && nb) {
|
|
28429
|
+
if (Math.abs(ob.width - nb.width) > 0.5 || Math.abs(ob.height - nb.height) > 0.5)
|
|
28430
|
+
c.push(`size ${Math.round(ob.width)}×${Math.round(ob.height)} → ${Math.round(nb.width)}×${Math.round(nb.height)}`);
|
|
28431
|
+
if (Math.abs(ob.x - nb.x) > 0.5 || Math.abs(ob.y - nb.y) > 0.5)
|
|
28432
|
+
c.push(`moved ${Math.round(nb.x - ob.x)}, ${Math.round(nb.y - ob.y)}`);
|
|
28433
|
+
}
|
|
28434
|
+
if (fillsOf(old) !== fillsOf(now))
|
|
28435
|
+
c.push(`fill ${fillsOf(old) || "none"} → ${fillsOf(now) || "none"}`);
|
|
28436
|
+
if (c.length)
|
|
28437
|
+
changed.push({ path, type: now.type, id: now.id, changes: c });
|
|
28438
|
+
}
|
|
28439
|
+
const movedOnly = (x) => x.changes.length === 1 && x.changes[0].startsWith("moved");
|
|
28440
|
+
const movedPaths = new Set(changed.filter(movedOnly).map((x) => x.path));
|
|
28441
|
+
const cleaned = changed.filter((x) => !(movedOnly(x) && [...movedPaths].some((p) => p !== x.path && x.path.startsWith(`${p}/`))));
|
|
28442
|
+
const top = (list) => list.filter((x) => !list.some((y) => y !== x && x.path.startsWith(`${y.path}/`)));
|
|
28443
|
+
const addedTop = top(added);
|
|
28444
|
+
const removedTop = top(removed);
|
|
28445
|
+
return {
|
|
28446
|
+
summary: { added: addedTop.length, removed: removedTop.length, changed: cleaned.length, layersBefore: a.size, layersAfter: b.size },
|
|
28447
|
+
added: addedTop.slice(0, max),
|
|
28448
|
+
removed: removedTop.slice(0, max),
|
|
28449
|
+
changed: cleaned.slice(0, max),
|
|
28450
|
+
truncated: addedTop.length > max || removedTop.length > max || cleaned.length > max
|
|
28451
|
+
};
|
|
28452
|
+
}
|
|
28453
|
+
var TOKEN_HELP, RestError, hex = (c) => "#" + [c.r, c.g, c.b].map((v) => Math.round(v * 255).toString(16).padStart(2, "0")).join("").toUpperCase() + (c.a < 0.999 ? Math.round(c.a * 255).toString(16).padStart(2, "0").toUpperCase() : ""), fillsOf = (n) => (n.fills ?? []).filter((f) => f.visible !== false).map((f) => f.type === "SOLID" && f.color ? hex(f.color) : f.type.toLowerCase()).join(",");
|
|
28454
|
+
var init_rest = __esm(() => {
|
|
28455
|
+
TOKEN_HELP = "These features use Figma's REST API and need a personal access token. In Figma: Settings → Security → " + "Personal access tokens → Generate (scopes: file_content:read, file_comments:write, file_versions:read). " + 'Then add it to the Figma Bridge entry of your AI client config: "env": {"FIGMA_TOKEN": "figd_…"}, and restart the client.';
|
|
28456
|
+
RestError = class RestError extends Error {
|
|
28457
|
+
code;
|
|
28458
|
+
status;
|
|
28459
|
+
constructor(message, code, status) {
|
|
28460
|
+
super(message);
|
|
28461
|
+
this.code = code;
|
|
28462
|
+
this.status = status;
|
|
28463
|
+
}
|
|
28464
|
+
};
|
|
28465
|
+
});
|
|
28466
|
+
|
|
26802
28467
|
// src/project/detect.ts
|
|
26803
|
-
import { existsSync as existsSync2, readdirSync, readFileSync as
|
|
26804
|
-
import { join as
|
|
28468
|
+
import { existsSync as existsSync2, readdirSync, readFileSync as readFileSync3, statSync } from "node:fs";
|
|
28469
|
+
import { join as join3, relative, sep } from "node:path";
|
|
26805
28470
|
function listFiles(root, filter, max = 4000) {
|
|
26806
28471
|
const out = [];
|
|
26807
28472
|
const walk = (dir, depth) => {
|
|
@@ -26816,7 +28481,7 @@ function listFiles(root, filter, max = 4000) {
|
|
|
26816
28481
|
for (const name of entries.sort()) {
|
|
26817
28482
|
if (IGNORE.has(name) || name.startsWith(".") && name !== ".storybook")
|
|
26818
28483
|
continue;
|
|
26819
|
-
const full =
|
|
28484
|
+
const full = join3(dir, name);
|
|
26820
28485
|
let st;
|
|
26821
28486
|
try {
|
|
26822
28487
|
st = statSync(full);
|
|
@@ -26839,19 +28504,19 @@ function listFiles(root, filter, max = 4000) {
|
|
|
26839
28504
|
}
|
|
26840
28505
|
function readJsonc(path) {
|
|
26841
28506
|
try {
|
|
26842
|
-
const text =
|
|
28507
|
+
const text = readFileSync3(path, "utf8").replace(/("(?:[^"\\]|\\.)*")|\/\/[^\n]*|\/\*[\s\S]*?\*\//g, (m, str) => str ?? "").replace(/,(\s*[}\]])/g, "$1");
|
|
26843
28508
|
return JSON.parse(text);
|
|
26844
28509
|
} catch {
|
|
26845
28510
|
return null;
|
|
26846
28511
|
}
|
|
26847
28512
|
}
|
|
26848
28513
|
function detectStack(root) {
|
|
26849
|
-
const pkg = readJsonc(
|
|
28514
|
+
const pkg = readJsonc(join3(root, "package.json"));
|
|
26850
28515
|
if (!pkg)
|
|
26851
28516
|
throw new Error(`No package.json in ${root}: give the root folder of a JavaScript project as projectPath.`);
|
|
26852
28517
|
const deps = { ...pkg.peerDependencies, ...pkg.devDependencies, ...pkg.dependencies };
|
|
26853
28518
|
const has = (d) => deps[d] !== undefined;
|
|
26854
|
-
const at = (...p) => existsSync2(
|
|
28519
|
+
const at = (...p) => existsSync2(join3(root, ...p));
|
|
26855
28520
|
const evidence = [];
|
|
26856
28521
|
let kind = "html";
|
|
26857
28522
|
let framework = "html";
|
|
@@ -26887,7 +28552,7 @@ function detectStack(root) {
|
|
|
26887
28552
|
styling = "css-modules";
|
|
26888
28553
|
evidence.push(`styling: ${styling}`);
|
|
26889
28554
|
const ui = [];
|
|
26890
|
-
const shadcn = readJsonc(
|
|
28555
|
+
const shadcn = readJsonc(join3(root, "components.json"));
|
|
26891
28556
|
if (shadcn?.aliases || has("class-variance-authority") && sourceFiles.some((f) => /components\/ui\/button\.(t|j)sx$/.test(f)))
|
|
26892
28557
|
ui.push("shadcn");
|
|
26893
28558
|
if (has("@mui/material"))
|
|
@@ -26896,7 +28561,7 @@ function detectStack(root) {
|
|
|
26896
28561
|
ui.push("chakra");
|
|
26897
28562
|
if (ui.length)
|
|
26898
28563
|
evidence.push(`ui: ${ui.join(", ")}`);
|
|
26899
|
-
const tsconfig = readJsonc(
|
|
28564
|
+
const tsconfig = readJsonc(join3(root, "tsconfig.json")) ?? readJsonc(join3(root, "jsconfig.json"));
|
|
26900
28565
|
const typescript = at("tsconfig.json") || has("typescript");
|
|
26901
28566
|
const aliases = [];
|
|
26902
28567
|
const paths = tsconfig?.compilerOptions?.paths ?? {};
|
|
@@ -26905,7 +28570,7 @@ function detectStack(root) {
|
|
|
26905
28570
|
const target = Array.isArray(targets) ? String(targets[0]) : "";
|
|
26906
28571
|
if (!key.endsWith("/*") || !target.endsWith("/*"))
|
|
26907
28572
|
continue;
|
|
26908
|
-
const t =
|
|
28573
|
+
const t = join3(baseUrl, target.slice(0, -2)).split(sep).join("/").replace(/^\.\//, "");
|
|
26909
28574
|
aliases.push({ prefix: key.slice(0, -1), target: t === "." ? "" : `${t}/` });
|
|
26910
28575
|
}
|
|
26911
28576
|
if (kind === "nuxt" && !aliases.length)
|
|
@@ -26942,8 +28607,8 @@ var init_detect = __esm(() => {
|
|
|
26942
28607
|
});
|
|
26943
28608
|
|
|
26944
28609
|
// src/project/components.ts
|
|
26945
|
-
import { readFileSync as
|
|
26946
|
-
import { basename, join as
|
|
28610
|
+
import { readFileSync as readFileSync4 } from "node:fs";
|
|
28611
|
+
import { basename, join as join4 } from "node:path";
|
|
26947
28612
|
function block(text, start, open = "{", close = "}") {
|
|
26948
28613
|
let depth = 0;
|
|
26949
28614
|
for (let i = start;i < text.length; i++) {
|
|
@@ -27123,7 +28788,7 @@ function indexComponents(stack, max = 400) {
|
|
|
27123
28788
|
for (const file of files) {
|
|
27124
28789
|
let src;
|
|
27125
28790
|
try {
|
|
27126
|
-
src =
|
|
28791
|
+
src = readFileSync4(join4(stack.root, file), "utf8");
|
|
27127
28792
|
} catch {
|
|
27128
28793
|
continue;
|
|
27129
28794
|
}
|
|
@@ -27174,14 +28839,14 @@ var init_components = __esm(() => {
|
|
|
27174
28839
|
});
|
|
27175
28840
|
|
|
27176
28841
|
// src/project/mapping.ts
|
|
27177
|
-
import { existsSync as existsSync3, readFileSync as
|
|
27178
|
-
import { join as
|
|
28842
|
+
import { existsSync as existsSync3, readFileSync as readFileSync5 } from "node:fs";
|
|
28843
|
+
import { join as join5 } from "node:path";
|
|
27179
28844
|
function loadMapFile(root) {
|
|
27180
|
-
const path =
|
|
28845
|
+
const path = join5(root, MAP_FILE);
|
|
27181
28846
|
if (!existsSync3(path))
|
|
27182
28847
|
return { map: {} };
|
|
27183
28848
|
try {
|
|
27184
|
-
return { map: JSON.parse(
|
|
28849
|
+
return { map: JSON.parse(readFileSync5(path, "utf8")), path };
|
|
27185
28850
|
} catch (e) {
|
|
27186
28851
|
return { map: {}, error: `${MAP_FILE} is not valid JSON: ${e.message}` };
|
|
27187
28852
|
}
|
|
@@ -27338,8 +29003,8 @@ var init_mapping = __esm(() => {
|
|
|
27338
29003
|
});
|
|
27339
29004
|
|
|
27340
29005
|
// src/project/tokens.ts
|
|
27341
|
-
import { readFileSync as
|
|
27342
|
-
import { join as
|
|
29006
|
+
import { readFileSync as readFileSync6 } from "node:fs";
|
|
29007
|
+
import { join as join6 } from "node:path";
|
|
27343
29008
|
function oklch(l, c, h, a) {
|
|
27344
29009
|
const hr = h * Math.PI / 180;
|
|
27345
29010
|
return oklab(l, c * Math.cos(hr), c * Math.sin(hr), a);
|
|
@@ -27426,7 +29091,7 @@ function loadTokens(stack) {
|
|
|
27426
29091
|
for (const file of stack.cssFiles) {
|
|
27427
29092
|
let css;
|
|
27428
29093
|
try {
|
|
27429
|
-
css =
|
|
29094
|
+
css = readFileSync6(join6(stack.root, file), "utf8");
|
|
27430
29095
|
} catch {
|
|
27431
29096
|
continue;
|
|
27432
29097
|
}
|
|
@@ -27470,7 +29135,7 @@ function loadTokens(stack) {
|
|
|
27470
29135
|
}
|
|
27471
29136
|
if (stack.tailwind === 3 && stack.tailwindConfig) {
|
|
27472
29137
|
try {
|
|
27473
|
-
for (const t of tailwind3Colors(
|
|
29138
|
+
for (const t of tailwind3Colors(readFileSync6(join6(stack.root, stack.tailwindConfig), "utf8")))
|
|
27474
29139
|
index.add(t);
|
|
27475
29140
|
index.sources.push(stack.tailwindConfig);
|
|
27476
29141
|
} catch {}
|
|
@@ -27534,8 +29199,8 @@ var init_tokens2 = __esm(() => {
|
|
|
27534
29199
|
});
|
|
27535
29200
|
|
|
27536
29201
|
// src/project/plan.ts
|
|
27537
|
-
import { existsSync as existsSync4, mkdirSync as
|
|
27538
|
-
import { dirname as
|
|
29202
|
+
import { existsSync as existsSync4, mkdirSync as mkdirSync4, readFileSync as readFileSync7, writeFileSync as writeFileSync4 } from "node:fs";
|
|
29203
|
+
import { dirname as dirname3, join as join7, posix } from "node:path";
|
|
27539
29204
|
function planProjectExport(root, tree, assets, opts = {}) {
|
|
27540
29205
|
const stack = detectStack(root);
|
|
27541
29206
|
if (opts.framework)
|
|
@@ -27607,19 +29272,19 @@ function planProjectExport(root, tree, assets, opts = {}) {
|
|
|
27607
29272
|
}
|
|
27608
29273
|
function writePlan(root, files, overwrite) {
|
|
27609
29274
|
return files.map((f) => {
|
|
27610
|
-
const target =
|
|
29275
|
+
const target = join7(root, f.path);
|
|
27611
29276
|
const next = typeof f.content === "string" ? Buffer.from(f.content) : Buffer.from(f.content);
|
|
27612
29277
|
if (existsSync4(target)) {
|
|
27613
|
-
const before =
|
|
29278
|
+
const before = readFileSync7(target);
|
|
27614
29279
|
if (before.equals(next))
|
|
27615
29280
|
return { path: f.path, status: "unchanged" };
|
|
27616
29281
|
if (!overwrite)
|
|
27617
29282
|
return { path: f.path, status: "skipped (exists)" };
|
|
27618
|
-
|
|
29283
|
+
writeFileSync4(target, next);
|
|
27619
29284
|
return { path: f.path, status: "updated" };
|
|
27620
29285
|
}
|
|
27621
|
-
|
|
27622
|
-
|
|
29286
|
+
mkdirSync4(dirname3(target), { recursive: true });
|
|
29287
|
+
writeFileSync4(target, next);
|
|
27623
29288
|
return { path: f.path, status: "created" };
|
|
27624
29289
|
});
|
|
27625
29290
|
}
|
|
@@ -27688,7 +29353,7 @@ class FontIndex {
|
|
|
27688
29353
|
}
|
|
27689
29354
|
}
|
|
27690
29355
|
var GENERIC, STYLE_WEIGHTS;
|
|
27691
|
-
var
|
|
29356
|
+
var init_fonts2 = __esm(() => {
|
|
27692
29357
|
GENERIC = {
|
|
27693
29358
|
"sans-serif": ["Inter", "Roboto", "Arial", "Helvetica"],
|
|
27694
29359
|
"system-ui": ["Inter", "SF Pro Text", "Segoe UI", "Roboto", "Arial"],
|
|
@@ -27838,7 +29503,7 @@ function cssLiteral(v, value) {
|
|
|
27838
29503
|
if (typeof value === "boolean")
|
|
27839
29504
|
return null;
|
|
27840
29505
|
if (typeof value === "number")
|
|
27841
|
-
return unitless(v) ?
|
|
29506
|
+
return unitless(v) ? num2(value) : `${num2(value)}px`;
|
|
27842
29507
|
if (v.type === "string" && category(v) === "fontFamily")
|
|
27843
29508
|
return /^[\w-]+$/.test(String(value)) ? String(value) : quote(String(value));
|
|
27844
29509
|
if (v.type === "string")
|
|
@@ -27846,12 +29511,12 @@ function cssLiteral(v, value) {
|
|
|
27846
29511
|
return String(value);
|
|
27847
29512
|
}
|
|
27848
29513
|
function gradientCss(g) {
|
|
27849
|
-
const stops = g.gradient.map((s) => `${s.color} ${
|
|
29514
|
+
const stops = g.gradient.map((s) => `${s.color} ${num2(s.at * 100)}%`).join(", ");
|
|
27850
29515
|
if (g.type === "radial")
|
|
27851
29516
|
return `radial-gradient(circle, ${stops})`;
|
|
27852
29517
|
if (g.type)
|
|
27853
29518
|
return `conic-gradient(${stops})`;
|
|
27854
|
-
return `linear-gradient(${
|
|
29519
|
+
return `linear-gradient(${num2((g.angle ?? 0) + 90)}deg, ${stops})`;
|
|
27855
29520
|
}
|
|
27856
29521
|
function paintCss(value, ref) {
|
|
27857
29522
|
const one = (p) => typeof p === "string" ? p : p && p.alias ? ref(p.alias) : p && p.gradient ? gradientCss(p) : "transparent";
|
|
@@ -27860,7 +29525,7 @@ function paintCss(value, ref) {
|
|
|
27860
29525
|
return value.slice().reverse().map((p) => typeof p === "string" || p && p.alias ? `linear-gradient(${one(p)}, ${one(p)})` : one(p)).join(", ");
|
|
27861
29526
|
}
|
|
27862
29527
|
function shadowCss(effects, ref) {
|
|
27863
|
-
const list = effects.filter((e) => e.blur !== undefined && e.type === undefined).reverse().map((e) => `${e.inner ? "inset " : ""}${
|
|
29528
|
+
const list = effects.filter((e) => e.blur !== undefined && e.type === undefined).reverse().map((e) => `${e.inner ? "inset " : ""}${num2(e.x)}px ${num2(e.y)}px ${num2(e.blur)}px ${num2(e.spread ?? 0)}px ${e.colorVariable ? ref(e.colorVariable) : e.color}`);
|
|
27864
29529
|
return list.length ? list.join(", ") : null;
|
|
27865
29530
|
}
|
|
27866
29531
|
function textDecls(t, ref) {
|
|
@@ -27869,7 +29534,7 @@ function textDecls(t, ref) {
|
|
|
27869
29534
|
const d = [
|
|
27870
29535
|
["font-family", bound.fontFamily ? ref(bound.fontFamily) : `${/^[\w-]+$/.test(t.font) ? t.font : quote(t.font)}, sans-serif`],
|
|
27871
29536
|
["font-weight", bound.fontWeight ? ref(bound.fontWeight) : String(w.weight)],
|
|
27872
|
-
["font-size", bound.fontSize ? ref(bound.fontSize) : `${
|
|
29537
|
+
["font-size", bound.fontSize ? ref(bound.fontSize) : `${num2(t.size)}px`]
|
|
27873
29538
|
];
|
|
27874
29539
|
if (w.italic)
|
|
27875
29540
|
d.push(["font-style", "italic"]);
|
|
@@ -28146,7 +29811,7 @@ ${textDecls(t, ref).map(([k, v]) => ` ${k}: ${v};`).join(`
|
|
|
28146
29811
|
}
|
|
28147
29812
|
function tsLiteral(value) {
|
|
28148
29813
|
if (typeof value === "number")
|
|
28149
|
-
return
|
|
29814
|
+
return num2(value);
|
|
28150
29815
|
if (typeof value === "boolean")
|
|
28151
29816
|
return String(value);
|
|
28152
29817
|
return quote(String(value));
|
|
@@ -28266,7 +29931,7 @@ function dtcgType(v) {
|
|
|
28266
29931
|
function toDtcg(model) {
|
|
28267
29932
|
const out = {};
|
|
28268
29933
|
const dtcgRef = (a) => `{${[a.collection, ...a.name.split("/")].join(".")}}`;
|
|
28269
|
-
const literal = (v, x) => isAlias(x) ? dtcgRef(x.alias) : dtcgType(v) === "dimension" ? `${
|
|
29934
|
+
const literal = (v, x) => isAlias(x) ? dtcgRef(x.alias) : dtcgType(v) === "dimension" ? `${num2(Number(x))}px` : x;
|
|
28270
29935
|
for (const c of model.data.collections) {
|
|
28271
29936
|
const group = { $extensions: { "figma-bridge": { collection: true, modes: c.modes } } };
|
|
28272
29937
|
for (const v of c.variables) {
|
|
@@ -28297,9 +29962,9 @@ function toDtcg(model) {
|
|
|
28297
29962
|
}
|
|
28298
29963
|
for (const t of s.text) {
|
|
28299
29964
|
const w = styleWeight(t.style);
|
|
28300
|
-
const value = { fontFamily: t.font, fontWeight: w.weight, fontSize: `${
|
|
29965
|
+
const value = { fontFamily: t.font, fontWeight: w.weight, fontSize: `${num2(t.size)}px` };
|
|
28301
29966
|
if (t.lineHeight !== undefined)
|
|
28302
|
-
value.lineHeight = typeof t.lineHeight === "number" ? `${
|
|
29967
|
+
value.lineHeight = typeof t.lineHeight === "number" ? `${num2(t.lineHeight)}px` : t.lineHeight;
|
|
28303
29968
|
if (t.letterSpacing !== undefined)
|
|
28304
29969
|
value.letterSpacing = letterSpacingCss(t.letterSpacing);
|
|
28305
29970
|
const token = { $type: "typography", $value: value, $extensions: { "figma-bridge": { style: t.style } } };
|
|
@@ -28313,7 +29978,7 @@ function toDtcg(model) {
|
|
|
28313
29978
|
continue;
|
|
28314
29979
|
const token = {
|
|
28315
29980
|
$type: "shadow",
|
|
28316
|
-
$value: shadows.map((x) => ({ color: x.color, offsetX: `${
|
|
29981
|
+
$value: shadows.map((x) => ({ color: x.color, offsetX: `${num2(x.x)}px`, offsetY: `${num2(x.y)}px`, blur: `${num2(x.blur)}px`, spread: `${num2(x.spread ?? 0)}px`, ...x.inner ? { inset: true } : {} }))
|
|
28317
29982
|
};
|
|
28318
29983
|
if (e.description)
|
|
28319
29984
|
token.$description = e.description;
|
|
@@ -28360,9 +30025,9 @@ function exportTokenFiles(data, formats, opts = {}) {
|
|
|
28360
30025
|
}
|
|
28361
30026
|
return { files, warnings: [...data.warnings ?? [], ...model.warnings] };
|
|
28362
30027
|
}
|
|
28363
|
-
var FORMATS, FILE_NAMES, isAlias = (v) => typeof v === "object" && v !== null && ("alias" in v), kebab2 = (s) => s.normalize("NFKD").replace(/[̀-ͯ]/g, "").replace(/([a-z0-9])([A-Z])/g, "$1-$2").toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, ""), camel2 = (s) => kebab2(s).replace(/-([a-z0-9])/g, (_, c) => c.toUpperCase()),
|
|
30028
|
+
var FORMATS, FILE_NAMES, isAlias = (v) => typeof v === "object" && v !== null && ("alias" in v), kebab2 = (s) => s.normalize("NFKD").replace(/[̀-ͯ]/g, "").replace(/([a-z0-9])([A-Z])/g, "$1-$2").toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, ""), camel2 = (s) => kebab2(s).replace(/-([a-z0-9])/g, (_, c) => c.toUpperCase()), num2 = (n) => String(Math.round(n * 1000) / 1000), quote = (s) => JSON.stringify(s), qualified = (collection, name) => `${collection}/${name}`, CATEGORY_WORDS, letterSpacingCss = (v) => v === undefined ? undefined : typeof v === "string" && v.endsWith("%") ? `${num2(parseFloat(v) / 100)}em` : `${num2(Number(v))}px`, lineHeightCss = (v) => v === undefined ? undefined : typeof v === "string" ? v : `${num2(v)}px`, TW3_KEYS, TW4_NS;
|
|
28364
30029
|
var init_tokens_export = __esm(() => {
|
|
28365
|
-
|
|
30030
|
+
init_fonts2();
|
|
28366
30031
|
FORMATS = ["dtcg", "css", "tailwind", "tailwind4", "scss", "ts", "json"];
|
|
28367
30032
|
FILE_NAMES = {
|
|
28368
30033
|
dtcg: "tokens.json",
|
|
@@ -29975,16 +31640,16 @@ var BUILD_LIMIT = 2800, isSvg = (bytes) => /^\s*(<\?xml[^>]*>\s*)?(<!--[\s\S]*?-
|
|
|
29975
31640
|
var init_import = __esm(() => {
|
|
29976
31641
|
init_browser();
|
|
29977
31642
|
init_convert();
|
|
29978
|
-
|
|
31643
|
+
init_fonts2();
|
|
29979
31644
|
});
|
|
29980
31645
|
|
|
29981
31646
|
// src/snippets.ts
|
|
29982
|
-
import { createHash } from "node:crypto";
|
|
29983
|
-
import { mkdirSync as
|
|
29984
|
-
import { homedir } from "node:os";
|
|
29985
|
-
import { join as
|
|
31647
|
+
import { createHash as createHash2 } from "node:crypto";
|
|
31648
|
+
import { mkdirSync as mkdirSync5, readdirSync as readdirSync2, readFileSync as readFileSync8, rmSync, statSync as statSync2, writeFileSync as writeFileSync5 } from "node:fs";
|
|
31649
|
+
import { homedir as homedir2 } from "node:os";
|
|
31650
|
+
import { join as join8 } from "node:path";
|
|
29986
31651
|
function ensureDir() {
|
|
29987
|
-
|
|
31652
|
+
mkdirSync5(SNIPPETS_DIR, { recursive: true });
|
|
29988
31653
|
}
|
|
29989
31654
|
function checkName(name) {
|
|
29990
31655
|
if (!NAME.test(name))
|
|
@@ -29999,14 +31664,14 @@ function files() {
|
|
|
29999
31664
|
}
|
|
30000
31665
|
function listSnippets() {
|
|
30001
31666
|
return files().map((f) => {
|
|
30002
|
-
const source =
|
|
31667
|
+
const source = readFileSync8(join8(SNIPPETS_DIR, f), "utf8");
|
|
30003
31668
|
return { name: f.slice(0, -3), description: header2(source, "description"), usage: header2(source, "usage") };
|
|
30004
31669
|
});
|
|
30005
31670
|
}
|
|
30006
31671
|
function getSnippet(name) {
|
|
30007
31672
|
checkName(name);
|
|
30008
31673
|
try {
|
|
30009
|
-
return
|
|
31674
|
+
return readFileSync8(join8(SNIPPETS_DIR, `${name}.js`), "utf8");
|
|
30010
31675
|
} catch {
|
|
30011
31676
|
throw new BridgeError(`No snippet named "${name}".`, "NOT_FOUND");
|
|
30012
31677
|
}
|
|
@@ -30019,37 +31684,37 @@ function saveSnippet(name, code, description = "", usage = "") {
|
|
|
30019
31684
|
// @usage ${usage.replace(/\n/g, " ") || `await lib.${name}(args)`}
|
|
30020
31685
|
${body}
|
|
30021
31686
|
`;
|
|
30022
|
-
const path =
|
|
30023
|
-
|
|
31687
|
+
const path = join8(SNIPPETS_DIR, `${name}.js`);
|
|
31688
|
+
writeFileSync5(path, text);
|
|
30024
31689
|
return path;
|
|
30025
31690
|
}
|
|
30026
31691
|
function deleteSnippet(name) {
|
|
30027
31692
|
checkName(name);
|
|
30028
|
-
rmSync(
|
|
31693
|
+
rmSync(join8(SNIPPETS_DIR, `${name}.js`), { force: true });
|
|
30029
31694
|
}
|
|
30030
31695
|
function loadLibrary() {
|
|
30031
31696
|
const list = files();
|
|
30032
|
-
const stamp = list.map((f) => `${f}:${statSync2(
|
|
31697
|
+
const stamp = list.map((f) => `${f}:${statSync2(join8(SNIPPETS_DIR, f)).mtimeMs}`).join("|");
|
|
30033
31698
|
if (cached2 && cached2.stamp === stamp)
|
|
30034
31699
|
return cached2;
|
|
30035
31700
|
const lib = {};
|
|
30036
31701
|
for (const f of list)
|
|
30037
|
-
lib[f.slice(0, -3)] =
|
|
30038
|
-
cached2 = { stamp, hash: stamp ?
|
|
31702
|
+
lib[f.slice(0, -3)] = readFileSync8(join8(SNIPPETS_DIR, f), "utf8");
|
|
31703
|
+
cached2 = { stamp, hash: stamp ? createHash2("sha1").update(stamp + JSON.stringify(lib)).digest("base64url").slice(0, 12) : "empty", lib };
|
|
30039
31704
|
return cached2;
|
|
30040
31705
|
}
|
|
30041
31706
|
var SNIPPETS_DIR, NAME, cached2 = null;
|
|
30042
31707
|
var init_snippets = __esm(() => {
|
|
30043
31708
|
init_bridge();
|
|
30044
|
-
SNIPPETS_DIR = process.env.FIGMA_BRIDGE_SNIPPETS ||
|
|
31709
|
+
SNIPPETS_DIR = process.env.FIGMA_BRIDGE_SNIPPETS || join8(homedir2(), ".figma-bridge", "snippets");
|
|
30045
31710
|
NAME = /^[A-Za-z_][A-Za-z0-9_]{0,63}$/;
|
|
30046
31711
|
});
|
|
30047
31712
|
|
|
30048
31713
|
// src/server.ts
|
|
30049
|
-
import { appendFileSync, mkdirSync as
|
|
31714
|
+
import { appendFileSync, mkdirSync as mkdirSync6, statSync as statSync3, truncateSync, writeFileSync as writeFileSync6 } from "node:fs";
|
|
30050
31715
|
import { mkdir, readFile, stat, writeFile } from "node:fs/promises";
|
|
30051
31716
|
import { tmpdir } from "node:os";
|
|
30052
|
-
import { dirname as
|
|
31717
|
+
import { dirname as dirname4, join as join9 } from "node:path";
|
|
30053
31718
|
import { fileURLToPath } from "node:url";
|
|
30054
31719
|
function log(line) {
|
|
30055
31720
|
const text = `[${new Date().toISOString().slice(11, 23)}] ${line}`;
|
|
@@ -30140,6 +31805,12 @@ async function prepareSpec(spec, defaultColor) {
|
|
|
30140
31805
|
return;
|
|
30141
31806
|
if (Array.isArray(node))
|
|
30142
31807
|
return node.forEach(walk);
|
|
31808
|
+
if (String(node.type ?? "").toLowerCase() === "diagram") {
|
|
31809
|
+
const expanded = diagramSpec(node);
|
|
31810
|
+
for (const k of Object.keys(node))
|
|
31811
|
+
delete node[k];
|
|
31812
|
+
Object.assign(node, expanded);
|
|
31813
|
+
}
|
|
30143
31814
|
if (node.icon && !node.svg) {
|
|
30144
31815
|
const size = typeof node.size === "number" ? node.size : 24;
|
|
30145
31816
|
jobs.push(iconSvg(String(node.icon), size, typeof node.color === "string" ? node.color : defaultColor).then((svg) => void (node.svg = svg)));
|
|
@@ -30164,6 +31835,8 @@ async function prepareSpec(spec, defaultColor) {
|
|
|
30164
31835
|
node.children.forEach(walk);
|
|
30165
31836
|
if (Array.isArray(node.variants))
|
|
30166
31837
|
node.variants.forEach(walk);
|
|
31838
|
+
if (Array.isArray(node.slides))
|
|
31839
|
+
node.slides.forEach(walk);
|
|
30167
31840
|
if (node.base)
|
|
30168
31841
|
walk(node.base);
|
|
30169
31842
|
};
|
|
@@ -30175,7 +31848,7 @@ async function writeIfChanged(path, content) {
|
|
|
30175
31848
|
const before = await readFile(path, "utf8").catch(() => null);
|
|
30176
31849
|
if (before === content)
|
|
30177
31850
|
return "unchanged";
|
|
30178
|
-
await mkdir(
|
|
31851
|
+
await mkdir(dirname4(path), { recursive: true });
|
|
30179
31852
|
await writeFile(path, content);
|
|
30180
31853
|
return before === null ? "created" : "updated";
|
|
30181
31854
|
}
|
|
@@ -30189,7 +31862,46 @@ function renameAssets(n, names) {
|
|
|
30189
31862
|
out.children = n.children.map((c) => renameAssets(c, names));
|
|
30190
31863
|
return out;
|
|
30191
31864
|
}
|
|
30192
|
-
|
|
31865
|
+
async function restContext(fileUrl) {
|
|
31866
|
+
const token = process.env.FIGMA_TOKEN;
|
|
31867
|
+
if (!token)
|
|
31868
|
+
throw new BridgeError(TOKEN_HELP, "NO_TOKEN");
|
|
31869
|
+
const session = await bridge.current().catch(() => null);
|
|
31870
|
+
let key = fileUrl ? fileKeyFrom(fileUrl) : null;
|
|
31871
|
+
if (fileUrl && !key)
|
|
31872
|
+
throw new BridgeError(`Not a Figma file link or key: ${fileUrl}`, "BAD_ARGS");
|
|
31873
|
+
key ??= session?.fileKey ?? null;
|
|
31874
|
+
if (!key) {
|
|
31875
|
+
throw new BridgeError("Unknown file key: the plugin can't read it here (Figma gives it only to development and private plugins). Pass fileUrl: the file's link (Share → Copy link).", "NO_FILE_KEY");
|
|
31876
|
+
}
|
|
31877
|
+
return { rest: new FigmaRest(token), key, session: session?.fileKey === key ? session : null };
|
|
31878
|
+
}
|
|
31879
|
+
async function layerNames(session, ids) {
|
|
31880
|
+
if (!session || !ids.length)
|
|
31881
|
+
return {};
|
|
31882
|
+
return bridge.request("node_info", { ids: [...new Set(ids)] }, 20000).catch(() => ({}));
|
|
31883
|
+
}
|
|
31884
|
+
var PORT, CHANNEL, OUT_DIR, LOG_FILE, MAX_OUTPUT_CHARS = 60000, MAX_IMAGE_BYTES, FIGMA_MAX_IMAGE_DIM = 4096, VIEW_MAX_DIM = 1568, bridge, ok = (value) => ({ content: [{ type: "text", text: json(value) }] }), plain = (text) => ({ content: [{ type: "text", text }] }), oneLine = (s, max = 120) => s.replace(/\s+/g, " ").trim().slice(0, max), safeName = (s) => s.replace(/[^\w.-]+/g, "_").replace(/^_+|_+$/g, "").slice(0, 40) || "node", INSTRUCTIONS, BUILD_DESCRIPTION = `Create a whole layout in ONE call from a declarative spec. Fonts load automatically, icons are fetched, images loaded. Much faster and safer than run_script for new UI.
|
|
31885
|
+
Node: {type?, name?, ...props, children?: Node[]}. type is inferred (text/spans→text, icon→icon, src→image, component→instance, variants→componentSet, else frame). Types: frame, component, componentSet, text, rect, ellipse, line, icon, image, svg, instance.
|
|
31886
|
+
FRAME: layout "row"|"column"|"grid" (auto-layout; omit for free positioning), gap (number|"auto"), padding (n | [v,h] | [t,r,b,l]), align (cross axis: start|center|end|baseline), justify (main axis: start|center|end|between), wrap, rowGap, clip. Frames have no fill unless set.
|
|
31887
|
+
GRID: layout:"grid", columns (count | tracks like [200,"1fr","2fr","hug"]), rows (same; default: enough rows, hugging), columnGap, rowGap (or gap). Children: span:[rows,cols] or colSpan/rowSpan, cellAlign/cellValign. Give the grid a w for "fr" columns.
|
|
31888
|
+
SIZE: w / h: number (fixed) | "fill" (stretch inside an auto-layout parent) | "hug". Auto-layout frames hug by default. grow:true. absolute:true with x/y inside auto-layout; x/y for children of free frames.
|
|
31889
|
+
TEXT: text, font ("Inter" | "Inter:Bold"), weight (400|500|600|700 or style name), size, color, lineHeight (1.5 | 24 | "150%"), letterSpacing (px | "2%"), align (left|center|right|justify), case (upper|lower|title), decoration (underline|strike), maxLines, textStyle "style:Name". Give w:"fill" or a number to wrap text.
|
|
31890
|
+
RICH TEXT: spans:[{text:"Read the "},{text:"docs",weight:600,color:"#0D99FF",link:"https://…"}] instead of text; a span may set font, weight, size, color, decoration, case, link.
|
|
31891
|
+
PAINT (fill, stroke, color): "#RRGGBB[AA]", "style:<paint style>", "var:<color variable>", {gradient:["#a","#b"], angle:90, type?:"radial"}, {image:"https://…"|path, fit:"fill"|"fit"|"crop"|"tile"}, null, or an array (last on top). stroke + strokeWidth (n | [t,r,b,l]) + strokeAlign (inside|center|outside) + strokeDash [dash,gap].
|
|
31892
|
+
EFFECTS: radius (n | [tl,tr,br,bl] | "var:x"), opacity, shadow (true | {x,y,blur,spread,color} | [...] | "style:Name"), blur, backgroundBlur, rotation, visible.
|
|
31893
|
+
ICON: {icon:"lucide:house", size:20, color:"#111"} (any Iconify set). IMAGE: {src:"C:/img.png" | "https://…", w, h, fit:"fill"|"fit"|"crop"|"tile"}. SVG: {svg:"<svg…>"}.
|
|
31894
|
+
INSTANCE: {component:"Button" | node id | library key, props:{Variant:"Primary", Label:"Buy"}, text:{"Label layer name":"Buy"}}.
|
|
31895
|
+
COMPONENT SET: {type:"componentSet", name:"Button", base:{shared frame spec}, variants:[{props:{Variant:"Primary",Size:"M"}, fill:"#0D99FF", children:[…]}, …], properties:{Label:"Button", "Show icon":{type:"boolean",default:true}, Icon:{type:"instance",default:"Icon/Star"}}}. One component per variant, combined as variants.
|
|
31896
|
+
On a layer inside a component: bind:"Label" links a text to a text property (created if missing); bind:{visible:"Show icon"} or {mainComponent:"Icon"} for the others. type:"component" takes properties too.
|
|
31897
|
+
PROTOTYPE: reactions:[{trigger:"click", action:"navigate", to:"Details", transition:"smart", duration:300}] on any layer (see the prototype tool).
|
|
31898
|
+
SECTION: {type:"section", name, children (placed with x/y), w?, h?} (wraps its children when no size).
|
|
31899
|
+
FIGJAM files: {type:"sticky", text, color:"yellow"|"blue"|…|"#hex", wide?}, {type:"shape", shape:"rounded"|"square"|"ellipse"|"diamond"|"database"|"hexagon"|…, text, w, h, fill, stroke}, {type:"connector", from, to, label?, line:"elbowed"|"straight"|"curved", dashed?, endArrow?:"arrow"|"none"} (from/to: layer name, key or id; any node can set key:"a"), {type:"table", rows:[["Name","Role"],["Ada","Eng"]]}, {type:"codeBlock", code, language:"typescript"}.
|
|
31900
|
+
DIAGRAM (FigJam): {type:"diagram", source:"flowchart LR\\n A[Start] --> B{Valid?}\\n B -->|yes| C(Done)\\n B -.->|no| A", as?:"shapes"|"stickies", name?} lays out a Mermaid flowchart (shapes [] () {} (()) [()] {{}}, edges --> --- -.-> ==> with |labels|, subgraphs → sections).
|
|
31901
|
+
SLIDES files: {slides:[{name, fill?, children:[…]}, …]} or {type:"slide", …}: one 1920×1080 slide per entry, children placed with x/y or auto-layout.
|
|
31902
|
+
gap/padding/radius accept "var:<number variable>". MODES: modes:{"Theme":"Dark"} sets a collection's variable mode on a frame and its children.
|
|
31903
|
+
Example: {"name":"Card","layout":"column","w":320,"padding":24,"gap":12,"fill":"#FFFFFF","radius":16,"shadow":true,"children":[{"text":"Pro plan","size":20,"weight":600},{"text":"Everything you need","color":"#6B7280","w":"fill"},{"layout":"row","gap":8,"align":"center","children":[{"icon":"lucide:check","size":16,"color":"#16A34A"},{"text":"Unlimited projects"}]}]}
|
|
31904
|
+
Returns {rootId, ids:{layerName:id}, created, warnings}. The result is selected and zoomed to unless select:false.`, server, SESSION_TOOLS, registerTool, toolDocs, shutdown = () => process.exit(0);
|
|
30193
31905
|
var init_server3 = __esm(async () => {
|
|
30194
31906
|
init_mcp();
|
|
30195
31907
|
init_stdio2();
|
|
@@ -30198,6 +31910,10 @@ var init_server3 = __esm(async () => {
|
|
|
30198
31910
|
init_icons();
|
|
30199
31911
|
init_image();
|
|
30200
31912
|
init_codegen();
|
|
31913
|
+
init_diagram();
|
|
31914
|
+
init_guides();
|
|
31915
|
+
init_export();
|
|
31916
|
+
init_rest();
|
|
30201
31917
|
init_plan();
|
|
30202
31918
|
init_tokens();
|
|
30203
31919
|
init_tokens_export();
|
|
@@ -30206,38 +31922,58 @@ var init_server3 = __esm(async () => {
|
|
|
30206
31922
|
init_package();
|
|
30207
31923
|
PORT = Number(process.env.FIGMA_BRIDGE_PORT) || 3055;
|
|
30208
31924
|
CHANNEL = process.env.FIGMA_BRIDGE_CHANNEL || "default";
|
|
30209
|
-
OUT_DIR = process.env.FIGMA_BRIDGE_OUT ||
|
|
30210
|
-
LOG_FILE =
|
|
31925
|
+
OUT_DIR = process.env.FIGMA_BRIDGE_OUT || join9(tmpdir(), "figma-bridge");
|
|
31926
|
+
LOG_FILE = join9(OUT_DIR, "bridge.log");
|
|
30211
31927
|
MAX_IMAGE_BYTES = 50 << 20;
|
|
30212
|
-
|
|
31928
|
+
mkdirSync6(OUT_DIR, { recursive: true });
|
|
30213
31929
|
try {
|
|
30214
31930
|
if (statSync3(LOG_FILE).size > 5 << 20)
|
|
30215
31931
|
truncateSync(LOG_FILE, 0);
|
|
30216
31932
|
} catch {}
|
|
30217
31933
|
bridge = new Bridge({ port: PORT, channel: CHANNEL, version, log });
|
|
30218
31934
|
bridge.start();
|
|
31935
|
+
INSTRUCTIONS = [
|
|
31936
|
+
"Figma Bridge drives the user's Figma desktop app through a local plugin. No rate limits: iterate freely.",
|
|
31937
|
+
"Workflow:",
|
|
31938
|
+
"1. get_context, then get_design_system when the file has styles, variables or components: reuse them instead of raw values. find locates layers by name, text, type, style or component across pages.",
|
|
31939
|
+
"2. New UI → build (one call per screen or section, with auto-layout; grid, rich text spans, component sets with variants, prototype reactions). Editing existing design → describe it first, then build into it (parentId) or run_script.",
|
|
31940
|
+
"3. Design system → design_tokens writes variables (with Light/Dark modes) and styles from simple JSON, W3C tokens or a Tailwind theme; build uses them via var:, style: and modes.",
|
|
31941
|
+
"4. Verify → screenshot with returnImage:true, and audit to catch contrast, overflow and naming issues. Fix, then check again. audit {scope:'design-system'} scores the file's design system; audit {fix:true} binds raw values to variables and styles.",
|
|
31942
|
+
"Reproduce a mockup or screenshot: build it at the mockup's size → compare {nodeId, reference, returnImage:true} → fix the largest regions → compare again until mismatchPercent stops dropping.",
|
|
31943
|
+
"5. Before risky changes to existing work → checkpoint {action:'save'}; restore if the result is worse. Each command is one Ctrl+Z step for the user.",
|
|
31944
|
+
"Existing website or HTML → import_web turns it into editable auto-layout frames (one per viewport); then compare against the returned reference screenshot.",
|
|
31945
|
+
"When the target is unclear, wait_for_selection asks the user to pick it in Figma. prototype links screens, annotate leaves Dev Mode notes, export_code turns a frame into code; give it projectPath to reuse the project's components and tokens.",
|
|
31946
|
+
"run_script: `figma` global, top-level await, `return` a small JSON value (nodes come back as {id,name,type}).",
|
|
31947
|
+
"Script helpers: utils.loadFonts('Inter:Bold', …), utils.node(id), utils.page(name), utils.hex('#hex'), utils.solid('#hex', opacity?),",
|
|
31948
|
+
"utils.build(spec, {parentId}), utils.describe(nodeOrId, depth); lib.<name>(args) runs a saved snippet; console.log is returned as `logs`.",
|
|
31949
|
+
"Save helpers you will reuse with the snippets tool. Icons: search_icons, then insert_icon or {icon:'set:name'} in build.",
|
|
31950
|
+
"Rules: load fonts before editing text; pages load on demand (await figma.setCurrentPageAsync / getNodeByIdAsync); keep results small.",
|
|
31951
|
+
"The user can cancel a command from the plugin (code CANCELLED): build stops, a script may still finish, so check the file before retrying.",
|
|
31952
|
+
"With FIGMA_TOKEN set: comments reads and posts file comments pinned to layers, versions lists the history and diffs a version against now.",
|
|
31953
|
+
"Roblox: export_roblox turns a frame into a ScreenGui (.rbxmx + Luau for execute_luau + PNG assets); with a Roblox Studio MCP, upload the assets, run the Luau and check a Studio screenshot.",
|
|
31954
|
+
"Prompts new-screen, apply-design-system, reproduce-screenshot, import-website, figma-to-code, audit-and-fix and figma-to-roblox are ready-made workflows; the resources figma-bridge://docs/build-spec and figma-bridge://docs/workflow are the full reference.",
|
|
31955
|
+
"If no file is connected, tell the user to run Plugins → Development → Figma Bridge in Figma (Ctrl+Alt+P re-runs it). If list_sessions reports versionMismatch, ask them to reopen or update the plugin."
|
|
31956
|
+
];
|
|
30219
31957
|
server = new McpServer({ name: "figma-bridge", version }, {
|
|
30220
|
-
instructions:
|
|
30221
|
-
"Figma Bridge drives the user's Figma desktop app through a local plugin. No rate limits: iterate freely.",
|
|
30222
|
-
"Workflow:",
|
|
30223
|
-
"1. get_context, then get_design_system when the file has styles, variables or components: reuse them instead of raw values. find locates layers by name, text, type, style or component across pages.",
|
|
30224
|
-
"2. New UI → build (one call per screen or section, with auto-layout; grid, rich text spans, component sets with variants, prototype reactions). Editing existing design → describe it first, then build into it (parentId) or run_script.",
|
|
30225
|
-
"3. Design system → design_tokens writes variables (with Light/Dark modes) and styles from simple JSON, W3C tokens or a Tailwind theme; build uses them via var:, style: and modes.",
|
|
30226
|
-
"4. Verify → screenshot with returnImage:true, and audit to catch contrast, overflow and naming issues. Fix, then check again. audit {scope:'design-system'} scores the file's design system; audit {fix:true} binds raw values to variables and styles.",
|
|
30227
|
-
"Reproduce a mockup or screenshot: build it at the mockup's size → compare {nodeId, reference, returnImage:true} → fix the largest regions → compare again until mismatchPercent stops dropping.",
|
|
30228
|
-
"5. Before risky changes to existing work → checkpoint {action:'save'}; restore if the result is worse. Each command is one Ctrl+Z step for the user.",
|
|
30229
|
-
"Existing website or HTML → import_web turns it into editable auto-layout frames (one per viewport); then compare against the returned reference screenshot.",
|
|
30230
|
-
"When the target is unclear, wait_for_selection asks the user to pick it in Figma. prototype links screens, annotate leaves Dev Mode notes, export_code turns a frame into code; give it projectPath to reuse the project's components and tokens.",
|
|
30231
|
-
"run_script: `figma` global, top-level await, `return` a small JSON value (nodes come back as {id,name,type}).",
|
|
30232
|
-
"Script helpers: utils.loadFonts('Inter:Bold', …), utils.node(id), utils.page(name), utils.hex('#hex'), utils.solid('#hex', opacity?),",
|
|
30233
|
-
"utils.build(spec, {parentId}), utils.describe(nodeOrId, depth); lib.<name>(args) runs a saved snippet; console.log is returned as `logs`.",
|
|
30234
|
-
"Save helpers you will reuse with the snippets tool. Icons: search_icons, then insert_icon or {icon:'set:name'} in build.",
|
|
30235
|
-
"Rules: load fonts before editing text; pages load on demand (await figma.setCurrentPageAsync / getNodeByIdAsync); keep results small.",
|
|
30236
|
-
"The user can cancel a command from the plugin (code CANCELLED): build stops, a script may still finish, so check the file before retrying.",
|
|
30237
|
-
"If no file is connected, tell the user to run Plugins → Development → Figma Bridge in Figma (Ctrl+Alt+P re-runs it). If list_sessions reports versionMismatch, ask them to reopen or update the plugin."
|
|
30238
|
-
].join(`
|
|
31958
|
+
instructions: INSTRUCTIONS.join(`
|
|
30239
31959
|
`)
|
|
30240
31960
|
});
|
|
31961
|
+
SESSION_TOOLS = new Set(["list_sessions", "select_session"]);
|
|
31962
|
+
registerTool = server.registerTool.bind(server);
|
|
31963
|
+
toolDocs = [];
|
|
31964
|
+
server.registerTool = (name, config, handler) => {
|
|
31965
|
+
toolDocs.push({ name, title: config.title, description: config.description ?? "" });
|
|
31966
|
+
if (SESSION_TOOLS.has(name))
|
|
31967
|
+
return registerTool(name, config, handler);
|
|
31968
|
+
const inputSchema = {
|
|
31969
|
+
...config.inputSchema,
|
|
31970
|
+
file: string2().optional().describe("Target file name or session id when several files are connected (default: the selected file)")
|
|
31971
|
+
};
|
|
31972
|
+
return registerTool(name, { ...config, inputSchema }, (args, extra) => {
|
|
31973
|
+
const { file, ...rest } = args ?? {};
|
|
31974
|
+
return callTarget.run({ file: typeof file === "string" && file ? file : undefined }, () => handler(rest, extra));
|
|
31975
|
+
});
|
|
31976
|
+
};
|
|
30241
31977
|
server.registerTool("run_script", {
|
|
30242
31978
|
title: "Run Figma Plugin API script",
|
|
30243
31979
|
description: "Execute JavaScript in the Figma plugin main thread (full Plugin API via `figma`). The code is the body of an async " + "function: use top-level await and `return` the output (JSON-serialized; a single expression is returned automatically). " + "Helpers: utils.loadFonts, utils.node, utils.page, utils.hex, utils.solid, utils.build, utils.describe; lib.<snippet>(args); " + "console.log is captured. On error returns {ok:false, error, line, stack}. Prefer build for creating new layouts. " + "If the user presses Cancel in the plugin you get code CANCELLED, but a script cannot be interrupted and may still finish: check the file before retrying.",
|
|
@@ -30253,23 +31989,7 @@ var init_server3 = __esm(async () => {
|
|
|
30253
31989
|
}));
|
|
30254
31990
|
server.registerTool("build", {
|
|
30255
31991
|
title: "Build a layout from a JSON spec",
|
|
30256
|
-
description:
|
|
30257
|
-
Node: {type?, name?, ...props, children?: Node[]}. type is inferred (text/spans→text, icon→icon, src→image, component→instance, variants→componentSet, else frame). Types: frame, component, componentSet, text, rect, ellipse, line, icon, image, svg, instance.
|
|
30258
|
-
FRAME: layout "row"|"column"|"grid" (auto-layout; omit for free positioning), gap (number|"auto"), padding (n | [v,h] | [t,r,b,l]), align (cross axis: start|center|end|baseline), justify (main axis: start|center|end|between), wrap, rowGap, clip. Frames have no fill unless set.
|
|
30259
|
-
GRID: layout:"grid", columns (count | tracks like [200,"1fr","2fr","hug"]), rows (same; default: enough rows, hugging), columnGap, rowGap (or gap). Children: span:[rows,cols] or colSpan/rowSpan, cellAlign/cellValign. Give the grid a w for "fr" columns.
|
|
30260
|
-
SIZE: w / h: number (fixed) | "fill" (stretch inside an auto-layout parent) | "hug". Auto-layout frames hug by default. grow:true. absolute:true with x/y inside auto-layout; x/y for children of free frames.
|
|
30261
|
-
TEXT: text, font ("Inter" | "Inter:Bold"), weight (400|500|600|700 or style name), size, color, lineHeight (1.5 | 24 | "150%"), letterSpacing (px | "2%"), align (left|center|right|justify), case (upper|lower|title), decoration (underline|strike), maxLines, textStyle "style:Name". Give w:"fill" or a number to wrap text.
|
|
30262
|
-
RICH TEXT: spans:[{text:"Read the "},{text:"docs",weight:600,color:"#0D99FF",link:"https://…"}] instead of text; a span may set font, weight, size, color, decoration, case, link.
|
|
30263
|
-
PAINT (fill, stroke, color): "#RRGGBB[AA]", "style:<paint style>", "var:<color variable>", {gradient:["#a","#b"], angle:90, type?:"radial"}, {image:"https://…"|path, fit:"fill"|"fit"|"crop"|"tile"}, null, or an array (last on top). stroke + strokeWidth (n | [t,r,b,l]) + strokeAlign (inside|center|outside) + strokeDash [dash,gap].
|
|
30264
|
-
EFFECTS: radius (n | [tl,tr,br,bl] | "var:x"), opacity, shadow (true | {x,y,blur,spread,color} | [...] | "style:Name"), blur, backgroundBlur, rotation, visible.
|
|
30265
|
-
ICON: {icon:"lucide:house", size:20, color:"#111"} (any Iconify set). IMAGE: {src:"C:/img.png" | "https://…", w, h, fit:"fill"|"fit"|"crop"|"tile"}. SVG: {svg:"<svg…>"}.
|
|
30266
|
-
INSTANCE: {component:"Button" | node id | library key, props:{Variant:"Primary", Label:"Buy"}, text:{"Label layer name":"Buy"}}.
|
|
30267
|
-
COMPONENT SET: {type:"componentSet", name:"Button", base:{shared frame spec}, variants:[{props:{Variant:"Primary",Size:"M"}, fill:"#0D99FF", children:[…]}, …], properties:{Label:"Button", "Show icon":{type:"boolean",default:true}, Icon:{type:"instance",default:"Icon/Star"}}}. One component per variant, combined as variants.
|
|
30268
|
-
On a layer inside a component: bind:"Label" links a text to a text property (created if missing); bind:{visible:"Show icon"} or {mainComponent:"Icon"} for the others. type:"component" takes properties too.
|
|
30269
|
-
PROTOTYPE: reactions:[{trigger:"click", action:"navigate", to:"Details", transition:"smart", duration:300}] on any layer (see the prototype tool).
|
|
30270
|
-
gap/padding/radius accept "var:<number variable>". MODES: modes:{"Theme":"Dark"} sets a collection's variable mode on a frame and its children.
|
|
30271
|
-
Example: {"name":"Card","layout":"column","w":320,"padding":24,"gap":12,"fill":"#FFFFFF","radius":16,"shadow":true,"children":[{"text":"Pro plan","size":20,"weight":600},{"text":"Everything you need","color":"#6B7280","w":"fill"},{"layout":"row","gap":8,"align":"center","children":[{"icon":"lucide:check","size":16,"color":"#16A34A"},{"text":"Unlimited projects"}]}]}
|
|
30272
|
-
Returns {rootId, ids:{layerName:id}, created, warnings}. The result is selected and zoomed to unless select:false.`,
|
|
31992
|
+
description: BUILD_DESCRIPTION,
|
|
30273
31993
|
inputSchema: {
|
|
30274
31994
|
spec: object2({}).passthrough().describe("Root node: {name, layout, children:[...], ...}. Several roots: wrap them in a frame or call build again."),
|
|
30275
31995
|
parentId: string2().optional().describe("Parent frame (default: current page)"),
|
|
@@ -30356,7 +32076,7 @@ ${f.content}`).join(`
|
|
|
30356
32076
|
const single = files.length === 1 && /\.[a-z0-9]+$/i.test(args.path);
|
|
30357
32077
|
const written = [];
|
|
30358
32078
|
for (const f of files) {
|
|
30359
|
-
const target = single ? args.path :
|
|
32079
|
+
const target = single ? args.path : join9(args.path, f.path);
|
|
30360
32080
|
written.push({ format: f.format, path: target, status: await writeIfChanged(target, f.content) });
|
|
30361
32081
|
}
|
|
30362
32082
|
return ok({ counts, files: written, ...warnings.length ? { warnings } : {} });
|
|
@@ -30394,7 +32114,7 @@ ${f.content}`).join(`
|
|
|
30394
32114
|
const bytes = Buffer.from(r.bytesB64, "base64");
|
|
30395
32115
|
const info = imageInfo(bytes);
|
|
30396
32116
|
const ext = r.format === "JPG" ? "jpg" : "png";
|
|
30397
|
-
const path =
|
|
32117
|
+
const path = join9(OUT_DIR, `${safeName(r.name)}-${safeName(r.nodeId)}-${Date.now()}.${ext}`);
|
|
30398
32118
|
await writeFile(path, bytes);
|
|
30399
32119
|
const meta = { path, nodeId: r.nodeId, name: r.name, width: info?.width, height: info?.height, bytes: bytes.length };
|
|
30400
32120
|
const content = [{ type: "text", text: json(meta) }];
|
|
@@ -30421,7 +32141,7 @@ ${f.content}`).join(`
|
|
|
30421
32141
|
const diff = await bridge.request("compare_images", { actual: shot.bytesB64, reference: Buffer.from(refBytes).toString("base64"), referenceMime: info.mime, threshold: args.threshold, sideBySide: !!args.returnImage }, 90000);
|
|
30422
32142
|
const pct = (v) => Math.round(v * 1e4) / 100;
|
|
30423
32143
|
const toLayer = (v, axis) => Math.round((v / shot.scale + shot.offset[axis]) * 10) / 10;
|
|
30424
|
-
const heatmapPath =
|
|
32144
|
+
const heatmapPath = join9(OUT_DIR, `compare-${safeName(shot.name)}-${Date.now()}.png`);
|
|
30425
32145
|
await writeFile(heatmapPath, Buffer.from(diff.heatmap, "base64"));
|
|
30426
32146
|
const refRatio = diff.reference.width / diff.reference.height;
|
|
30427
32147
|
const ratio = diff.width / diff.height;
|
|
@@ -30495,7 +32215,7 @@ Example: {"url":"https://example.com","viewports":[1440,390]}`,
|
|
|
30495
32215
|
const built = await bridge.request("build", { spec: v.spec, images, parentId: args.parentId, x, y, select: false, label: "Web import" }, 120000);
|
|
30496
32216
|
let reference;
|
|
30497
32217
|
if (v.screenshot) {
|
|
30498
|
-
reference =
|
|
32218
|
+
reference = join9(OUT_DIR, `import-${safeName(v.title || "page")}-${v.viewport}-${Date.now()}.png`);
|
|
30499
32219
|
await writeFile(reference, v.screenshot);
|
|
30500
32220
|
}
|
|
30501
32221
|
frames.push({ viewport: v.viewport, rootId: built.rootId, name: v.spec.name, layers: built.created, size: { width: v.width, height: v.height }, reference });
|
|
@@ -30565,7 +32285,7 @@ Links are added to existing interactions unless replace:true; clear:[ids] remove
|
|
|
30565
32285
|
}
|
|
30566
32286
|
}, ({ nodeId, projectPath, framework, styling, name, outDir, write, overwrite }) => track("export_code", `${nodeId ?? "(selection)"} ${projectPath ?? `${framework ?? "html"}/${styling ?? "css"}`}`, async () => {
|
|
30567
32287
|
const r = await bridge.request("export_tree", { nodeId }, 120000);
|
|
30568
|
-
const dir =
|
|
32288
|
+
const dir = join9(OUT_DIR, `export-${safeName(r.tree.name)}-${Date.now()}`);
|
|
30569
32289
|
const finalName = {};
|
|
30570
32290
|
const assetBytes = {};
|
|
30571
32291
|
for (const [file, a] of Object.entries(r.assets)) {
|
|
@@ -30581,8 +32301,8 @@ Links are added to existing interactions unless replace:true; clear:[ids] remove
|
|
|
30581
32301
|
if (projectPath) {
|
|
30582
32302
|
const plan = planProjectExport(projectPath, tree, assetBytes, { framework, styling, name, outDir });
|
|
30583
32303
|
for (const f of plan.files) {
|
|
30584
|
-
|
|
30585
|
-
|
|
32304
|
+
mkdirSync6(dirname4(join9(dir, f.path)), { recursive: true });
|
|
32305
|
+
writeFileSync6(join9(dir, f.path), typeof f.content === "string" ? f.content : Buffer.from(f.content));
|
|
30586
32306
|
}
|
|
30587
32307
|
const written = write ? writePlan(projectPath, plan.files, !!overwrite) : null;
|
|
30588
32308
|
const s = plan.stack;
|
|
@@ -30607,13 +32327,13 @@ ${f.content}`).join(`
|
|
|
30607
32327
|
… truncated: the full code is in ${dir}`;
|
|
30608
32328
|
return { content: [{ type: "text", text: json(meta) }, { type: "text", text }] };
|
|
30609
32329
|
}
|
|
30610
|
-
|
|
32330
|
+
mkdirSync6(join9(dir, "assets"), { recursive: true });
|
|
30611
32331
|
for (const [file, bytes] of Object.entries(assetBytes))
|
|
30612
|
-
|
|
32332
|
+
writeFileSync6(join9(dir, "assets", file), bytes);
|
|
30613
32333
|
const fw = framework ?? "html";
|
|
30614
32334
|
const code = generateCode(tree, { framework: fw, styling: styling ?? "css", assetPath: (f) => `assets/${f}` });
|
|
30615
32335
|
for (const f of code.files)
|
|
30616
|
-
|
|
32336
|
+
writeFileSync6(join9(dir, f.path), f.content);
|
|
30617
32337
|
warnings.push(...code.warnings);
|
|
30618
32338
|
const meta = {
|
|
30619
32339
|
dir,
|
|
@@ -30762,9 +32482,119 @@ ${f.content}`).join(`
|
|
|
30762
32482
|
return ok({ deleted: args.name });
|
|
30763
32483
|
}
|
|
30764
32484
|
}));
|
|
32485
|
+
server.registerTool("export_roblox", {
|
|
32486
|
+
title: "Export a frame to Roblox UI",
|
|
32487
|
+
description: "Turn a Figma frame (default: selection) into Roblox UI that looks the same: a .rbxmx model (a ScreenGui, or a Frame with asRootFrame), an equivalent Luau builder " + `script for execute_luau (creates the UI under PARENT, default StarterGui, replaces a previous copy with the same name, returns the root), and the PNG assets.
|
|
32488
|
+
` + "Frames become Frames with UICorner, UIStroke, UIGradient, UIPadding; auto-layout becomes UIListLayout (UIFlexItem for fill, AutomaticSize for hug) or UIGridLayout; " + "constraints become AnchorPoint + UDim2; text becomes TextLabel with FontFace (Inter → Builder Sans…), RichText for mixed styles, UITextSizeConstraint; clickable or " + "*Button layers become TextButton/ImageButton. What Roblox can't draw (vectors, icons, blurs, several fills, radial gradients) is rasterized at 2×; panels whose background " + `can't be native become 9-slice images with their content on top; drop shadows become a 9-slice image behind the panel.
|
|
32489
|
+
` + "mode: scale (UDim2 scale everywhere), offset (pixels), hybrid (default: scale for free-positioned layers, pixels inside auto-layout). " + "Assets: with upload:true and ROBLOX_API_KEY + ROBLOX_CREATOR_ID set, pictures are uploaded through Open Cloud and their ids written in; otherwise each asset has a " + "placeholder (rbxassetid://PENDING_n): upload the files with your Roblox Studio tools (e.g. upload_image), replace the placeholders in the Luau, run it with execute_luau and check a Studio screenshot. " + 'Example: {"nodeId":"12:34","mode":"hybrid"}',
|
|
32490
|
+
inputSchema: {
|
|
32491
|
+
nodeId: string2().optional(),
|
|
32492
|
+
mode: _enum(["scale", "offset", "hybrid"]).optional().describe("Default hybrid"),
|
|
32493
|
+
targetResolution: array(number2().int().min(100).max(8000)).length(2).optional().describe("Screen size the UI is designed for, default [1920, 1080]"),
|
|
32494
|
+
rasterize: _enum(["auto", "none", "all"]).optional().describe("auto (default): pictures only where Roblox can't draw it; none: approximate instead; all: every styled layer"),
|
|
32495
|
+
outDir: string2().optional().describe("Folder for the files (default: a new temp folder)"),
|
|
32496
|
+
upload: boolean2().optional().describe("Upload the pictures through Open Cloud (needs ROBLOX_API_KEY and ROBLOX_CREATOR_ID)"),
|
|
32497
|
+
asRootFrame: boolean2().optional().describe("Root is a Frame instead of a ScreenGui"),
|
|
32498
|
+
textScaled: boolean2().optional().describe("TextScaled on labels (UITextSizeConstraint keeps the design size as the maximum)"),
|
|
32499
|
+
fonts: object2({}).passthrough().optional().describe('Figma family → Roblox family or rbxasset URL, e.g. {"Inter":"GothamSSm"}'),
|
|
32500
|
+
parent: string2().optional().describe('Luau expression for the parent, default game:GetService("StarterGui")')
|
|
32501
|
+
}
|
|
32502
|
+
}, (args) => track("export_roblox", `${args.nodeId ?? "(selection)"} ${args.mode ?? "hybrid"}`, async () => {
|
|
32503
|
+
const r = await exportRoblox({ ...args, targetResolution: args.targetResolution, fonts: args.fonts }, { request: (m, p, t) => bridge.request(m, p, t), outDir: OUT_DIR });
|
|
32504
|
+
let luau = r.luau;
|
|
32505
|
+
if (luau.length > MAX_OUTPUT_CHARS)
|
|
32506
|
+
luau = luau.slice(0, MAX_OUTPUT_CHARS) + `
|
|
32507
|
+
-- … truncated: the full script is in ${r.result.luau}`;
|
|
32508
|
+
return { content: [{ type: "text", text: json(r.result) }, { type: "text", text: luau }] };
|
|
32509
|
+
}));
|
|
32510
|
+
server.registerTool("comments", {
|
|
32511
|
+
title: "Read and write file comments",
|
|
32512
|
+
description: "Figma comments through the REST API (needs FIGMA_TOKEN; without it, returns how to set one). " + "list: open threads with their replies and the layer each is pinned to (includeResolved for all). " + "post: {message, nodeId?} pinned to a layer (x/y offset inside it), or on the file. reply: {commentId, message}. " + `resolve: Figma's API cannot resolve comments, so this replies "✓ Resolved" (or message) and the user clicks Resolve. delete: {commentId} (your own comments). ` + 'The file is the connected one, or fileUrl. Example: {"action":"post","nodeId":"12:34","message":"Contrast is 2.8:1 here"}',
|
|
32513
|
+
inputSchema: {
|
|
32514
|
+
action: _enum(["list", "post", "reply", "resolve", "delete"]).optional().describe("Default list"),
|
|
32515
|
+
fileUrl: string2().optional().describe("File link or key (default: the connected file)"),
|
|
32516
|
+
nodeId: string2().optional().describe("post: layer to pin the comment to"),
|
|
32517
|
+
x: number2().optional().describe("post: offset inside the layer, px"),
|
|
32518
|
+
y: number2().optional(),
|
|
32519
|
+
message: string2().max(5000).optional(),
|
|
32520
|
+
commentId: string2().optional().describe("reply, resolve, delete"),
|
|
32521
|
+
includeResolved: boolean2().optional().describe("list: include resolved threads")
|
|
32522
|
+
}
|
|
32523
|
+
}, (args) => track("comments", `${args.action ?? "list"} ${args.commentId ?? args.nodeId ?? ""}`, async () => {
|
|
32524
|
+
const { rest, key, session } = await restContext(args.fileUrl);
|
|
32525
|
+
const action = args.action ?? "list";
|
|
32526
|
+
const need = (v, what) => {
|
|
32527
|
+
if (!v)
|
|
32528
|
+
throw new BridgeError(`\`${what}\` is required for ${action}.`, "BAD_ARGS");
|
|
32529
|
+
return v;
|
|
32530
|
+
};
|
|
32531
|
+
if (action === "list") {
|
|
32532
|
+
const threads = threadComments(await rest.comments(key), !!args.includeResolved);
|
|
32533
|
+
const names = await layerNames(session, threads.map((t) => t.nodeId).filter((x) => !!x));
|
|
32534
|
+
return ok({
|
|
32535
|
+
fileKey: key,
|
|
32536
|
+
open: threads.filter((t) => !t.resolvedAt).length,
|
|
32537
|
+
threads: threads.map((t) => t.nodeId && names[t.nodeId] ? { ...t, layer: names[t.nodeId] } : t)
|
|
32538
|
+
});
|
|
32539
|
+
}
|
|
32540
|
+
if (action === "post") {
|
|
32541
|
+
const c = await rest.postComment(key, need(args.message, "message"), { nodeId: args.nodeId, offset: args.nodeId ? { x: args.x ?? 0, y: args.y ?? 0 } : undefined });
|
|
32542
|
+
return ok({ posted: c.id, nodeId: args.nodeId ?? null });
|
|
32543
|
+
}
|
|
32544
|
+
if (action === "reply") {
|
|
32545
|
+
const c = await rest.postComment(key, need(args.message, "message"), { replyTo: need(args.commentId, "commentId") });
|
|
32546
|
+
return ok({ replied: c.id, to: args.commentId });
|
|
32547
|
+
}
|
|
32548
|
+
if (action === "resolve") {
|
|
32549
|
+
const c = await rest.postComment(key, args.message ?? "✓ Resolved", { replyTo: need(args.commentId, "commentId") });
|
|
32550
|
+
return ok({ replied: c.id, to: args.commentId, note: "Figma's REST API has no way to resolve a comment: a reply says it is resolved; ask the user to click Resolve." });
|
|
32551
|
+
}
|
|
32552
|
+
await rest.deleteComment(key, need(args.commentId, "commentId"));
|
|
32553
|
+
return ok({ deleted: args.commentId });
|
|
32554
|
+
}));
|
|
32555
|
+
server.registerTool("versions", {
|
|
32556
|
+
title: "File versions and what changed",
|
|
32557
|
+
description: "Version history through the REST API (needs FIGMA_TOKEN). list: saved versions (id, label, description, author, date). " + "diff: {versionId} compares that version with now (or with toVersionId) and lists layers added, removed and changed (text, size, position, fill, visibility, type), matched by their name path. " + "Scope: nodeId, else the current page of the connected file, else every page. The file is the connected one, or fileUrl. " + 'Example: {"action":"diff","versionId":"1234567890"}',
|
|
32558
|
+
inputSchema: {
|
|
32559
|
+
action: _enum(["list", "diff"]).optional().describe("Default list"),
|
|
32560
|
+
fileUrl: string2().optional().describe("File link or key (default: the connected file)"),
|
|
32561
|
+
versionId: string2().optional().describe("diff: the older version"),
|
|
32562
|
+
toVersionId: string2().optional().describe("diff: the newer version (default: now)"),
|
|
32563
|
+
nodeId: string2().optional().describe("diff: only this layer and its children"),
|
|
32564
|
+
limit: number2().int().min(1).max(200).optional().describe("list: max versions, default 30; diff: max items per list, default 200")
|
|
32565
|
+
}
|
|
32566
|
+
}, (args) => track("versions", `${args.action ?? "list"} ${args.versionId ?? ""}`, async () => {
|
|
32567
|
+
const { rest, key, session } = await restContext(args.fileUrl);
|
|
32568
|
+
if ((args.action ?? "list") === "list") {
|
|
32569
|
+
const list = await rest.versions(key, args.limit ?? 30);
|
|
32570
|
+
return ok({
|
|
32571
|
+
fileKey: key,
|
|
32572
|
+
versions: list.map((v) => ({ id: v.id, createdAt: v.created_at, ...v.label ? { label: v.label } : {}, ...v.description ? { description: v.description } : {}, author: v.user?.handle }))
|
|
32573
|
+
});
|
|
32574
|
+
}
|
|
32575
|
+
if (!args.versionId)
|
|
32576
|
+
throw new BridgeError('`versionId` is required for diff: call versions {action:"list"} first.', "BAD_ARGS");
|
|
32577
|
+
let ids;
|
|
32578
|
+
if (args.nodeId)
|
|
32579
|
+
ids = [args.nodeId];
|
|
32580
|
+
else if (session?.pageId)
|
|
32581
|
+
ids = [session.pageId];
|
|
32582
|
+
else
|
|
32583
|
+
ids = [...new Set([...await rest.pages(key, args.versionId), ...await rest.pages(key, args.toVersionId)].map((p) => p.id))];
|
|
32584
|
+
const [before, after] = await Promise.all([rest.nodes(key, ids, args.versionId), rest.nodes(key, ids, args.toVersionId)]);
|
|
32585
|
+
const empty = (id, name) => ({ id, name, type: "PAGE", children: [] });
|
|
32586
|
+
const results = ids.map((id) => {
|
|
32587
|
+
const b = before[id];
|
|
32588
|
+
const a = after[id];
|
|
32589
|
+
const name = (a ?? b)?.name ?? id;
|
|
32590
|
+
const d = diffTrees(b ?? empty(id, name), a ?? empty(id, name), args.limit ?? 200);
|
|
32591
|
+
return { id, name, ...b ? {} : { note: "did not exist in the older version" }, ...a ? {} : { note: "no longer exists" }, ...d };
|
|
32592
|
+
});
|
|
32593
|
+
return ok({ fileKey: key, from: args.versionId, to: args.toVersionId ?? "now", scopes: results });
|
|
32594
|
+
}));
|
|
30765
32595
|
server.registerTool("list_sessions", {
|
|
30766
32596
|
title: "List connected Figma files",
|
|
30767
|
-
description: "Figma files that currently have the plugin open, plus bridge status.
|
|
32597
|
+
description: "Figma files that currently have the plugin open (with their editor: figma, figjam or slides), plus bridge status. With several files, " + 'select_session picks the default one, or pass file:"<name>" to any tool to target a file for that call only.',
|
|
30768
32598
|
inputSchema: {}
|
|
30769
32599
|
}, () => track("list_sessions", "", async () => {
|
|
30770
32600
|
const sessions = await bridge.sessions().catch(() => []);
|
|
@@ -30776,6 +32606,7 @@ ${f.content}`).join(`
|
|
|
30776
32606
|
fileName: s.fileName,
|
|
30777
32607
|
page: s.page,
|
|
30778
32608
|
pluginVersion: s.version,
|
|
32609
|
+
editor: s.editorType,
|
|
30779
32610
|
...s.version && s.version !== version ? { versionMismatch: `Plugin v${s.version}, server v${version}: ask the user to reopen the plugin in Figma, or update it.` } : {},
|
|
30780
32611
|
connectedSeconds: Math.round((Date.now() - s.connectedAt) / 1000),
|
|
30781
32612
|
selected: s.id === selected || undefined
|
|
@@ -30791,6 +32622,7 @@ ${f.content}`).join(`
|
|
|
30791
32622
|
const s = await bridge.select(name);
|
|
30792
32623
|
return ok({ selected: { id: s.id, fileName: s.fileName, page: s.page } });
|
|
30793
32624
|
}));
|
|
32625
|
+
registerGuides(server, { instructions: INSTRUCTIONS, buildSpec: BUILD_DESCRIPTION, tools: () => toolDocs });
|
|
30794
32626
|
await server.connect(new StdioServerTransport);
|
|
30795
32627
|
log(`Figma Bridge MCP ${version} ready (port ${PORT}, channel ${CHANNEL}, files in ${OUT_DIR})`);
|
|
30796
32628
|
process.stdin.on("close", shutdown);
|
|
@@ -30799,8 +32631,8 @@ ${f.content}`).join(`
|
|
|
30799
32631
|
});
|
|
30800
32632
|
|
|
30801
32633
|
// src/cli.ts
|
|
30802
|
-
import { homedir as
|
|
30803
|
-
import { dirname as
|
|
32634
|
+
import { homedir as homedir3 } from "node:os";
|
|
32635
|
+
import { dirname as dirname5, join as join10 } from "node:path";
|
|
30804
32636
|
import { fileURLToPath as fileURLToPath2 } from "node:url";
|
|
30805
32637
|
|
|
30806
32638
|
// src/setup.ts
|
|
@@ -30941,9 +32773,9 @@ function parseClients(argv) {
|
|
|
30941
32773
|
init_package();
|
|
30942
32774
|
var PACKAGE = "@clovis500c/figma-bridge";
|
|
30943
32775
|
var [cmd, ...rest] = process.argv.slice(2);
|
|
30944
|
-
var home =
|
|
30945
|
-
var pluginSource =
|
|
30946
|
-
var pluginTarget =
|
|
32776
|
+
var home = homedir3();
|
|
32777
|
+
var pluginSource = join10(dirname5(fileURLToPath2(import.meta.url)), "..", "plugin");
|
|
32778
|
+
var pluginTarget = join10(home, ".figma-bridge", "plugin");
|
|
30947
32779
|
var npx = process.platform === "win32" ? { command: "cmd", args: ["/c", "npx", "-y", PACKAGE] } : { command: "npx", args: ["-y", PACKAGE] };
|
|
30948
32780
|
function plugin() {
|
|
30949
32781
|
const manifest = installPlugin(pluginSource, pluginTarget);
|
|
@@ -30973,7 +32805,7 @@ Figma Bridge setup
|
|
|
30973
32805
|
break;
|
|
30974
32806
|
}
|
|
30975
32807
|
try {
|
|
30976
|
-
const appData = process.env.APPDATA ||
|
|
32808
|
+
const appData = process.env.APPDATA || join10(home, "AppData", "Roaming");
|
|
30977
32809
|
printSetup(runSetup({ home, appData, command: npx.command, args: npx.args, clients: parseClients(rest) }), `npx ${PACKAGE} setup`);
|
|
30978
32810
|
} catch (e) {
|
|
30979
32811
|
console.log(yellow(e.message));
|