@clovis500c/figma-bridge 1.8.0 → 1.13.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 +49 -13
- package/dist/server.js +2256 -351
- package/package.json +7 -3
- package/plugin/code.js +577 -20
- package/plugin/manifest.json +2 -1
- package/plugin/ui.html +24 -3
package/dist/server.js
CHANGED
|
@@ -46,8 +46,175 @@ var __esm = (fn, res, err) => () => {
|
|
|
46
46
|
};
|
|
47
47
|
var __require = /* @__PURE__ */ createRequire(import.meta.url);
|
|
48
48
|
|
|
49
|
+
// src/setup.ts
|
|
50
|
+
import { copyFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
51
|
+
import { dirname, join } from "node:path";
|
|
52
|
+
function clientList(home, appData) {
|
|
53
|
+
return [
|
|
54
|
+
{ id: "claude-code", label: "Claude Code", files: [join(home, ".claude.json")], format: "json", extra: { type: "stdio" } },
|
|
55
|
+
{ id: "claude-desktop", label: "Claude Desktop", files: [join(appData, "Claude", "claude_desktop_config.json")], format: "json" },
|
|
56
|
+
{ id: "codex", label: "Codex", files: [join(home, ".codex", "config.toml")], format: "toml" },
|
|
57
|
+
{
|
|
58
|
+
id: "antigravity",
|
|
59
|
+
label: "Antigravity",
|
|
60
|
+
files: [join(home, ".gemini", "antigravity", "mcp_config.json"), join(home, ".gemini", "config", "mcp_config.json")],
|
|
61
|
+
format: "json"
|
|
62
|
+
},
|
|
63
|
+
{ id: "gemini-cli", label: "Gemini CLI", files: [join(home, ".gemini", "settings.json")], format: "json" },
|
|
64
|
+
{ id: "cursor", label: "Cursor", files: [join(home, ".cursor", "mcp.json")], format: "json" },
|
|
65
|
+
{ id: "windsurf", label: "Windsurf", files: [join(home, ".codeium", "windsurf", "mcp_config.json")], format: "json" }
|
|
66
|
+
];
|
|
67
|
+
}
|
|
68
|
+
function backup(path, text) {
|
|
69
|
+
writeFileSync(path + ".bak-figma-bridge", text);
|
|
70
|
+
}
|
|
71
|
+
function writeJson(path, entry) {
|
|
72
|
+
const text = existsSync(path) ? readFileSync(path, "utf8") : "";
|
|
73
|
+
const config = text.trim() ? JSON.parse(text) : {};
|
|
74
|
+
if (text.trim())
|
|
75
|
+
backup(path, text);
|
|
76
|
+
config.mcpServers = { ...config.mcpServers, [SERVER_NAME]: entry };
|
|
77
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
78
|
+
writeFileSync(path, JSON.stringify(config, null, 2) + `
|
|
79
|
+
`);
|
|
80
|
+
}
|
|
81
|
+
function tomlBlock(command, args) {
|
|
82
|
+
return [
|
|
83
|
+
`[mcp_servers.${SERVER_NAME}]`,
|
|
84
|
+
`command = '${command}'`,
|
|
85
|
+
`args = [${args.map((a) => `'${a}'`).join(", ")}]`,
|
|
86
|
+
"startup_timeout_sec = 60",
|
|
87
|
+
"tool_timeout_sec = 120"
|
|
88
|
+
].join(`
|
|
89
|
+
`);
|
|
90
|
+
}
|
|
91
|
+
function writeToml(path, command, args) {
|
|
92
|
+
const text = existsSync(path) ? readFileSync(path, "utf8") : "";
|
|
93
|
+
if (text.trim())
|
|
94
|
+
backup(path, text);
|
|
95
|
+
const kept = [];
|
|
96
|
+
let skipping = false;
|
|
97
|
+
for (const line of text.split(/\r?\n/)) {
|
|
98
|
+
const header = /^\s*\[([^\]]+)\]\s*$/.exec(line);
|
|
99
|
+
if (header)
|
|
100
|
+
skipping = header[1] === `mcp_servers.${SERVER_NAME}` || header[1].startsWith(`mcp_servers.${SERVER_NAME}.`);
|
|
101
|
+
if (!skipping)
|
|
102
|
+
kept.push(line);
|
|
103
|
+
}
|
|
104
|
+
const body = kept.join(`
|
|
105
|
+
`).trimEnd();
|
|
106
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
107
|
+
writeFileSync(path, (body ? body + `
|
|
108
|
+
|
|
109
|
+
` : "") + tomlBlock(command, args) + `
|
|
110
|
+
`);
|
|
111
|
+
}
|
|
112
|
+
function runSetup(opts) {
|
|
113
|
+
const all = clientList(opts.home, opts.appData);
|
|
114
|
+
const forced = opts.clients ?? [];
|
|
115
|
+
const unknown = forced.filter((id) => !all.some((c) => c.id === id));
|
|
116
|
+
if (unknown.length)
|
|
117
|
+
throw new Error(`Unknown client(s): ${unknown.join(", ")}. Known: ${all.map((c) => c.id).join(", ")}`);
|
|
118
|
+
const result = { updated: [], failed: [], missing: [] };
|
|
119
|
+
for (const client of all) {
|
|
120
|
+
const detected = client.files.filter((f) => existsSync(dirname(f)) && (client.id !== "gemini-cli" || existsSync(f)));
|
|
121
|
+
const targets = forced.length ? forced.includes(client.id) ? detected.length ? detected : [client.files[0]] : [] : detected;
|
|
122
|
+
if (!targets.length) {
|
|
123
|
+
if (!forced.length)
|
|
124
|
+
result.missing.push(client.label);
|
|
125
|
+
continue;
|
|
126
|
+
}
|
|
127
|
+
for (const file of targets) {
|
|
128
|
+
try {
|
|
129
|
+
if (client.format === "toml")
|
|
130
|
+
writeToml(file, opts.command, opts.args);
|
|
131
|
+
else
|
|
132
|
+
writeJson(file, { ...client.extra, command: opts.command, args: opts.args });
|
|
133
|
+
result.updated.push({ client: client.id, label: client.label, file });
|
|
134
|
+
} catch (e) {
|
|
135
|
+
result.failed.push({ client: client.id, label: client.label, file, error: e.message });
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
return result;
|
|
140
|
+
}
|
|
141
|
+
function installPlugin(from, to, version) {
|
|
142
|
+
mkdirSync(to, { recursive: true });
|
|
143
|
+
for (const f of PLUGIN_FILES)
|
|
144
|
+
copyFileSync(join(from, f), join(to, f));
|
|
145
|
+
if (version)
|
|
146
|
+
writeFileSync(join(to, "version.json"), JSON.stringify({ version }) + `
|
|
147
|
+
`);
|
|
148
|
+
return join(to, "manifest.json");
|
|
149
|
+
}
|
|
150
|
+
function compareVersions(a, b) {
|
|
151
|
+
const pa = a.split(/[.-]/).map((n) => parseInt(n, 10) || 0);
|
|
152
|
+
const pb = b.split(/[.-]/).map((n) => parseInt(n, 10) || 0);
|
|
153
|
+
for (let i = 0;i < Math.max(pa.length, pb.length); i++) {
|
|
154
|
+
const d = (pa[i] ?? 0) - (pb[i] ?? 0);
|
|
155
|
+
if (d)
|
|
156
|
+
return d;
|
|
157
|
+
}
|
|
158
|
+
return 0;
|
|
159
|
+
}
|
|
160
|
+
function syncPlugin(from, to, version) {
|
|
161
|
+
if (!PLUGIN_FILES.every((f) => existsSync(join(from, f))))
|
|
162
|
+
return "skipped";
|
|
163
|
+
const had = existsSync(join(to, "manifest.json"));
|
|
164
|
+
let installed = "";
|
|
165
|
+
try {
|
|
166
|
+
installed = JSON.parse(readFileSync(join(to, "version.json"), "utf8")).version || "";
|
|
167
|
+
} catch {}
|
|
168
|
+
if (installed) {
|
|
169
|
+
const order = compareVersions(installed, version);
|
|
170
|
+
if (order > 0)
|
|
171
|
+
return "newer";
|
|
172
|
+
if (order === 0 && PLUGIN_FILES.every((f) => existsSync(join(to, f))))
|
|
173
|
+
return "current";
|
|
174
|
+
}
|
|
175
|
+
installPlugin(from, to, version);
|
|
176
|
+
return had ? "updated" : "installed";
|
|
177
|
+
}
|
|
178
|
+
function printSetup(r, rerun) {
|
|
179
|
+
for (const label of r.missing)
|
|
180
|
+
console.log(dim(` – ${label.padEnd(15)} not found`));
|
|
181
|
+
for (const u of r.updated)
|
|
182
|
+
console.log(green(` ✔ ${u.label.padEnd(15)}`) + dim(u.file));
|
|
183
|
+
for (const f of r.failed)
|
|
184
|
+
console.log(yellow(` ! ${f.label.padEnd(15)}could not update ${f.file}: ${f.error}`));
|
|
185
|
+
if (!r.updated.length)
|
|
186
|
+
console.log(yellow(`
|
|
187
|
+
No AI client found. Use \`${rerun} --client <name>\` or \`${rerun} --print\` for manual config.`));
|
|
188
|
+
}
|
|
189
|
+
function printSnippets(command, args) {
|
|
190
|
+
console.log(bold("JSON clients") + dim(" (Claude, Antigravity, Gemini CLI, Cursor, Windsurf…)"));
|
|
191
|
+
console.log(JSON.stringify({ mcpServers: { [SERVER_NAME]: { command, args } } }, null, 2));
|
|
192
|
+
console.log(bold(`
|
|
193
|
+
Codex`) + dim(" (~/.codex/config.toml)"));
|
|
194
|
+
console.log(tomlBlock(command, args) + `
|
|
195
|
+
`);
|
|
196
|
+
}
|
|
197
|
+
function parseClients(argv) {
|
|
198
|
+
const out = [];
|
|
199
|
+
argv.forEach((a, i) => {
|
|
200
|
+
if (a === "--client" && argv[i + 1])
|
|
201
|
+
out.push(...argv[i + 1].split(","));
|
|
202
|
+
else if (a.startsWith("--client="))
|
|
203
|
+
out.push(...a.slice(9).split(","));
|
|
204
|
+
});
|
|
205
|
+
return out;
|
|
206
|
+
}
|
|
207
|
+
var SERVER_NAME = "FigmaBridge", PLUGIN_FILES, color = (code) => (s) => process.stdout.isTTY ? `\x1B[${code}m${s}\x1B[0m` : s, bold, green, dim, yellow;
|
|
208
|
+
var init_setup = __esm(() => {
|
|
209
|
+
PLUGIN_FILES = ["manifest.json", "code.js", "ui.html"];
|
|
210
|
+
bold = color(1);
|
|
211
|
+
green = color(32);
|
|
212
|
+
dim = color(2);
|
|
213
|
+
yellow = color(33);
|
|
214
|
+
});
|
|
215
|
+
|
|
49
216
|
// package.json
|
|
50
|
-
var version = "1.
|
|
217
|
+
var version = "1.13.0";
|
|
51
218
|
var init_package = () => {};
|
|
52
219
|
|
|
53
220
|
// node_modules/zod/v3/helpers/util.js
|
|
@@ -24772,6 +24939,7 @@ var init_wrapper = __esm(() => {
|
|
|
24772
24939
|
});
|
|
24773
24940
|
|
|
24774
24941
|
// src/bridge.ts
|
|
24942
|
+
import { AsyncLocalStorage } from "node:async_hooks";
|
|
24775
24943
|
import { randomUUID } from "node:crypto";
|
|
24776
24944
|
import { createServer } from "node:http";
|
|
24777
24945
|
import { connect } from "node:net";
|
|
@@ -24998,6 +25166,10 @@ class Hub {
|
|
|
24998
25166
|
ws.data.session.fileName = msg.fileName;
|
|
24999
25167
|
if (typeof msg.page === "string")
|
|
25000
25168
|
ws.data.session.page = msg.page;
|
|
25169
|
+
if (typeof msg.editorType === "string")
|
|
25170
|
+
ws.data.session.editorType = msg.editorType;
|
|
25171
|
+
if (typeof msg.pageId === "string")
|
|
25172
|
+
ws.data.session.pageId = msg.pageId;
|
|
25001
25173
|
}
|
|
25002
25174
|
} else if (ws.data.role === "agent") {
|
|
25003
25175
|
if (msg.t === "req")
|
|
@@ -25025,6 +25197,9 @@ class Hub {
|
|
|
25025
25197
|
page: String(s.page || ""),
|
|
25026
25198
|
channel,
|
|
25027
25199
|
version: msg.version,
|
|
25200
|
+
editorType: typeof s.editorType === "string" ? s.editorType : undefined,
|
|
25201
|
+
fileKey: typeof s.fileKey === "string" && s.fileKey ? s.fileKey : undefined,
|
|
25202
|
+
pageId: typeof s.pageId === "string" ? s.pageId : undefined,
|
|
25028
25203
|
connectedAt: Date.now()
|
|
25029
25204
|
};
|
|
25030
25205
|
const previous = this.plugins.get(session.id);
|
|
@@ -25216,7 +25391,7 @@ class Bridge {
|
|
|
25216
25391
|
throw new BridgeError(this.lastError ?? `The bridge could not open or join port ${this.opts.port}.`, "OFFLINE");
|
|
25217
25392
|
}
|
|
25218
25393
|
try {
|
|
25219
|
-
return await this.send(method, params, timeoutMs);
|
|
25394
|
+
return await this.send(method, params, timeoutMs, await this.selectionFor(method));
|
|
25220
25395
|
} catch (e) {
|
|
25221
25396
|
const retryable = e instanceof BridgeError && (e.code === "NO_SESSION" || e.code === "OFFLINE");
|
|
25222
25397
|
if (!retryable || Date.now() > deadline)
|
|
@@ -25225,10 +25400,17 @@ class Bridge {
|
|
|
25225
25400
|
}
|
|
25226
25401
|
}
|
|
25227
25402
|
}
|
|
25228
|
-
|
|
25403
|
+
async selectionFor(method) {
|
|
25404
|
+
const file = method === "$sessions" ? undefined : callTarget.getStore()?.file;
|
|
25405
|
+
if (!file)
|
|
25406
|
+
return this.selection;
|
|
25407
|
+
const s = await this.find(file);
|
|
25408
|
+
return { id: s.id, fileName: s.fileName };
|
|
25409
|
+
}
|
|
25410
|
+
send(method, params, timeoutMs, sel) {
|
|
25229
25411
|
return new Promise((resolve, reject) => {
|
|
25230
25412
|
const id = randomUUID();
|
|
25231
|
-
const msg = { t: "req", id, method, params, sel
|
|
25413
|
+
const msg = { t: "req", id, method, params, sel, timeoutMs };
|
|
25232
25414
|
const timer = setTimeout(() => {
|
|
25233
25415
|
this.pending.delete(id);
|
|
25234
25416
|
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 +25445,18 @@ class Bridge {
|
|
|
25263
25445
|
return this.request("$sessions", {}, 5000, 1500);
|
|
25264
25446
|
}
|
|
25265
25447
|
async select(name) {
|
|
25448
|
+
const hit = await this.find(name);
|
|
25449
|
+
this.selection = { id: hit.id, fileName: hit.fileName };
|
|
25450
|
+
return hit;
|
|
25451
|
+
}
|
|
25452
|
+
async current() {
|
|
25453
|
+
const file = callTarget.getStore()?.file;
|
|
25454
|
+
if (file)
|
|
25455
|
+
return this.find(file);
|
|
25456
|
+
const list = await this.sessions().catch(() => []);
|
|
25457
|
+
return list.find((s) => s.id === this.selection.id) ?? (list.length === 1 ? list[0] : null);
|
|
25458
|
+
}
|
|
25459
|
+
async find(name) {
|
|
25266
25460
|
const list = await this.sessions();
|
|
25267
25461
|
const q = name.trim().toLowerCase();
|
|
25268
25462
|
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 +25464,15 @@ class Bridge {
|
|
|
25270
25464
|
const names = list.map((s) => `"${s.fileName}"`).join(", ") || "none";
|
|
25271
25465
|
throw new BridgeError(`No unique connected file matches "${name}". Connected: ${names}.`, "NOT_FOUND");
|
|
25272
25466
|
}
|
|
25273
|
-
this.selection = { id: hit.id, fileName: hit.fileName };
|
|
25274
25467
|
return hit;
|
|
25275
25468
|
}
|
|
25276
25469
|
}
|
|
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;
|
|
25470
|
+
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
25471
|
var init_bridge = __esm(() => {
|
|
25279
25472
|
init_wrapper();
|
|
25280
25473
|
CHUNK_CHARS = 1 << 20;
|
|
25281
25474
|
MAX_MESSAGE_CHARS = 256 << 20;
|
|
25475
|
+
callTarget = new AsyncLocalStorage;
|
|
25282
25476
|
BridgeError = class BridgeError extends Error {
|
|
25283
25477
|
code;
|
|
25284
25478
|
details;
|
|
@@ -26799,113 +26993,1751 @@ var init_codegen = __esm(() => {
|
|
|
26799
26993
|
RADII = { 2: "rounded-sm", 4: "rounded", 6: "rounded-md", 8: "rounded-lg", 12: "rounded-xl", 16: "rounded-2xl", 24: "rounded-3xl" };
|
|
26800
26994
|
});
|
|
26801
26995
|
|
|
26802
|
-
// src/
|
|
26803
|
-
|
|
26804
|
-
|
|
26805
|
-
|
|
26806
|
-
const
|
|
26807
|
-
const
|
|
26808
|
-
|
|
26809
|
-
|
|
26810
|
-
|
|
26811
|
-
|
|
26812
|
-
|
|
26813
|
-
|
|
26814
|
-
|
|
26996
|
+
// src/diagram.ts
|
|
26997
|
+
function parseDiagram(source) {
|
|
26998
|
+
const lines = source.replace(/%%.*$/gm, "").split(/\n|;/).map((l) => l.trim()).filter(Boolean);
|
|
26999
|
+
const d = { direction: "TB", nodes: [], edges: [], subgraphs: [] };
|
|
27000
|
+
const byId = new Map;
|
|
27001
|
+
const stack = [];
|
|
27002
|
+
const node = (ref) => {
|
|
27003
|
+
const m = /^([A-Za-z0-9_\-.$]+)\s*(.*)$/s.exec(ref.trim());
|
|
27004
|
+
if (!m)
|
|
27005
|
+
return null;
|
|
27006
|
+
const id = m[1];
|
|
27007
|
+
const rest = m[2].trim();
|
|
27008
|
+
let n = byId.get(id);
|
|
27009
|
+
if (!n) {
|
|
27010
|
+
n = { id, label: id, shape: "rounded" };
|
|
27011
|
+
byId.set(id, n);
|
|
27012
|
+
d.nodes.push(n);
|
|
26815
27013
|
}
|
|
26816
|
-
|
|
26817
|
-
|
|
26818
|
-
|
|
26819
|
-
|
|
26820
|
-
|
|
26821
|
-
|
|
26822
|
-
|
|
26823
|
-
|
|
26824
|
-
continue;
|
|
26825
|
-
}
|
|
26826
|
-
if (st.isDirectory())
|
|
26827
|
-
walk(full, depth + 1);
|
|
26828
|
-
else {
|
|
26829
|
-
const rel = relative(root, full).split(sep).join("/");
|
|
26830
|
-
if (filter(rel))
|
|
26831
|
-
out.push(rel);
|
|
27014
|
+
if (rest) {
|
|
27015
|
+
for (const [re, shape] of SHAPES) {
|
|
27016
|
+
const s = re.exec(rest);
|
|
27017
|
+
if (s) {
|
|
27018
|
+
n.label = unquote(s[1]);
|
|
27019
|
+
n.shape = shape;
|
|
27020
|
+
break;
|
|
27021
|
+
}
|
|
26832
27022
|
}
|
|
26833
|
-
if (out.length >= max)
|
|
26834
|
-
return;
|
|
26835
27023
|
}
|
|
27024
|
+
for (const g of stack)
|
|
27025
|
+
if (!g.nodes.includes(id))
|
|
27026
|
+
g.nodes.push(id);
|
|
27027
|
+
return id;
|
|
26836
27028
|
};
|
|
26837
|
-
|
|
26838
|
-
|
|
27029
|
+
for (const line of lines) {
|
|
27030
|
+
const header = /^(?:flowchart|graph)\s*(TB|TD|BT|LR|RL)?\b/i.exec(line);
|
|
27031
|
+
if (header) {
|
|
27032
|
+
const dir = (header[1] ?? "TB").toUpperCase();
|
|
27033
|
+
d.direction = dir === "TD" ? "TB" : dir;
|
|
27034
|
+
continue;
|
|
27035
|
+
}
|
|
27036
|
+
const sub = /^subgraph\s+([^\s[]+)(?:\s*\[(.*)\])?\s*(.*)$/i.exec(line);
|
|
27037
|
+
if (sub) {
|
|
27038
|
+
const g = { id: sub[1], label: unquote(sub[2] ?? (sub[3] || sub[1])), nodes: [] };
|
|
27039
|
+
d.subgraphs.push(g);
|
|
27040
|
+
stack.push(g);
|
|
27041
|
+
continue;
|
|
27042
|
+
}
|
|
27043
|
+
if (/^end$/i.test(line)) {
|
|
27044
|
+
stack.pop();
|
|
27045
|
+
continue;
|
|
27046
|
+
}
|
|
27047
|
+
if (/^(direction|classDef|class|style|linkStyle|click)\b/.test(line))
|
|
27048
|
+
continue;
|
|
27049
|
+
const parts = [];
|
|
27050
|
+
const links = [];
|
|
27051
|
+
let rest = line;
|
|
27052
|
+
for (;; ) {
|
|
27053
|
+
const m = EDGE.exec(rest);
|
|
27054
|
+
if (!m) {
|
|
27055
|
+
parts.push(rest);
|
|
27056
|
+
break;
|
|
27057
|
+
}
|
|
27058
|
+
parts.push(rest.slice(0, m.index));
|
|
27059
|
+
let op = m[1];
|
|
27060
|
+
let label = m[2];
|
|
27061
|
+
const inline = /^(--|==|-\.)\s+(.+?)\s+(-->|==>|\.->)$/.exec(op);
|
|
27062
|
+
if (inline) {
|
|
27063
|
+
label = inline[2];
|
|
27064
|
+
op = inline[1] === "--" ? "-->" : inline[1] === "==" ? "==>" : "-.->";
|
|
27065
|
+
}
|
|
27066
|
+
links.push({ op, label: label?.trim() || undefined });
|
|
27067
|
+
rest = rest.slice(m.index + m[0].length);
|
|
27068
|
+
}
|
|
27069
|
+
const groups = parts.map((p) => p.split(/\s+&\s+/).map(node).filter((x) => !!x));
|
|
27070
|
+
links.forEach((l, i) => {
|
|
27071
|
+
for (const from of groups[i] ?? []) {
|
|
27072
|
+
for (const to of groups[i + 1] ?? []) {
|
|
27073
|
+
d.edges.push({ from, to, label: l.label, dashed: l.op.includes("."), thick: l.op.includes("="), arrow: />$/.test(l.op) });
|
|
27074
|
+
}
|
|
27075
|
+
}
|
|
27076
|
+
});
|
|
27077
|
+
}
|
|
27078
|
+
return d;
|
|
26839
27079
|
}
|
|
26840
|
-
function
|
|
26841
|
-
|
|
26842
|
-
|
|
26843
|
-
|
|
26844
|
-
|
|
26845
|
-
|
|
27080
|
+
function layoutDiagram(d, opts = {}) {
|
|
27081
|
+
const size = opts.size ?? defaultSize;
|
|
27082
|
+
const ids = d.nodes.map((n) => n.id);
|
|
27083
|
+
const out = new Map(ids.map((i) => [i, []]));
|
|
27084
|
+
const inn = new Map(ids.map((i) => [i, []]));
|
|
27085
|
+
const state = new Map;
|
|
27086
|
+
const forward = [];
|
|
27087
|
+
const adj = new Map(ids.map((i) => [i, []]));
|
|
27088
|
+
for (const e of d.edges)
|
|
27089
|
+
if (e.from !== e.to)
|
|
27090
|
+
adj.get(e.from).push(e.to);
|
|
27091
|
+
const dfs = (v) => {
|
|
27092
|
+
state.set(v, 1);
|
|
27093
|
+
for (const w of adj.get(v)) {
|
|
27094
|
+
if (state.get(w) === 1)
|
|
27095
|
+
continue;
|
|
27096
|
+
forward.push([v, w]);
|
|
27097
|
+
if (!state.get(w))
|
|
27098
|
+
dfs(w);
|
|
27099
|
+
}
|
|
27100
|
+
state.set(v, 2);
|
|
27101
|
+
};
|
|
27102
|
+
for (const v of ids)
|
|
27103
|
+
if (!state.get(v))
|
|
27104
|
+
dfs(v);
|
|
27105
|
+
for (const [a, b] of forward) {
|
|
27106
|
+
out.get(a).push(b);
|
|
27107
|
+
inn.get(b).push(a);
|
|
27108
|
+
}
|
|
27109
|
+
const layer = new Map;
|
|
27110
|
+
const visit = (v) => {
|
|
27111
|
+
if (layer.has(v))
|
|
27112
|
+
return layer.get(v);
|
|
27113
|
+
layer.set(v, 0);
|
|
27114
|
+
const l = Math.max(0, ...inn.get(v).map((u) => visit(u) + 1));
|
|
27115
|
+
layer.set(v, l);
|
|
27116
|
+
return l;
|
|
27117
|
+
};
|
|
27118
|
+
for (const v of ids)
|
|
27119
|
+
visit(v);
|
|
27120
|
+
const layers = [];
|
|
27121
|
+
for (const v of ids)
|
|
27122
|
+
(layers[layer.get(v)] ??= []).push(v);
|
|
27123
|
+
const pos = new Map;
|
|
27124
|
+
const index = () => layers.forEach((l) => l.forEach((v, i) => pos.set(v, i)));
|
|
27125
|
+
index();
|
|
27126
|
+
for (let sweep = 0;sweep < 4; sweep++) {
|
|
27127
|
+
const down = sweep % 2 === 0;
|
|
27128
|
+
const order = down ? layers.slice(1) : layers.slice(0, -1).reverse();
|
|
27129
|
+
for (const l of order) {
|
|
27130
|
+
const bary = (v) => {
|
|
27131
|
+
const ns = down ? inn.get(v) : out.get(v);
|
|
27132
|
+
return ns.length ? ns.reduce((a, u) => a + pos.get(u), 0) / ns.length : pos.get(v);
|
|
27133
|
+
};
|
|
27134
|
+
l.sort((a, b) => bary(a) - bary(b));
|
|
27135
|
+
l.forEach((v, i) => pos.set(v, i));
|
|
27136
|
+
}
|
|
27137
|
+
}
|
|
27138
|
+
const horizontal = d.direction === "LR" || d.direction === "RL";
|
|
27139
|
+
const gap = opts.gap ?? 60;
|
|
27140
|
+
const layerGap = opts.layerGap ?? 100;
|
|
27141
|
+
const sizes = new Map(d.nodes.map((n) => [n.id, size(n)]));
|
|
27142
|
+
const along = (v) => horizontal ? sizes.get(v).h : sizes.get(v).w;
|
|
27143
|
+
const across = (v) => horizontal ? sizes.get(v).w : sizes.get(v).h;
|
|
27144
|
+
const spans = layers.map((l) => l.reduce((a, v) => a + along(v), 0) + gap * Math.max(0, l.length - 1));
|
|
27145
|
+
const widest = Math.max(0, ...spans);
|
|
27146
|
+
const positions = new Map;
|
|
27147
|
+
let depth = 0;
|
|
27148
|
+
layers.forEach((l, li) => {
|
|
27149
|
+
const thickness = Math.max(...l.map(across));
|
|
27150
|
+
let cursor = (widest - spans[li]) / 2;
|
|
27151
|
+
for (const v of l) {
|
|
27152
|
+
const s = sizes.get(v);
|
|
27153
|
+
const main = cursor;
|
|
27154
|
+
const cross = depth + (thickness - across(v)) / 2;
|
|
27155
|
+
positions.set(v, horizontal ? { x: cross, y: main, w: s.w, h: s.h } : { x: main, y: cross, w: s.w, h: s.h });
|
|
27156
|
+
cursor += along(v) + gap;
|
|
27157
|
+
}
|
|
27158
|
+
depth += thickness + layerGap;
|
|
27159
|
+
});
|
|
27160
|
+
const total = Math.max(0, depth - layerGap);
|
|
27161
|
+
if (d.direction === "BT" || d.direction === "RL") {
|
|
27162
|
+
for (const p of positions.values()) {
|
|
27163
|
+
if (horizontal)
|
|
27164
|
+
p.x = total - p.x - p.w;
|
|
27165
|
+
else
|
|
27166
|
+
p.y = total - p.y - p.h;
|
|
27167
|
+
}
|
|
26846
27168
|
}
|
|
27169
|
+
return { positions, width: horizontal ? total : widest, height: horizontal ? widest : total };
|
|
26847
27170
|
}
|
|
26848
|
-
function
|
|
26849
|
-
const
|
|
26850
|
-
|
|
26851
|
-
|
|
26852
|
-
const
|
|
26853
|
-
const
|
|
26854
|
-
|
|
26855
|
-
|
|
26856
|
-
|
|
26857
|
-
|
|
26858
|
-
if (
|
|
26859
|
-
|
|
26860
|
-
|
|
26861
|
-
|
|
26862
|
-
|
|
26863
|
-
|
|
26864
|
-
|
|
26865
|
-
|
|
26866
|
-
|
|
26867
|
-
|
|
26868
|
-
|
|
26869
|
-
|
|
26870
|
-
|
|
26871
|
-
|
|
26872
|
-
|
|
26873
|
-
|
|
26874
|
-
|
|
26875
|
-
|
|
26876
|
-
|
|
26877
|
-
|
|
26878
|
-
|
|
27171
|
+
function defaultSize(n) {
|
|
27172
|
+
const lines = n.label.split(`
|
|
27173
|
+
`);
|
|
27174
|
+
const longest = Math.max(...lines.map((l) => l.length));
|
|
27175
|
+
const w = Math.min(320, Math.max(n.shape === "diamond" ? 180 : 160, longest * 9 + 48));
|
|
27176
|
+
const h = Math.max(n.shape === "diamond" ? 120 : 80, lines.length * 22 + 40);
|
|
27177
|
+
return n.shape === "ellipse" ? { w, h: Math.max(h, 100) } : { w, h };
|
|
27178
|
+
}
|
|
27179
|
+
function diagramSpec(spec) {
|
|
27180
|
+
const d = parseDiagram(String(spec.source ?? spec.mermaid ?? ""));
|
|
27181
|
+
if (!d.nodes.length)
|
|
27182
|
+
throw new Error('diagram: no nodes found. Example: source:"flowchart LR\\n A[Start] --> B{OK?}\\n B -->|yes| C(Done)"');
|
|
27183
|
+
const stickies = spec.as === "stickies";
|
|
27184
|
+
const STICKY = { w: 240, h: 240 };
|
|
27185
|
+
const lay = layoutDiagram(d, stickies ? { size: () => STICKY, gap: 80, layerGap: 120 } : {});
|
|
27186
|
+
const pad = 80;
|
|
27187
|
+
const head = 60;
|
|
27188
|
+
const inGroup = new Map;
|
|
27189
|
+
for (const g of d.subgraphs)
|
|
27190
|
+
for (const id of g.nodes)
|
|
27191
|
+
if (!inGroup.has(id))
|
|
27192
|
+
inGroup.set(id, g);
|
|
27193
|
+
const nodeSpec = (n, dx, dy) => {
|
|
27194
|
+
const p = lay.positions.get(n.id);
|
|
27195
|
+
const base = { name: n.label.split(`
|
|
27196
|
+
`)[0], key: `${spec.key ?? "diagram"}:${n.id}`, x: Math.round(p.x + dx), y: Math.round(p.y + dy) };
|
|
27197
|
+
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 } : {} };
|
|
27198
|
+
};
|
|
27199
|
+
const children = [];
|
|
27200
|
+
for (const g of d.subgraphs) {
|
|
27201
|
+
const members = g.nodes.filter((id) => inGroup.get(id) === g).map((id) => d.nodes.find((n) => n.id === id));
|
|
27202
|
+
if (!members.length)
|
|
27203
|
+
continue;
|
|
27204
|
+
const ps = members.map((n) => lay.positions.get(n.id));
|
|
27205
|
+
const x0 = Math.min(...ps.map((p) => p.x)) - 40;
|
|
27206
|
+
const y0 = Math.min(...ps.map((p) => p.y)) - head;
|
|
27207
|
+
const x1 = Math.max(...ps.map((p) => p.x + p.w)) + 40;
|
|
27208
|
+
const y1 = Math.max(...ps.map((p) => p.y + p.h)) + 40;
|
|
27209
|
+
children.push({
|
|
27210
|
+
type: "section",
|
|
27211
|
+
name: g.label,
|
|
27212
|
+
x: Math.round(x0 + pad),
|
|
27213
|
+
y: Math.round(y0 + pad + head),
|
|
27214
|
+
w: Math.round(x1 - x0),
|
|
27215
|
+
h: Math.round(y1 - y0),
|
|
27216
|
+
children: members.map((n) => nodeSpec(n, -x0, -y0))
|
|
27217
|
+
});
|
|
26879
27218
|
}
|
|
26880
|
-
|
|
26881
|
-
|
|
26882
|
-
|
|
26883
|
-
|
|
26884
|
-
|
|
26885
|
-
|
|
26886
|
-
|
|
26887
|
-
|
|
26888
|
-
|
|
26889
|
-
|
|
26890
|
-
|
|
26891
|
-
|
|
26892
|
-
|
|
26893
|
-
|
|
26894
|
-
|
|
26895
|
-
|
|
26896
|
-
|
|
26897
|
-
|
|
26898
|
-
|
|
26899
|
-
|
|
26900
|
-
|
|
26901
|
-
|
|
26902
|
-
|
|
26903
|
-
|
|
26904
|
-
|
|
27219
|
+
for (const n of d.nodes)
|
|
27220
|
+
if (!inGroup.has(n.id))
|
|
27221
|
+
children.push(nodeSpec(n, pad, pad + head));
|
|
27222
|
+
for (const e of d.edges) {
|
|
27223
|
+
children.push({
|
|
27224
|
+
type: "connector",
|
|
27225
|
+
from: `${spec.key ?? "diagram"}:${e.from}`,
|
|
27226
|
+
to: `${spec.key ?? "diagram"}:${e.to}`,
|
|
27227
|
+
...e.label ? { label: e.label } : {},
|
|
27228
|
+
...e.dashed ? { dashed: true } : {},
|
|
27229
|
+
...e.thick ? { strokeWidth: 4 } : {},
|
|
27230
|
+
...e.arrow ? {} : { endArrow: "none" },
|
|
27231
|
+
line: spec.line ?? "elbowed"
|
|
27232
|
+
});
|
|
27233
|
+
}
|
|
27234
|
+
return {
|
|
27235
|
+
type: "section",
|
|
27236
|
+
name: spec.name ?? "Diagram",
|
|
27237
|
+
...spec.x !== undefined ? { x: spec.x } : {},
|
|
27238
|
+
...spec.y !== undefined ? { y: spec.y } : {},
|
|
27239
|
+
w: Math.round(lay.width + pad * 2),
|
|
27240
|
+
h: Math.round(lay.height + pad * 2 + head),
|
|
27241
|
+
children
|
|
27242
|
+
};
|
|
27243
|
+
}
|
|
27244
|
+
var SHAPES, unquote = (s) => s.trim().replace(/^"(.*)"$/s, "$1").replace(/<br\s*\/?>/gi, `
|
|
27245
|
+
`), EDGE;
|
|
27246
|
+
var init_diagram = __esm(() => {
|
|
27247
|
+
SHAPES = [
|
|
27248
|
+
[/^\(\((.*)\)\)$/s, "ellipse"],
|
|
27249
|
+
[/^\(\[(.*)\]\)$/s, "rounded"],
|
|
27250
|
+
[/^\[\[(.*)\]\]$/s, "process"],
|
|
27251
|
+
[/^\[\((.*)\)\]$/s, "database"],
|
|
27252
|
+
[/^\{\{(.*)\}\}$/s, "hexagon"],
|
|
27253
|
+
[/^\[\/(.*)\/\]$/s, "parallelogram"],
|
|
27254
|
+
[/^\[\\(.*)\\\]$/s, "parallelogramleft"],
|
|
27255
|
+
[/^\[\/(.*)\\\]$/s, "trapezoid"],
|
|
27256
|
+
[/^>(.*)\]$/s, "chevron"],
|
|
27257
|
+
[/^\((.*)\)$/s, "rounded"],
|
|
27258
|
+
[/^\{(.*)\}$/s, "diamond"],
|
|
27259
|
+
[/^\[(.*)\]$/s, "square"]
|
|
27260
|
+
];
|
|
27261
|
+
EDGE = /\s*(<?-{2,}>|<?-\.+->|<?={2,}>|-{3,}|={3,}|-\.+-|--\s+[^-]+?\s+-->|==\s+[^=]+?\s+==>|-\.\s+[^.]+?\s+\.->)\s*(?:\|([^|]*)\|)?\s*/;
|
|
27262
|
+
});
|
|
27263
|
+
|
|
27264
|
+
// src/guides.ts
|
|
27265
|
+
function registerGuides(server, docs) {
|
|
27266
|
+
for (const p of PROMPTS) {
|
|
27267
|
+
const argsSchema = {};
|
|
27268
|
+
for (const [k, d] of Object.entries(p.args))
|
|
27269
|
+
argsSchema[k] = string2().optional().describe(d);
|
|
27270
|
+
server.registerPrompt(p.name, { title: p.title, description: p.description, argsSchema }, (args) => text(p.render(args ?? {})));
|
|
27271
|
+
}
|
|
27272
|
+
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) }] }));
|
|
27273
|
+
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()) }] }));
|
|
27274
|
+
}
|
|
27275
|
+
function buildSpecMarkdown(spec) {
|
|
27276
|
+
const lines = spec.split(`
|
|
27277
|
+
`);
|
|
27278
|
+
const out = ["# build spec reference", "", lines[0], ""];
|
|
27279
|
+
for (const line of lines.slice(1)) {
|
|
27280
|
+
const m = /^([A-Z]{2,}(?: [A-Z]{2,})*)((?: \([^)]*\))?(?: files)?):\s*(.*)$/.exec(line);
|
|
27281
|
+
if (m && !/^Example/.test(line)) {
|
|
27282
|
+
const title = m[1] + m[2];
|
|
27283
|
+
out.push(`## ${title[0]}${title.slice(1).toLowerCase()}`, "", m[3], "");
|
|
27284
|
+
} else if (/^Example:/.test(line))
|
|
27285
|
+
out.push("## Example", "", "```json", line.replace(/^Example:\s*/, ""), "```", "");
|
|
27286
|
+
else
|
|
27287
|
+
out.push(line, "");
|
|
27288
|
+
}
|
|
27289
|
+
return out.join(`
|
|
27290
|
+
`).replace(/\n{3,}/g, `
|
|
27291
|
+
|
|
27292
|
+
`);
|
|
27293
|
+
}
|
|
27294
|
+
function workflowMarkdown(instructions, tools) {
|
|
27295
|
+
const first = (d) => (/^(.*?[.!?])(\s|$)/s.exec(d.replace(/\s+/g, " "))?.[1] ?? d).slice(0, 220);
|
|
27296
|
+
return [
|
|
27297
|
+
"# Working with Figma Bridge",
|
|
27298
|
+
"",
|
|
27299
|
+
...instructions.map((l) => /^\d\./.test(l) ? l : `- ${l}`),
|
|
27300
|
+
"",
|
|
27301
|
+
"## Tools",
|
|
27302
|
+
"",
|
|
27303
|
+
...tools.map((t) => `- **${t.name}**${t.title ? ` (${t.title})` : ""}: ${first(t.description)}`),
|
|
27304
|
+
"",
|
|
27305
|
+
"## Prompts",
|
|
27306
|
+
"",
|
|
27307
|
+
...PROMPTS.map((p) => `- **${p.name}**: ${p.description}`),
|
|
27308
|
+
"",
|
|
27309
|
+
`Full build spec: ${RESOURCES.build}`,
|
|
27310
|
+
""
|
|
27311
|
+
].join(`
|
|
27312
|
+
`);
|
|
27313
|
+
}
|
|
27314
|
+
var RESOURCES, text = (t) => ({ messages: [{ role: "user", content: { type: "text", text: t.trim() } }] }), opt = (v, fallback) => v && v.trim() ? v.trim() : fallback, PROMPTS;
|
|
27315
|
+
var init_guides = __esm(() => {
|
|
27316
|
+
init_zod();
|
|
27317
|
+
RESOURCES = {
|
|
27318
|
+
build: "figma-bridge://docs/build-spec",
|
|
27319
|
+
workflow: "figma-bridge://docs/workflow"
|
|
27320
|
+
};
|
|
27321
|
+
PROMPTS = [
|
|
27322
|
+
{
|
|
27323
|
+
name: "new-screen",
|
|
27324
|
+
title: "Design a new screen",
|
|
27325
|
+
description: "Design a screen from a description, reusing the file's design system.",
|
|
27326
|
+
args: { description: "What the screen is for and what it contains", platform: "desktop (1440), tablet (834) or mobile (390); default desktop" },
|
|
27327
|
+
render: (a) => `
|
|
27328
|
+
Design a new ${opt(a.platform, "desktop")} screen in the connected Figma file: ${opt(a.description, "(ask me what the screen is for)")}.
|
|
27329
|
+
1. get_context, then get_design_system. Reuse its color and text styles, variables and components (style:, var:, {component}) instead of raw values.
|
|
27330
|
+
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}.
|
|
27331
|
+
3. screenshot {returnImage:true} the result and look at it critically: spacing rhythm, alignment, hierarchy, contrast.
|
|
27332
|
+
4. audit the frame and fix every error and warning.
|
|
27333
|
+
5. Tell me what you built and what you would improve next.`
|
|
27334
|
+
},
|
|
27335
|
+
{
|
|
27336
|
+
name: "apply-design-system",
|
|
27337
|
+
title: "Apply the design system",
|
|
27338
|
+
description: "Make a screen use the file's variables, styles and components.",
|
|
27339
|
+
args: { target: "Layer name or id (default: the selection)" },
|
|
27340
|
+
render: (a) => `
|
|
27341
|
+
Apply the file's design system to ${opt(a.target, "the selected layers (use wait_for_selection if nothing is selected)")}.
|
|
27342
|
+
1. get_design_system to see the variables, styles and components available, and describe the target.
|
|
27343
|
+
2. checkpoint {action:"save"} on the target first.
|
|
27344
|
+
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.
|
|
27345
|
+
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.
|
|
27346
|
+
5. audit {scope:"design-system", nodeId} before and after, and report the score change and anything you could not map.`
|
|
27347
|
+
},
|
|
27348
|
+
{
|
|
27349
|
+
name: "reproduce-screenshot",
|
|
27350
|
+
title: "Reproduce a screenshot",
|
|
27351
|
+
description: "Rebuild a mockup or screenshot as an editable, auto-layout frame and check it pixel by pixel.",
|
|
27352
|
+
args: { image: "Local path or URL of the image", width: "Width of the frame in px (default: the image's width)" },
|
|
27353
|
+
render: (a) => `
|
|
27354
|
+
Reproduce ${opt(a.image, "the image I give you")} as an editable Figma frame${a.width ? ` ${a.width} px wide` : ""}.
|
|
27355
|
+
1. Look at the image carefully. get_design_system and reuse what matches.
|
|
27356
|
+
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"}.
|
|
27357
|
+
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.
|
|
27358
|
+
4. Stop when mismatchPercent no longer drops; report the final number and what still differs.`
|
|
27359
|
+
},
|
|
27360
|
+
{
|
|
27361
|
+
name: "import-website",
|
|
27362
|
+
title: "Import a website",
|
|
27363
|
+
description: "Turn a web page into editable Figma frames at several widths, then clean them up.",
|
|
27364
|
+
args: { url: "The page URL, or a local .html path", viewports: "Comma-separated widths, default 1440,390" },
|
|
27365
|
+
render: (a) => {
|
|
27366
|
+
const viewports = opt(a.viewports, "1440,390").split(",").map((v) => Number(v.trim())).filter((n) => n > 0);
|
|
27367
|
+
return `
|
|
27368
|
+
Import ${opt(a.url, "(ask me for the URL)")} into the connected Figma file.
|
|
27369
|
+
1. import_web {${/^https?:/.test(a.url ?? "") ? "url" : "path"}:"${opt(a.url, "<url>")}", viewports:${JSON.stringify(viewports)}}.
|
|
27370
|
+
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.
|
|
27371
|
+
3. Report fontSubstitutions: suggest installing the missing fonts or switching to the file's fonts.
|
|
27372
|
+
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.`;
|
|
27373
|
+
}
|
|
27374
|
+
},
|
|
27375
|
+
{
|
|
27376
|
+
name: "figma-to-code",
|
|
27377
|
+
title: "Figma to code",
|
|
27378
|
+
description: "Export a frame as code that uses the project's own components and tokens.",
|
|
27379
|
+
args: { projectPath: "Root folder of the app (where package.json is)", target: "Layer name or id (default: the selection)" },
|
|
27380
|
+
render: (a) => `
|
|
27381
|
+
Turn ${opt(a.target, "the selected frame")} into code for the project at ${opt(a.projectPath, "(ask me for the project folder)")}.
|
|
27382
|
+
1. export_code {projectPath, nodeId} without writing. Read the plan: the detected stack, the components used and the unmatched instances, the token sources.
|
|
27383
|
+
2. If important instances are unmatched, add figma-bridge.map.json entries for them (Figma component → code component and props) and export again.
|
|
27384
|
+
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.
|
|
27385
|
+
4. When it looks right, export_code with write:true, then run the project's type-check or linter and fix what they report.`
|
|
27386
|
+
},
|
|
27387
|
+
{
|
|
27388
|
+
name: "figma-to-roblox",
|
|
27389
|
+
title: "Figma to Roblox",
|
|
27390
|
+
description: "Turn a frame into Roblox UI and place it in Studio with a Roblox Studio MCP.",
|
|
27391
|
+
args: { target: "Layer name or id (default: the selection)", mode: "scale, offset or hybrid (default hybrid)" },
|
|
27392
|
+
render: (a) => `
|
|
27393
|
+
Turn ${opt(a.target, "the selected frame")} into Roblox UI that looks the same, and put it in the open Roblox Studio place.
|
|
27394
|
+
1. export_roblox {${a.target ? `nodeId:"<id of ${a.target}>", ` : ""}mode:"${opt(a.mode, "hybrid")}"}. Read the warnings (approximations, font substitutions).
|
|
27395
|
+
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.
|
|
27396
|
+
3. Run the Luau with execute_luau: it creates the ScreenGui under StarterGui (replacing an earlier copy) and returns it.
|
|
27397
|
+
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).
|
|
27398
|
+
5. Report the result and anything Roblox could not reproduce.`
|
|
27399
|
+
},
|
|
27400
|
+
{
|
|
27401
|
+
name: "audit-and-fix",
|
|
27402
|
+
title: "Audit and fix",
|
|
27403
|
+
description: "Score the design system's health, apply safe fixes, and report what changed.",
|
|
27404
|
+
args: { target: "Layer name or id to limit the fixes (default: the current page)" },
|
|
27405
|
+
render: (a) => `
|
|
27406
|
+
Audit and improve this file${a.target ? ` (fixes limited to ${a.target})` : ""}.
|
|
27407
|
+
1. audit {scope:"design-system"} and summarize the score per category with the most important issues.
|
|
27408
|
+
2. checkpoint {action:"save"} on what you are going to change.
|
|
27409
|
+
3. audit {fix:true${a.target ? `, nodeId:"<id of ${a.target}>"` : ""}} and read the changes.
|
|
27410
|
+
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.
|
|
27411
|
+
5. audit {scope:"design-system"} again and report the score before and after.`
|
|
27412
|
+
}
|
|
27413
|
+
];
|
|
27414
|
+
});
|
|
27415
|
+
|
|
27416
|
+
// src/roblox/fonts.ts
|
|
27417
|
+
function robloxFamily(family, overrides = {}) {
|
|
27418
|
+
const key = family.trim().toLowerCase();
|
|
27419
|
+
const custom = Object.entries(overrides).find(([k]) => k.trim().toLowerCase() === key)?.[1];
|
|
27420
|
+
const name = custom ?? ROBLOX_FONTS[key] ?? DEFAULT_FONT;
|
|
27421
|
+
const url = /^rbxasset(id)?:\/\//.test(name) ? name : `rbxasset://fonts/families/${name.replace(/\s+/g, "")}.json`;
|
|
27422
|
+
const exact = custom !== undefined || name.toLowerCase() === key.replace(/\s+/g, "") || key === "gotham" && name === "GothamSSm";
|
|
27423
|
+
return { url, substituted: !exact };
|
|
27424
|
+
}
|
|
27425
|
+
function robloxWeight(weight) {
|
|
27426
|
+
let best = WEIGHTS2[3];
|
|
27427
|
+
for (const w of WEIGHTS2)
|
|
27428
|
+
if (Math.abs(w[0] - weight) < Math.abs(best[0] - weight))
|
|
27429
|
+
best = w;
|
|
27430
|
+
return { value: best[0], name: best[1] };
|
|
27431
|
+
}
|
|
27432
|
+
var ROBLOX_FONTS, DEFAULT_FONT = "BuilderSans", WEIGHTS2;
|
|
27433
|
+
var init_fonts = __esm(() => {
|
|
27434
|
+
ROBLOX_FONTS = {
|
|
27435
|
+
"builder sans": "BuilderSans",
|
|
27436
|
+
montserrat: "Montserrat",
|
|
27437
|
+
roboto: "Roboto",
|
|
27438
|
+
"roboto condensed": "RobotoCondensed",
|
|
27439
|
+
"roboto mono": "RobotoMono",
|
|
27440
|
+
"source sans pro": "SourceSansPro",
|
|
27441
|
+
"source sans 3": "SourceSansPro",
|
|
27442
|
+
nunito: "Nunito",
|
|
27443
|
+
oswald: "Oswald",
|
|
27444
|
+
merriweather: "Merriweather",
|
|
27445
|
+
ubuntu: "Ubuntu",
|
|
27446
|
+
"titillium web": "TitilliumWeb",
|
|
27447
|
+
"press start 2p": "PressStart2P",
|
|
27448
|
+
"fredoka one": "FredokaOne",
|
|
27449
|
+
fredoka: "FredokaOne",
|
|
27450
|
+
bangers: "Bangers",
|
|
27451
|
+
creepster: "Creepster",
|
|
27452
|
+
"luckiest guy": "LuckiestGuy",
|
|
27453
|
+
"permanent marker": "PermanentMarker",
|
|
27454
|
+
"patrick hand": "PatrickHand",
|
|
27455
|
+
"indie flower": "IndieFlower",
|
|
27456
|
+
"amatic sc": "AmaticSC",
|
|
27457
|
+
kalam: "Kalam",
|
|
27458
|
+
michroma: "Michroma",
|
|
27459
|
+
jura: "Jura",
|
|
27460
|
+
sarpanch: "Sarpanch",
|
|
27461
|
+
"special elite": "SpecialElite",
|
|
27462
|
+
"denk one": "DenkOne",
|
|
27463
|
+
inconsolata: "Inconsolata",
|
|
27464
|
+
arimo: "Arimo",
|
|
27465
|
+
gotham: "GothamSSm",
|
|
27466
|
+
"gotham ssm": "GothamSSm",
|
|
27467
|
+
inter: "BuilderSans",
|
|
27468
|
+
"sf pro": "BuilderSans",
|
|
27469
|
+
"sf pro text": "BuilderSans",
|
|
27470
|
+
"sf pro display": "BuilderSans",
|
|
27471
|
+
"open sans": "BuilderSans",
|
|
27472
|
+
lato: "BuilderSans",
|
|
27473
|
+
poppins: "Montserrat",
|
|
27474
|
+
"dm sans": "BuilderSans",
|
|
27475
|
+
manrope: "BuilderSans",
|
|
27476
|
+
"plus jakarta sans": "BuilderSans",
|
|
27477
|
+
"work sans": "BuilderSans",
|
|
27478
|
+
"ibm plex sans": "SourceSansPro",
|
|
27479
|
+
arial: "Arimo",
|
|
27480
|
+
helvetica: "Arimo",
|
|
27481
|
+
"helvetica neue": "Arimo",
|
|
27482
|
+
"segoe ui": "BuilderSans",
|
|
27483
|
+
"jetbrains mono": "RobotoMono",
|
|
27484
|
+
"fira code": "RobotoMono",
|
|
27485
|
+
"source code pro": "RobotoMono",
|
|
27486
|
+
"sf mono": "RobotoMono",
|
|
27487
|
+
"courier new": "RobotoMono",
|
|
27488
|
+
georgia: "Merriweather",
|
|
27489
|
+
"times new roman": "Merriweather",
|
|
27490
|
+
"playfair display": "Merriweather",
|
|
27491
|
+
"bebas neue": "Oswald",
|
|
27492
|
+
anton: "Oswald",
|
|
27493
|
+
"comic sans ms": "PatrickHand"
|
|
27494
|
+
};
|
|
27495
|
+
WEIGHTS2 = [
|
|
27496
|
+
[100, "Thin"],
|
|
27497
|
+
[200, "ExtraLight"],
|
|
27498
|
+
[300, "Light"],
|
|
27499
|
+
[400, "Regular"],
|
|
27500
|
+
[500, "Medium"],
|
|
27501
|
+
[600, "SemiBold"],
|
|
27502
|
+
[700, "Bold"],
|
|
27503
|
+
[800, "ExtraBold"],
|
|
27504
|
+
[900, "Heavy"]
|
|
27505
|
+
];
|
|
27506
|
+
});
|
|
27507
|
+
|
|
27508
|
+
// src/roblox/map.ts
|
|
27509
|
+
function color3(hex) {
|
|
27510
|
+
const h = hex.replace("#", "");
|
|
27511
|
+
const c = (i) => num(parseInt(h.slice(i, i + 2), 16) / 255);
|
|
27512
|
+
return { r: c(0), g: c(2), b: c(4) };
|
|
27513
|
+
}
|
|
27514
|
+
function visible(list) {
|
|
27515
|
+
return (list ?? []).filter((p) => p.opacity > 0);
|
|
27516
|
+
}
|
|
27517
|
+
function decide(n, opts) {
|
|
27518
|
+
const approximations = [];
|
|
27519
|
+
if (n.text)
|
|
27520
|
+
return { kind: "text", shadow: false, approximations };
|
|
27521
|
+
const raster = opts.rasterize;
|
|
27522
|
+
const fills = visible(n.fills);
|
|
27523
|
+
const strokes = visible(n.strokes);
|
|
27524
|
+
const effects = n.effects ?? [];
|
|
27525
|
+
const drop = effects.filter((e) => e.type === "DROP_SHADOW");
|
|
27526
|
+
const other = effects.filter((e) => e.type !== "DROP_SHADOW");
|
|
27527
|
+
const hasChildren = !!n.children?.some((c) => !c.hidden);
|
|
27528
|
+
if (n.vector || ["VECTOR", "BOOLEAN_OPERATION", "STAR", "POLYGON", "LINE"].includes(n.type)) {
|
|
27529
|
+
return raster === "none" ? { kind: "frame", shadow: false, approximations: ["vector shape skipped (rasterize is none)"] } : { kind: "picture", shadow: false, approximations };
|
|
27530
|
+
}
|
|
27531
|
+
const image = fills.length === 1 && fills[0].type === "IMAGE";
|
|
27532
|
+
if (image && !hasChildren && !strokes.length && !effects.length && raster !== "all")
|
|
27533
|
+
return { kind: "image", shadow: false, approximations };
|
|
27534
|
+
const reasons = [];
|
|
27535
|
+
if (fills.length > 1)
|
|
27536
|
+
reasons.push("several fills");
|
|
27537
|
+
if (fills.length === 1 && !["SOLID", "GRADIENT_LINEAR", "IMAGE"].includes(fills[0].type))
|
|
27538
|
+
reasons.push(`${fills[0].type.toLowerCase().replace("_", " ")} fill`);
|
|
27539
|
+
if (image && (strokes.length || effects.length))
|
|
27540
|
+
reasons.push("image with stroke or effects");
|
|
27541
|
+
if (strokes.length > 1 || strokes[0] && strokes[0].type !== "SOLID")
|
|
27542
|
+
reasons.push("complex stroke");
|
|
27543
|
+
if (n.dashed)
|
|
27544
|
+
reasons.push("dashed stroke");
|
|
27545
|
+
if (other.length)
|
|
27546
|
+
reasons.push(other.map((e) => e.type.toLowerCase().replace(/_/g, " ")).join(", "));
|
|
27547
|
+
if (n.type === "ELLIPSE" && Math.abs(n.w - n.h) > 0.5)
|
|
27548
|
+
reasons.push("oval");
|
|
27549
|
+
const styled = fills.length + strokes.length + effects.length > 0;
|
|
27550
|
+
if ((reasons.length || raster === "all" && styled) && raster !== "none") {
|
|
27551
|
+
return { kind: hasChildren ? "panel" : "picture", shadow: false, approximations };
|
|
27552
|
+
}
|
|
27553
|
+
if (reasons.length)
|
|
27554
|
+
approximations.push(`${reasons.join(", ")}: approximated (rasterize is none)`);
|
|
27555
|
+
const r = n.radius ?? [];
|
|
27556
|
+
if (r.length && r.some((v) => Math.abs(v - r[0]) > 0.5))
|
|
27557
|
+
approximations.push("different corner radii: used the largest");
|
|
27558
|
+
if (drop.length && raster === "none")
|
|
27559
|
+
approximations.push("drop shadow skipped (rasterize is none)");
|
|
27560
|
+
return { kind: "frame", shadow: drop.length > 0 && raster !== "none", approximations };
|
|
27561
|
+
}
|
|
27562
|
+
function pictureRequests(root, opts) {
|
|
27563
|
+
const out = [];
|
|
27564
|
+
const walk = (n) => {
|
|
27565
|
+
if (n.hidden)
|
|
27566
|
+
return;
|
|
27567
|
+
const d = decide(n, opts);
|
|
27568
|
+
if (d.kind === "picture")
|
|
27569
|
+
out.push({ id: n.id, mode: "full" });
|
|
27570
|
+
if (d.kind === "panel")
|
|
27571
|
+
out.push({ id: n.id, mode: "panel" });
|
|
27572
|
+
if (d.shadow)
|
|
27573
|
+
out.push({ id: n.id, mode: "shadow" });
|
|
27574
|
+
if (d.kind !== "picture")
|
|
27575
|
+
for (const c of n.children ?? [])
|
|
27576
|
+
walk(c);
|
|
27577
|
+
};
|
|
27578
|
+
walk(root);
|
|
27579
|
+
return out;
|
|
27580
|
+
}
|
|
27581
|
+
function imageHashes(root) {
|
|
27582
|
+
const out = new Set;
|
|
27583
|
+
const walk = (n) => {
|
|
27584
|
+
for (const f of n.fills ?? [])
|
|
27585
|
+
if (f.imageHash)
|
|
27586
|
+
out.add(f.imageHash);
|
|
27587
|
+
for (const c of n.children ?? [])
|
|
27588
|
+
walk(c);
|
|
27589
|
+
};
|
|
27590
|
+
walk(root);
|
|
27591
|
+
return [...out];
|
|
27592
|
+
}
|
|
27593
|
+
|
|
27594
|
+
class Mapper {
|
|
27595
|
+
opts;
|
|
27596
|
+
assets;
|
|
27597
|
+
warnings = new Set;
|
|
27598
|
+
fontSubstitutions = new Map;
|
|
27599
|
+
counts = { instances: 0, pictures: 0, skippedHidden: 0 };
|
|
27600
|
+
textFactor = 1;
|
|
27601
|
+
constructor(opts, assets) {
|
|
27602
|
+
this.opts = opts;
|
|
27603
|
+
this.assets = assets;
|
|
27604
|
+
}
|
|
27605
|
+
warn(n, msg) {
|
|
27606
|
+
if (this.warnings.size < 60)
|
|
27607
|
+
this.warnings.add(n ? `${n.name}: ${msg}` : msg);
|
|
27608
|
+
}
|
|
27609
|
+
map(root) {
|
|
27610
|
+
const [tw, th] = this.opts.targetResolution;
|
|
27611
|
+
const fullScreen = Math.abs(root.w / root.h - tw / th) < 0.1 * (tw / th) && root.w >= tw * 0.4;
|
|
27612
|
+
if (fullScreen && this.opts.mode !== "offset")
|
|
27613
|
+
this.textFactor = tw / root.w;
|
|
27614
|
+
const top = this.node(root, null, 0);
|
|
27615
|
+
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)]];
|
|
27616
|
+
top.props = top.props.filter(([k]) => !["AnchorPoint", "Position", "Size", "LayoutOrder", "ZIndex"].includes(k));
|
|
27617
|
+
top.props.splice(1, 0, ...geo);
|
|
27618
|
+
top.children.push(this.inst("UIAspectRatioConstraint", "AspectRatio", [["AspectRatio", F(root.w / root.h)]]));
|
|
27619
|
+
if (this.opts.asRootFrame)
|
|
27620
|
+
return top;
|
|
27621
|
+
return this.inst("ScreenGui", root.name, [["ResetOnSpawn", B(false)], ["ZIndexBehavior", E("ZIndexBehavior", "Sibling")], ["IgnoreGuiInset", B(true)]], [top]);
|
|
27622
|
+
}
|
|
27623
|
+
inst(className, name, props = [], children = [], source) {
|
|
27624
|
+
this.counts.instances++;
|
|
27625
|
+
return { className, name, props: [["Name", S(name)], ...props], children, ...source ? { source } : {} };
|
|
27626
|
+
}
|
|
27627
|
+
geometry(n, parent, index, d) {
|
|
27628
|
+
const out = [];
|
|
27629
|
+
if (!parent)
|
|
27630
|
+
return out;
|
|
27631
|
+
const inFlow = !!parent.layout && !n.absolute;
|
|
27632
|
+
const useScale = this.opts.mode === "scale" || this.opts.mode === "hybrid" && !inFlow;
|
|
27633
|
+
const hugW = n.sizing?.h === "HUG" && (d.kind === "text" || !!n.layout);
|
|
27634
|
+
const hugH = n.sizing?.v === "HUG" && (d.kind === "text" || !!n.layout);
|
|
27635
|
+
if (inFlow) {
|
|
27636
|
+
const l = parent.layout;
|
|
27637
|
+
const horizontal = l.mode === "HORIZONTAL";
|
|
27638
|
+
const pw = parent.w - (l.padding[1] ?? 0) - (l.padding[3] ?? 0);
|
|
27639
|
+
const ph = parent.h - (l.padding[0] ?? 0) - (l.padding[2] ?? 0);
|
|
27640
|
+
const crossFill = horizontal ? n.sizing?.v === "FILL" || n.stretch : n.sizing?.h === "FILL" || n.stretch;
|
|
27641
|
+
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);
|
|
27642
|
+
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);
|
|
27643
|
+
out.push(["Size", { t: "UDim2", xs: w.s, xo: w.o, ys: h.s, yo: h.o }]);
|
|
27644
|
+
out.push(["LayoutOrder", I(index + 1)]);
|
|
27645
|
+
return out;
|
|
27646
|
+
}
|
|
27647
|
+
const c = n.constraints ?? { h: "MIN", v: "MIN" };
|
|
27648
|
+
const axis = (pos, size, total, k) => {
|
|
27649
|
+
const end = total - pos - size;
|
|
27650
|
+
if (k === "SCALE" || useScale) {
|
|
27651
|
+
if (k === "MAX")
|
|
27652
|
+
return { anchor: 1, p: [(pos + size) / total, 0], s: [size / total, 0] };
|
|
27653
|
+
if (k === "CENTER")
|
|
27654
|
+
return { anchor: 0.5, p: [(pos + size / 2) / total, 0], s: [size / total, 0] };
|
|
27655
|
+
return { anchor: 0, p: [pos / total, 0], s: [size / total, 0] };
|
|
27656
|
+
}
|
|
27657
|
+
if (k === "MAX")
|
|
27658
|
+
return { anchor: 1, p: [1, -end], s: [0, size] };
|
|
27659
|
+
if (k === "CENTER")
|
|
27660
|
+
return { anchor: 0.5, p: [0.5, pos + size / 2 - total / 2], s: [0, size] };
|
|
27661
|
+
if (k === "STRETCH")
|
|
27662
|
+
return { anchor: 0, p: [0, pos], s: [1, -(pos + end)] };
|
|
27663
|
+
return { anchor: 0, p: [0, pos], s: [0, size] };
|
|
27664
|
+
};
|
|
27665
|
+
const x = axis(n.x, n.w, Math.max(1, parent.w), c.h);
|
|
27666
|
+
const y = axis(n.y, n.h, Math.max(1, parent.h), c.v);
|
|
27667
|
+
if (x.anchor || y.anchor)
|
|
27668
|
+
out.push(["AnchorPoint", V2(x.anchor, y.anchor)]);
|
|
27669
|
+
out.push(["Position", U2(x.p[0], x.p[1], y.p[0], y.p[1])]);
|
|
27670
|
+
out.push(["Size", U2(hugW ? 0 : x.s[0], hugW ? 0 : x.s[1], hugH ? 0 : y.s[0], hugH ? 0 : y.s[1])]);
|
|
27671
|
+
out.push(["ZIndex", I(index + 1)]);
|
|
27672
|
+
return out;
|
|
27673
|
+
}
|
|
27674
|
+
automaticSize(n, d) {
|
|
27675
|
+
const hugW = n.sizing?.h === "HUG" && (d.kind === "text" || !!n.layout);
|
|
27676
|
+
const hugH = n.sizing?.v === "HUG" && (d.kind === "text" || !!n.layout);
|
|
27677
|
+
if (!hugW && !hugH)
|
|
27678
|
+
return [];
|
|
27679
|
+
return [["AutomaticSize", E("AutomaticSize", hugW && hugH ? "XY" : hugW ? "X" : "Y")]];
|
|
27680
|
+
}
|
|
27681
|
+
corner(n) {
|
|
27682
|
+
const r = Math.max(0, ...n.radius ?? []);
|
|
27683
|
+
if (!r && !(n.type === "ELLIPSE"))
|
|
27684
|
+
return null;
|
|
27685
|
+
const pill = n.type === "ELLIPSE" || r >= Math.min(n.w, n.h) / 2 - 0.5;
|
|
27686
|
+
return this.inst("UICorner", "UICorner", [["CornerRadius", pill ? U(0.5, 0) : U(0, r)]]);
|
|
27687
|
+
}
|
|
27688
|
+
stroke(n) {
|
|
27689
|
+
const s = visible(n.strokes)[0];
|
|
27690
|
+
if (!s || s.type !== "SOLID" || !n.strokeWeight)
|
|
27691
|
+
return null;
|
|
27692
|
+
return this.inst("UIStroke", "UIStroke", [
|
|
27693
|
+
["ApplyStrokeMode", E("ApplyStrokeMode", "Border")],
|
|
27694
|
+
["Color", C3(s.color)],
|
|
27695
|
+
["Thickness", F(n.strokeWeight)],
|
|
27696
|
+
["Transparency", F(1 - s.opacity)],
|
|
27697
|
+
["LineJoinMode", E("LineJoinMode", "Round")]
|
|
27698
|
+
]);
|
|
27699
|
+
}
|
|
27700
|
+
gradient(p) {
|
|
27701
|
+
let stops = [...p.stops ?? []].sort((a, b) => a.at - b.at);
|
|
27702
|
+
if (stops.length > 20) {
|
|
27703
|
+
this.warn(null, "a gradient has more than 20 stops: kept 20");
|
|
27704
|
+
stops = stops.filter((_, i) => i % Math.ceil(stops.length / 20) === 0);
|
|
27705
|
+
}
|
|
27706
|
+
if (stops[0].at > 0)
|
|
27707
|
+
stops.unshift({ ...stops[0], at: 0 });
|
|
27708
|
+
if (stops[stops.length - 1].at < 1)
|
|
27709
|
+
stops.push({ ...stops[stops.length - 1], at: 1 });
|
|
27710
|
+
stops[0] = { ...stops[0], at: 0 };
|
|
27711
|
+
stops[stops.length - 1] = { ...stops[stops.length - 1], at: 1 };
|
|
27712
|
+
return this.inst("UIGradient", "UIGradient", [
|
|
27713
|
+
["Color", { t: "ColorSequence", keys: stops.map((s) => ({ time: num(s.at), ...color3(s.color) })) }],
|
|
27714
|
+
["Transparency", { t: "NumberSequence", keys: stops.map((s) => ({ time: num(s.at), value: num(1 - s.alpha * p.opacity) })) }],
|
|
27715
|
+
["Rotation", F(p.angle ?? 0)]
|
|
27716
|
+
]);
|
|
27717
|
+
}
|
|
27718
|
+
layoutChildren(n) {
|
|
27719
|
+
const l = n.layout;
|
|
27720
|
+
const out = [];
|
|
27721
|
+
const [t, r, b, lft] = l.padding;
|
|
27722
|
+
if (t || r || b || lft)
|
|
27723
|
+
out.push(this.inst("UIPadding", "UIPadding", [["PaddingTop", U(0, t)], ["PaddingRight", U(0, r)], ["PaddingBottom", U(0, b)], ["PaddingLeft", U(0, lft)]]));
|
|
27724
|
+
if (l.mode === "GRID") {
|
|
27725
|
+
const kids = (n.children ?? []).filter((c) => !c.hidden && !c.absolute);
|
|
27726
|
+
const first = kids[0];
|
|
27727
|
+
if (kids.some((k) => Math.abs(k.w - (first?.w ?? 0)) > 1 || Math.abs(k.h - (first?.h ?? 0)) > 1))
|
|
27728
|
+
this.warn(n, "grid cells of different sizes: UIGridLayout uses the first cell's size");
|
|
27729
|
+
out.push(this.inst("UIGridLayout", "UIGridLayout", [
|
|
27730
|
+
["CellSize", U2(0, first?.w ?? 100, 0, first?.h ?? 100)],
|
|
27731
|
+
["CellPadding", U2(0, l.columnGap ?? 0, 0, l.rowGap ?? 0)],
|
|
27732
|
+
["FillDirectionMaxCells", I(l.columns ?? 0)],
|
|
27733
|
+
["SortOrder", E("SortOrder", "LayoutOrder")]
|
|
27734
|
+
]));
|
|
27735
|
+
return out;
|
|
27736
|
+
}
|
|
27737
|
+
const horizontal = l.mode === "HORIZONTAL";
|
|
27738
|
+
const main = l.primary === "CENTER" ? "Center" : l.primary === "MAX" ? horizontal ? "Right" : "Bottom" : horizontal ? "Left" : "Top";
|
|
27739
|
+
const cross = l.counter === "CENTER" ? "Center" : l.counter === "MAX" ? horizontal ? "Bottom" : "Right" : horizontal ? "Top" : "Left";
|
|
27740
|
+
const props = [
|
|
27741
|
+
["FillDirection", E("FillDirection", horizontal ? "Horizontal" : "Vertical")],
|
|
27742
|
+
["SortOrder", E("SortOrder", "LayoutOrder")],
|
|
27743
|
+
["Padding", U(0, l.primary === "SPACE_BETWEEN" ? 0 : l.gap)],
|
|
27744
|
+
["HorizontalAlignment", E("HorizontalAlignment", horizontal ? main === "Right" ? "Right" : main === "Center" ? "Center" : "Left" : cross === "Right" ? "Right" : cross === "Center" ? "Center" : "Left")],
|
|
27745
|
+
["VerticalAlignment", E("VerticalAlignment", horizontal ? cross === "Bottom" ? "Bottom" : cross === "Center" ? "Center" : "Top" : main === "Bottom" ? "Bottom" : main === "Center" ? "Center" : "Top")]
|
|
27746
|
+
];
|
|
27747
|
+
if (l.primary === "SPACE_BETWEEN")
|
|
27748
|
+
props.push([horizontal ? "HorizontalFlex" : "VerticalFlex", E("UIFlexAlignment", "SpaceBetween")]);
|
|
27749
|
+
if (l.wrap)
|
|
27750
|
+
props.push(["Wraps", B(true)]);
|
|
27751
|
+
if (l.counter === "BASELINE")
|
|
27752
|
+
this.warn(n, "baseline alignment: aligned to the top");
|
|
27753
|
+
out.push(this.inst("UIListLayout", "UIListLayout", props));
|
|
27754
|
+
return out;
|
|
27755
|
+
}
|
|
27756
|
+
assetUrl(key, n) {
|
|
27757
|
+
const a = this.assets(key);
|
|
27758
|
+
if (!a) {
|
|
27759
|
+
this.warn(n, "picture missing: left empty");
|
|
27760
|
+
return { url: "" };
|
|
27761
|
+
}
|
|
27762
|
+
return a;
|
|
27763
|
+
}
|
|
27764
|
+
textProps(n) {
|
|
27765
|
+
const t = n.text;
|
|
27766
|
+
const segs = t.segments.length ? t.segments : [{ text: t.characters, family: "Inter", style: "Regular", weight: 400, size: 14 }];
|
|
27767
|
+
const base = segs.reduce((a, s) => s.text.length > a.text.length ? s : a, segs[0]);
|
|
27768
|
+
const family = robloxFamily(base.family, this.opts.fonts);
|
|
27769
|
+
if (family.substituted)
|
|
27770
|
+
this.fontSubstitutions.set(base.family, family.url.replace(/^.*\/(\w+)\.json$/, "$1"));
|
|
27771
|
+
const weight = robloxWeight(base.weight);
|
|
27772
|
+
const italic = /italic|oblique/i.test(base.style);
|
|
27773
|
+
const size = Math.max(1, Math.round(base.size * this.textFactor));
|
|
27774
|
+
const props = [
|
|
27775
|
+
["BackgroundTransparency", F(1)],
|
|
27776
|
+
["FontFace", { t: "Font", family: family.url, weight: weight.value, weightName: weight.name, style: italic ? "Italic" : "Normal" }],
|
|
27777
|
+
["TextSize", I(size)],
|
|
27778
|
+
["TextColor3", C3(base.color ?? "#000000")]
|
|
27779
|
+
];
|
|
27780
|
+
const alpha = (base.alpha ?? 1) * (n.opacity ?? 1);
|
|
27781
|
+
if (alpha < 1)
|
|
27782
|
+
props.push(["TextTransparency", F(1 - alpha)]);
|
|
27783
|
+
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);
|
|
27784
|
+
const text = segs.map((s) => s.text).join("");
|
|
27785
|
+
if (rich) {
|
|
27786
|
+
props.push(["RichText", B(true)], ["Text", S(segs.map((s) => this.richRun(s, base)).join(""))]);
|
|
27787
|
+
} else {
|
|
27788
|
+
let value = text;
|
|
27789
|
+
if (base.textCase === "UPPER")
|
|
27790
|
+
value = value.toUpperCase();
|
|
27791
|
+
else if (base.textCase === "LOWER")
|
|
27792
|
+
value = value.toLowerCase();
|
|
27793
|
+
if (base.decoration)
|
|
27794
|
+
props.push(["RichText", B(true)], ["Text", S(this.richRun({ ...base, text: value, textCase: undefined }, { ...base, decoration: undefined }))]);
|
|
27795
|
+
else
|
|
27796
|
+
props.push(["Text", S(value)]);
|
|
27797
|
+
}
|
|
27798
|
+
props.push(["TextXAlignment", E("TextXAlignment", t.align === "CENTER" ? "Center" : t.align === "RIGHT" ? "Right" : "Left")]);
|
|
27799
|
+
props.push(["TextYAlignment", E("TextYAlignment", t.valign === "CENTER" ? "Center" : t.valign === "BOTTOM" ? "Bottom" : "Top")]);
|
|
27800
|
+
if (t.autoResize !== "WIDTH_AND_HEIGHT")
|
|
27801
|
+
props.push(["TextWrapped", B(true)]);
|
|
27802
|
+
if (base.lineHeight)
|
|
27803
|
+
props.push(["LineHeight", F(Math.max(1, Math.min(3, base.lineHeight / (base.size * 1.2))))]);
|
|
27804
|
+
if (t.maxLines)
|
|
27805
|
+
props.push(["TextTruncate", E("TextTruncate", "AtEnd")]);
|
|
27806
|
+
if (this.opts.textScaled)
|
|
27807
|
+
props.push(["TextScaled", B(true)]);
|
|
27808
|
+
if (segs.some((s) => s.letterSpacing))
|
|
27809
|
+
this.warn(n, "letter spacing has no Roblox equivalent: ignored");
|
|
27810
|
+
return props;
|
|
27811
|
+
}
|
|
27812
|
+
richRun(s, base) {
|
|
27813
|
+
let text = s.text.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """).replace(/'/g, "'");
|
|
27814
|
+
const attrs = [];
|
|
27815
|
+
if (s.color && s.color !== base.color)
|
|
27816
|
+
attrs.push(`color="${s.color}"`);
|
|
27817
|
+
if (s.size !== base.size)
|
|
27818
|
+
attrs.push(`size="${Math.round(s.size * this.textFactor)}"`);
|
|
27819
|
+
if (s.weight !== base.weight)
|
|
27820
|
+
attrs.push(`weight="${robloxWeight(s.weight).value}"`);
|
|
27821
|
+
if (s.family !== base.family)
|
|
27822
|
+
attrs.push(`family="${robloxFamily(s.family, this.opts.fonts).url}"`);
|
|
27823
|
+
if (s.alpha !== undefined && s.alpha < 1)
|
|
27824
|
+
attrs.push(`transparency="${num(1 - s.alpha)}"`);
|
|
27825
|
+
if (attrs.length)
|
|
27826
|
+
text = `<font ${attrs.join(" ")}>${text}</font>`;
|
|
27827
|
+
if (/italic/i.test(s.style) && !/italic/i.test(base.style))
|
|
27828
|
+
text = `<i>${text}</i>`;
|
|
27829
|
+
if (s.decoration === "UNDERLINE")
|
|
27830
|
+
text = `<u>${text}</u>`;
|
|
27831
|
+
if (s.decoration === "STRIKETHROUGH")
|
|
27832
|
+
text = `<s>${text}</s>`;
|
|
27833
|
+
if (s.textCase === "UPPER")
|
|
27834
|
+
text = `<uc>${text}</uc>`;
|
|
27835
|
+
if (s.textCase === "SMALL_CAPS" || s.textCase === "SMALL_CAPS_FORCED")
|
|
27836
|
+
text = `<sc>${text}</sc>`;
|
|
27837
|
+
return text;
|
|
27838
|
+
}
|
|
27839
|
+
node(n, parent, index) {
|
|
27840
|
+
if (n.hidden) {
|
|
27841
|
+
this.counts.skippedHidden++;
|
|
27842
|
+
return null;
|
|
27843
|
+
}
|
|
27844
|
+
const d = decide(n, this.opts);
|
|
27845
|
+
for (const a of d.approximations)
|
|
27846
|
+
this.warn(n, a);
|
|
27847
|
+
const button = !!parent && (n.clickable || BUTTON_NAME.test(n.name)) && d.kind !== "text";
|
|
27848
|
+
const geo = this.geometry(n, parent, index, d);
|
|
27849
|
+
const common = [...geo, ...this.automaticSize(n, d)];
|
|
27850
|
+
if (n.rotation)
|
|
27851
|
+
common.push(["Rotation", F(n.rotation)]);
|
|
27852
|
+
const children = [];
|
|
27853
|
+
let className = "Frame";
|
|
27854
|
+
let props = [];
|
|
27855
|
+
if (d.kind === "text") {
|
|
27856
|
+
className = n.clickable || BUTTON_NAME.test(n.name) ? "TextButton" : "TextLabel";
|
|
27857
|
+
props = this.textProps(n);
|
|
27858
|
+
if (className === "TextButton")
|
|
27859
|
+
props.push(["AutoButtonColor", B(false)]);
|
|
27860
|
+
const size = props.find(([k]) => k === "TextSize")[1].v;
|
|
27861
|
+
children.push(this.inst("UITextSizeConstraint", "UITextSizeConstraint", [["MaxTextSize", I(size)], ["MinTextSize", I(Math.max(1, Math.round(size * 0.5)))]]));
|
|
27862
|
+
return this.inst(className, n.name, [...common, ...props], children, n.id);
|
|
27863
|
+
}
|
|
27864
|
+
if (d.kind === "picture") {
|
|
27865
|
+
this.counts.pictures++;
|
|
27866
|
+
const a = this.assetUrl(`${n.id}:full`, n);
|
|
27867
|
+
className = button ? "ImageButton" : "ImageLabel";
|
|
27868
|
+
const pic = this.pictureGeometry(n, a, geo);
|
|
27869
|
+
props = [["BackgroundTransparency", F(1)], ["Image", { t: "Content", url: a.url }], ["ScaleType", E("ScaleType", "Stretch")]];
|
|
27870
|
+
if (n.opacity !== undefined && n.opacity < 1)
|
|
27871
|
+
props.push(["ImageTransparency", F(1 - n.opacity)]);
|
|
27872
|
+
if (button)
|
|
27873
|
+
props.push(["AutoButtonColor", B(false)]);
|
|
27874
|
+
children.push(this.inst("UIAspectRatioConstraint", "UIAspectRatioConstraint", [["AspectRatio", F((a.size?.w ?? n.w) / Math.max(1, a.size?.h ?? n.h))]]));
|
|
27875
|
+
return this.inst(className, n.name, [...pic, ...props], children, n.id);
|
|
27876
|
+
}
|
|
27877
|
+
const fills = visible(n.fills);
|
|
27878
|
+
const opacity = n.opacity ?? 1;
|
|
27879
|
+
const hasKids = !!n.children?.some((c) => !c.hidden);
|
|
27880
|
+
if (d.kind === "image") {
|
|
27881
|
+
const f = fills[0];
|
|
27882
|
+
const a = this.assetUrl(`image:${f.imageHash}`, n);
|
|
27883
|
+
className = button ? "ImageButton" : "ImageLabel";
|
|
27884
|
+
const scaleType = f.scaleMode === "FIT" ? "Fit" : f.scaleMode === "TILE" ? "Tile" : "Crop";
|
|
27885
|
+
props = [["BackgroundTransparency", F(1)], ["Image", { t: "Content", url: a.url }], ["ScaleType", E("ScaleType", scaleType)]];
|
|
27886
|
+
if (f.opacity * opacity < 1)
|
|
27887
|
+
props.push(["ImageTransparency", F(1 - f.opacity * opacity)]);
|
|
27888
|
+
if (button)
|
|
27889
|
+
props.push(["AutoButtonColor", B(false)]);
|
|
27890
|
+
const corner = this.corner(n);
|
|
27891
|
+
if (corner)
|
|
27892
|
+
children.push(corner);
|
|
27893
|
+
children.push(this.inst("UIAspectRatioConstraint", "UIAspectRatioConstraint", [["AspectRatio", F(n.w / Math.max(1, n.h))]]));
|
|
27894
|
+
return this.inst(className, n.name, [...common, ...props], children, n.id);
|
|
27895
|
+
}
|
|
27896
|
+
if (d.kind === "panel") {
|
|
27897
|
+
this.counts.pictures++;
|
|
27898
|
+
const a = this.assetUrl(`${n.id}:panel`, n);
|
|
27899
|
+
const scale = this.opts.scale ?? 2;
|
|
27900
|
+
const inset = Math.ceil((Math.max(0, ...n.radius ?? [0]) + (n.strokeWeight ?? 0) + 2) * scale);
|
|
27901
|
+
const pw = Math.round((a.size?.w ?? n.w) * scale);
|
|
27902
|
+
const ph = Math.round((a.size?.h ?? n.h) * scale);
|
|
27903
|
+
const ix = Math.min(inset, Math.floor(pw / 2) - 1);
|
|
27904
|
+
const iy = Math.min(inset, Math.floor(ph / 2) - 1);
|
|
27905
|
+
className = button ? "ImageButton" : "ImageLabel";
|
|
27906
|
+
props = [
|
|
27907
|
+
["BackgroundTransparency", F(1)],
|
|
27908
|
+
["Image", { t: "Content", url: a.url }],
|
|
27909
|
+
["ScaleType", E("ScaleType", "Slice")],
|
|
27910
|
+
["SliceCenter", { t: "Rect", x0: ix, y0: iy, x1: pw - ix, y1: ph - iy }],
|
|
27911
|
+
["SliceScale", F(1 / scale)]
|
|
27912
|
+
];
|
|
27913
|
+
if (opacity < 1)
|
|
27914
|
+
props.push(["ImageTransparency", F(1 - opacity)]);
|
|
27915
|
+
if (button)
|
|
27916
|
+
props.push(["AutoButtonColor", B(false)]);
|
|
27917
|
+
} else {
|
|
27918
|
+
className = button ? "TextButton" : hasKids && opacity < 1 ? "CanvasGroup" : "Frame";
|
|
27919
|
+
const f = fills[0];
|
|
27920
|
+
if (!f)
|
|
27921
|
+
props.push(["BackgroundTransparency", F(1)]);
|
|
27922
|
+
else if (f.type === "SOLID") {
|
|
27923
|
+
props.push(["BackgroundColor3", C3(f.color)]);
|
|
27924
|
+
const t = 1 - f.opacity * (className === "CanvasGroup" ? 1 : opacity);
|
|
27925
|
+
if (t > 0)
|
|
27926
|
+
props.push(["BackgroundTransparency", F(t)]);
|
|
27927
|
+
} else if (f.type === "GRADIENT_LINEAR") {
|
|
27928
|
+
props.push(["BackgroundColor3", C3("#FFFFFF")]);
|
|
27929
|
+
children.push(this.gradient(f));
|
|
27930
|
+
} else if (f.type === "IMAGE") {
|
|
27931
|
+
props.push(["BackgroundTransparency", F(1)]);
|
|
27932
|
+
const a = this.assetUrl(`image:${f.imageHash}`, n);
|
|
27933
|
+
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")]]);
|
|
27934
|
+
const corner = this.corner(n);
|
|
27935
|
+
if (corner)
|
|
27936
|
+
bg.children.push(corner);
|
|
27937
|
+
children.push(bg);
|
|
27938
|
+
}
|
|
27939
|
+
if (className === "CanvasGroup")
|
|
27940
|
+
props.push(["GroupTransparency", F(1 - opacity)]);
|
|
27941
|
+
if (button)
|
|
27942
|
+
props.push(["Text", S("")], ["AutoButtonColor", B(false)]);
|
|
27943
|
+
const corner = this.corner(n);
|
|
27944
|
+
if (corner)
|
|
27945
|
+
children.push(corner);
|
|
27946
|
+
const stroke = this.stroke(n);
|
|
27947
|
+
if (stroke)
|
|
27948
|
+
children.push(stroke);
|
|
27949
|
+
}
|
|
27950
|
+
props.push(["BorderSizePixel", I(0)]);
|
|
27951
|
+
if (n.clip)
|
|
27952
|
+
props.push(["ClipsDescendants", B(true)]);
|
|
27953
|
+
const kids = n.children ?? [];
|
|
27954
|
+
const flow = n.layout ? kids.filter((k) => !k.absolute) : kids;
|
|
27955
|
+
const loose = n.layout ? kids.filter((k) => k.absolute) : [];
|
|
27956
|
+
const me = { w: n.w, h: n.h, layout: n.layout };
|
|
27957
|
+
const mapped = (list, p) => list.map((k, i) => this.node(k, p, i)).filter((x) => !!x);
|
|
27958
|
+
const flowMapped = mapped(flow, me);
|
|
27959
|
+
if (n.layout && n.layout.mode !== "GRID") {
|
|
27960
|
+
for (const k of flow) {
|
|
27961
|
+
const target = k.grow && !k.hidden ? flowMapped.find((c) => c.source === k.id) : undefined;
|
|
27962
|
+
if (target)
|
|
27963
|
+
target.children.push(this.inst("UIFlexItem", "UIFlexItem", [["FlexMode", E("UIFlexMode", "Fill")]]));
|
|
27964
|
+
}
|
|
27965
|
+
}
|
|
27966
|
+
if (n.layout && loose.length) {
|
|
27967
|
+
const contentZ = kids.findIndex((k) => !k.absolute) + 1;
|
|
27968
|
+
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]);
|
|
27969
|
+
const overlay = loose.map((k) => this.node(k, { w: n.w, h: n.h }, kids.indexOf(k))).filter((x) => !!x);
|
|
27970
|
+
children.push(content, ...overlay);
|
|
27971
|
+
} else {
|
|
27972
|
+
if (n.layout)
|
|
27973
|
+
children.push(...this.layoutChildren(n));
|
|
27974
|
+
children.push(...flowMapped);
|
|
27975
|
+
}
|
|
27976
|
+
const self = this.inst(className, n.name, [...common, ...props], children, n.id);
|
|
27977
|
+
if (!d.shadow)
|
|
27978
|
+
return self;
|
|
27979
|
+
this.counts.pictures++;
|
|
27980
|
+
const a = this.assetUrl(`${n.id}:shadow`, n);
|
|
27981
|
+
const scale = this.opts.scale ?? 2;
|
|
27982
|
+
const sh = (n.effects ?? []).filter((e) => e.type === "DROP_SHADOW");
|
|
27983
|
+
const reach = Math.max(...sh.map((e) => e.blur + (e.spread ?? 0) + Math.max(Math.abs(e.x ?? 0), Math.abs(e.y ?? 0))));
|
|
27984
|
+
const off = a.offset ?? { x: 0, y: 0 };
|
|
27985
|
+
const size = a.size ?? { w: n.w, h: n.h };
|
|
27986
|
+
const inset = Math.ceil((Math.max(0, ...n.radius ?? [0]) + reach) * scale);
|
|
27987
|
+
const pw = Math.round(size.w * scale);
|
|
27988
|
+
const ph = Math.round(size.h * scale);
|
|
27989
|
+
const ix = Math.min(inset, Math.floor(pw / 2) - 1);
|
|
27990
|
+
const iy = Math.min(inset, Math.floor(ph / 2) - 1);
|
|
27991
|
+
const shadow = this.inst("ImageLabel", "Shadow", [
|
|
27992
|
+
["Position", U2(0, off.x, 0, off.y)],
|
|
27993
|
+
["Size", U2(1, size.w - n.w, 1, size.h - n.h)],
|
|
27994
|
+
["ZIndex", I(1)],
|
|
27995
|
+
["BackgroundTransparency", F(1)],
|
|
27996
|
+
["Image", { t: "Content", url: a.url }],
|
|
27997
|
+
["ScaleType", E("ScaleType", "Slice")],
|
|
27998
|
+
["SliceCenter", { t: "Rect", x0: ix, y0: iy, x1: pw - ix, y1: ph - iy }],
|
|
27999
|
+
["SliceScale", F(1 / scale)]
|
|
28000
|
+
]);
|
|
28001
|
+
self.props = self.props.filter(([k]) => !["AnchorPoint", "Position", "Size", "LayoutOrder", "AutomaticSize"].includes(k));
|
|
28002
|
+
self.props.splice(1, 0, ["Size", U2(1, 0, 1, 0)], ["ZIndex", I(2)]);
|
|
28003
|
+
return this.inst("Frame", n.name, [...common.filter(([k]) => k !== "AutomaticSize"), ["BackgroundTransparency", F(1)], ["BorderSizePixel", I(0)]], [shadow, self], n.id);
|
|
28004
|
+
}
|
|
28005
|
+
pictureGeometry(n, a, geo) {
|
|
28006
|
+
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)
|
|
28007
|
+
return geo;
|
|
28008
|
+
return geo.map(([k, v]) => {
|
|
28009
|
+
if (k === "Size" && v.t === "UDim2")
|
|
28010
|
+
return [k, { ...v, xo: Math.round(v.xo + a.size.w - n.w), yo: Math.round(v.yo + a.size.h - n.h) }];
|
|
28011
|
+
if (k === "Position" && v.t === "UDim2")
|
|
28012
|
+
return [k, { ...v, xo: Math.round(v.xo + a.offset.x), yo: Math.round(v.yo + a.offset.y) }];
|
|
28013
|
+
return [k, v];
|
|
28014
|
+
});
|
|
28015
|
+
}
|
|
28016
|
+
}
|
|
28017
|
+
function mapToRoblox(root, opts, assets) {
|
|
28018
|
+
const m = new Mapper(opts, assets);
|
|
28019
|
+
const out = m.map(root);
|
|
28020
|
+
if (m.counts.skippedHidden)
|
|
28021
|
+
m.warn(null, `${m.counts.skippedHidden} hidden layer(s) skipped`);
|
|
28022
|
+
return { root: out, warnings: [...m.warnings], fontSubstitutions: Object.fromEntries(m.fontSubstitutions), counts: m.counts };
|
|
28023
|
+
}
|
|
28024
|
+
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;
|
|
28025
|
+
var init_map2 = __esm(() => {
|
|
28026
|
+
init_fonts();
|
|
28027
|
+
BUTTON_NAME = /\b(button|btn|cta)\b/i;
|
|
28028
|
+
});
|
|
28029
|
+
|
|
28030
|
+
// src/roblox/output.ts
|
|
28031
|
+
function xmlProp(name, v) {
|
|
28032
|
+
switch (v.t) {
|
|
28033
|
+
case "string":
|
|
28034
|
+
return `<string name="${name}">${xml(v.v)}</string>`;
|
|
28035
|
+
case "bool":
|
|
28036
|
+
return `<bool name="${name}">${v.v}</bool>`;
|
|
28037
|
+
case "int":
|
|
28038
|
+
return `<int name="${name}">${v.v}</int>`;
|
|
28039
|
+
case "float":
|
|
28040
|
+
return `<float name="${name}">${n(v.v)}</float>`;
|
|
28041
|
+
case "Color3":
|
|
28042
|
+
return `<Color3 name="${name}"><R>${n(v.r)}</R><G>${n(v.g)}</G><B>${n(v.b)}</B></Color3>`;
|
|
28043
|
+
case "UDim":
|
|
28044
|
+
return `<UDim name="${name}"><S>${n(v.s)}</S><O>${v.o}</O></UDim>`;
|
|
28045
|
+
case "UDim2":
|
|
28046
|
+
return `<UDim2 name="${name}"><XS>${n(v.xs)}</XS><XO>${v.xo}</XO><YS>${n(v.ys)}</YS><YO>${v.yo}</YO></UDim2>`;
|
|
28047
|
+
case "Vector2":
|
|
28048
|
+
return `<Vector2 name="${name}"><X>${n(v.x)}</X><Y>${n(v.y)}</Y></Vector2>`;
|
|
28049
|
+
case "Rect":
|
|
28050
|
+
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>`;
|
|
28051
|
+
case "enum": {
|
|
28052
|
+
const value = ENUMS[v.e]?.[v.v];
|
|
28053
|
+
if (value === undefined)
|
|
28054
|
+
throw new Error(`No enum value for ${v.e}.${v.v}`);
|
|
28055
|
+
return `<token name="${name}">${value}</token>`;
|
|
28056
|
+
}
|
|
28057
|
+
case "Content":
|
|
28058
|
+
return v.url ? `<Content name="${name}"><url>${xml(v.url)}</url></Content>` : `<Content name="${name}"><null></null></Content>`;
|
|
28059
|
+
case "ColorSequence":
|
|
28060
|
+
return `<ColorSequence name="${name}">${v.keys.map((k) => `${n(k.time)} ${n(k.r)} ${n(k.g)} ${n(k.b)} 0 `).join("")}</ColorSequence>`;
|
|
28061
|
+
case "NumberSequence":
|
|
28062
|
+
return `<NumberSequence name="${name}">${v.keys.map((k) => `${n(k.time)} ${n(k.value)} 0 `).join("")}</NumberSequence>`;
|
|
28063
|
+
case "Font":
|
|
28064
|
+
return `<Font name="${name}"><Family><url>${xml(v.family)}</url></Family><Weight>${v.weight}</Weight><Style>${v.style}</Style></Font>`;
|
|
28065
|
+
}
|
|
28066
|
+
}
|
|
28067
|
+
function toRbxmx(root) {
|
|
28068
|
+
let ref = 0;
|
|
28069
|
+
const item = (i, depth) => {
|
|
28070
|
+
const pad = "\t".repeat(depth);
|
|
28071
|
+
const props = i.props.map(([k, v]) => `${pad} ${xmlProp(k, v)}`).join(`
|
|
28072
|
+
`);
|
|
28073
|
+
const kids = i.children.map((c) => item(c, depth + 1)).join(`
|
|
28074
|
+
`);
|
|
28075
|
+
return `${pad}<Item class="${i.className}" referent="RBX${(ref++).toString(16).toUpperCase().padStart(8, "0")}">
|
|
28076
|
+
${pad} <Properties>
|
|
28077
|
+
${props}
|
|
28078
|
+
${pad} </Properties>${kids ? `
|
|
28079
|
+
${kids}` : ""}
|
|
28080
|
+
${pad}</Item>`;
|
|
28081
|
+
};
|
|
28082
|
+
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">
|
|
28083
|
+
` + ` <External>null</External>
|
|
28084
|
+
<External>nil</External>
|
|
28085
|
+
` + item(root, 1) + `
|
|
28086
|
+
</roblox>
|
|
28087
|
+
`;
|
|
28088
|
+
}
|
|
28089
|
+
function luauString(s) {
|
|
28090
|
+
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)}`)}"`;
|
|
28091
|
+
}
|
|
28092
|
+
function luauValue(v) {
|
|
28093
|
+
switch (v.t) {
|
|
28094
|
+
case "string":
|
|
28095
|
+
return luauString(v.v);
|
|
28096
|
+
case "bool":
|
|
28097
|
+
return String(v.v);
|
|
28098
|
+
case "int":
|
|
28099
|
+
return String(v.v);
|
|
28100
|
+
case "float":
|
|
28101
|
+
return n(v.v);
|
|
28102
|
+
case "Color3":
|
|
28103
|
+
return `Color3.fromRGB(${rgb(v.r)}, ${rgb(v.g)}, ${rgb(v.b)})`;
|
|
28104
|
+
case "UDim":
|
|
28105
|
+
return `UDim.new(${n(v.s)}, ${v.o})`;
|
|
28106
|
+
case "UDim2":
|
|
28107
|
+
if (!v.xo && !v.yo)
|
|
28108
|
+
return `UDim2.fromScale(${n(v.xs)}, ${n(v.ys)})`;
|
|
28109
|
+
if (!v.xs && !v.ys)
|
|
28110
|
+
return `UDim2.fromOffset(${v.xo}, ${v.yo})`;
|
|
28111
|
+
return `UDim2.new(${n(v.xs)}, ${v.xo}, ${n(v.ys)}, ${v.yo})`;
|
|
28112
|
+
case "Vector2":
|
|
28113
|
+
return `Vector2.new(${n(v.x)}, ${n(v.y)})`;
|
|
28114
|
+
case "Rect":
|
|
28115
|
+
return `Rect.new(${n(v.x0)}, ${n(v.y0)}, ${n(v.x1)}, ${n(v.y1)})`;
|
|
28116
|
+
case "enum":
|
|
28117
|
+
return `Enum.${v.e}.${v.v}`;
|
|
28118
|
+
case "Content":
|
|
28119
|
+
return luauString(v.url);
|
|
28120
|
+
case "ColorSequence":
|
|
28121
|
+
return `ColorSequence.new({
|
|
28122
|
+
${v.keys.map((k) => ` ColorSequenceKeypoint.new(${n(k.time)}, Color3.fromRGB(${rgb(k.r)}, ${rgb(k.g)}, ${rgb(k.b)})),`).join(`
|
|
28123
|
+
`)}
|
|
28124
|
+
})`;
|
|
28125
|
+
case "NumberSequence":
|
|
28126
|
+
return `NumberSequence.new({
|
|
28127
|
+
${v.keys.map((k) => ` NumberSequenceKeypoint.new(${n(k.time)}, ${n(k.value)}),`).join(`
|
|
28128
|
+
`)}
|
|
28129
|
+
})`;
|
|
28130
|
+
case "Font":
|
|
28131
|
+
return `Font.new(${luauString(v.family)}, Enum.FontWeight.${v.weightName}, Enum.FontStyle.${v.style})`;
|
|
28132
|
+
}
|
|
28133
|
+
}
|
|
28134
|
+
function identifier(name, className, used) {
|
|
28135
|
+
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);
|
|
28136
|
+
let id = words.map((w, i) => i ? w[0].toUpperCase() + w.slice(1).toLowerCase() : w.toLowerCase()).join("");
|
|
28137
|
+
if (!id || /^\d/.test(id))
|
|
28138
|
+
id = className[0].toLowerCase() + className.slice(1) + (id ? id[0].toUpperCase() + id.slice(1) : "");
|
|
28139
|
+
if (KEYWORDS.has(id))
|
|
28140
|
+
id += "Gui";
|
|
28141
|
+
let unique = id;
|
|
28142
|
+
for (let k = 2;used.has(unique); k++)
|
|
28143
|
+
unique = `${id}${k}`;
|
|
28144
|
+
used.add(unique);
|
|
28145
|
+
return unique;
|
|
28146
|
+
}
|
|
28147
|
+
function toLuau(root, opts = {}) {
|
|
28148
|
+
const used = new Set;
|
|
28149
|
+
const lines = [];
|
|
28150
|
+
const emit = (i, parentVar, depth) => {
|
|
28151
|
+
const v = identifier(i.name, i.className, used);
|
|
28152
|
+
if (!/^UI/.test(i.className) && depth <= 2 && parentVar)
|
|
28153
|
+
lines.push("", `-- ${i.name.replace(/\n/g, " ")}`);
|
|
28154
|
+
lines.push(`local ${v} = Instance.new(${luauString(i.className)})`);
|
|
28155
|
+
for (const [k, val] of i.props)
|
|
28156
|
+
lines.push(`${v}.${k} = ${luauValue(val)}`);
|
|
28157
|
+
for (const c of i.children)
|
|
28158
|
+
emit(c, v, depth + 1);
|
|
28159
|
+
if (parentVar)
|
|
28160
|
+
lines.push(`${v}.Parent = ${parentVar}`);
|
|
28161
|
+
return v;
|
|
28162
|
+
};
|
|
28163
|
+
const header = [
|
|
28164
|
+
`-- ${root.name}: generated by Figma Bridge${opts.source ? ` from "${opts.source.replace(/\n/g, " ")}"` : ""} (export_roblox).`,
|
|
28165
|
+
"-- Creates the UI under PARENT and returns it. Running it again replaces the previous copy.",
|
|
28166
|
+
`local PARENT = ${opts.parent ?? 'game:GetService("StarterGui")'}`,
|
|
28167
|
+
"",
|
|
28168
|
+
`local existing = PARENT:FindFirstChild(${luauString(root.name)})`,
|
|
28169
|
+
"if existing then",
|
|
28170
|
+
"\texisting:Destroy()",
|
|
28171
|
+
"end",
|
|
28172
|
+
""
|
|
28173
|
+
];
|
|
28174
|
+
const rootVar = emit(root, null, 0);
|
|
28175
|
+
return `${header.join(`
|
|
28176
|
+
`)}
|
|
28177
|
+
${lines.join(`
|
|
28178
|
+
`)}
|
|
28179
|
+
|
|
28180
|
+
${rootVar}.Parent = PARENT
|
|
28181
|
+
return ${rootVar}
|
|
28182
|
+
`.replace(/\n{3,}/g, `
|
|
28183
|
+
|
|
28184
|
+
`);
|
|
28185
|
+
}
|
|
28186
|
+
function substituteAssets(text, ids) {
|
|
28187
|
+
return text.replace(/rbxassetid:\/\/PENDING_\d+/g, (m) => ids[m] ? `rbxassetid://${ids[m]}` : m);
|
|
28188
|
+
}
|
|
28189
|
+
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);
|
|
28190
|
+
var init_output = __esm(() => {
|
|
28191
|
+
ENUMS = {
|
|
28192
|
+
ZIndexBehavior: { Global: 0, Sibling: 1 },
|
|
28193
|
+
FillDirection: { Horizontal: 0, Vertical: 1 },
|
|
28194
|
+
HorizontalAlignment: { Center: 0, Left: 1, Right: 2 },
|
|
28195
|
+
VerticalAlignment: { Center: 0, Top: 1, Bottom: 2 },
|
|
28196
|
+
SortOrder: { Name: 0, Custom: 1, LayoutOrder: 2 },
|
|
28197
|
+
UIFlexAlignment: { None: 0, Fill: 1, SpaceAround: 2, SpaceBetween: 3, SpaceEvenly: 4 },
|
|
28198
|
+
UIFlexMode: { None: 0, Grow: 1, Shrink: 2, Fill: 3, Custom: 4 },
|
|
28199
|
+
AutomaticSize: { None: 0, X: 1, Y: 2, XY: 3 },
|
|
28200
|
+
TextXAlignment: { Left: 0, Right: 1, Center: 2 },
|
|
28201
|
+
TextYAlignment: { Top: 0, Center: 1, Bottom: 2 },
|
|
28202
|
+
ScaleType: { Stretch: 0, Slice: 1, Tile: 2, Fit: 3, Crop: 4 },
|
|
28203
|
+
ApplyStrokeMode: { Contextual: 0, Border: 1 },
|
|
28204
|
+
LineJoinMode: { Round: 0, Bevel: 1, Miter: 2 },
|
|
28205
|
+
TextTruncate: { None: 0, AtEnd: 1, SplitWord: 2 }
|
|
28206
|
+
};
|
|
28207
|
+
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"]);
|
|
28208
|
+
});
|
|
28209
|
+
|
|
28210
|
+
// src/roblox/upload.ts
|
|
28211
|
+
import { createHash } from "node:crypto";
|
|
28212
|
+
import { mkdirSync as mkdirSync2, readFileSync as readFileSync2, writeFileSync as writeFileSync2 } from "node:fs";
|
|
28213
|
+
import { dirname as dirname2 } from "node:path";
|
|
28214
|
+
function creatorFrom(id) {
|
|
28215
|
+
const m = /^(?:(user|group)[:/])?\s*(\d+)$/i.exec(id.trim());
|
|
28216
|
+
if (!m)
|
|
28217
|
+
throw new RobloxUploadError(`ROBLOX_CREATOR_ID must be a user id, "user:<id>" or "group:<id>", not "${id}".`, "BAD_CREATOR");
|
|
28218
|
+
return m[1]?.toLowerCase() === "group" ? { groupId: m[2] } : { userId: m[2] };
|
|
28219
|
+
}
|
|
28220
|
+
|
|
28221
|
+
class RobloxUploader {
|
|
28222
|
+
opts;
|
|
28223
|
+
cache = {};
|
|
28224
|
+
fetchImpl;
|
|
28225
|
+
sleep;
|
|
28226
|
+
base;
|
|
28227
|
+
constructor(opts) {
|
|
28228
|
+
this.opts = opts;
|
|
28229
|
+
this.fetchImpl = opts.fetchImpl ?? fetch;
|
|
28230
|
+
this.sleep = opts.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
|
|
28231
|
+
this.base = opts.base ?? process.env.ROBLOX_API_URL ?? "https://apis.roblox.com";
|
|
28232
|
+
try {
|
|
28233
|
+
this.cache = JSON.parse(readFileSync2(opts.cacheFile, "utf8"));
|
|
28234
|
+
} catch {}
|
|
28235
|
+
}
|
|
28236
|
+
static hash(bytes) {
|
|
28237
|
+
return createHash("sha256").update(bytes).digest("hex");
|
|
28238
|
+
}
|
|
28239
|
+
async request(url, init) {
|
|
28240
|
+
let delay = 1000;
|
|
28241
|
+
for (let attempt = 0;; attempt++) {
|
|
28242
|
+
const res = await this.fetchImpl(url, { ...init, headers: { ...init.headers, "x-api-key": this.opts.apiKey }, signal: AbortSignal.timeout(60000) });
|
|
28243
|
+
if (res.ok)
|
|
28244
|
+
return res.json();
|
|
28245
|
+
const retryAfter = Number(res.headers.get("retry-after"));
|
|
28246
|
+
if ((res.status === 429 || res.status >= 500) && attempt < 3) {
|
|
28247
|
+
await this.sleep(retryAfter > 0 ? retryAfter * 1000 : delay);
|
|
28248
|
+
delay *= 2;
|
|
28249
|
+
continue;
|
|
28250
|
+
}
|
|
28251
|
+
let detail = "";
|
|
28252
|
+
try {
|
|
28253
|
+
const body = await res.json();
|
|
28254
|
+
detail = body.message ?? body.errors?.[0]?.message ?? "";
|
|
28255
|
+
} catch {}
|
|
28256
|
+
if (res.status === 401 || res.status === 403)
|
|
28257
|
+
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");
|
|
28258
|
+
throw new RobloxUploadError(`Roblox Open Cloud error ${res.status}${detail ? `: ${detail}` : ""}`, "UPLOAD_FAILED");
|
|
28259
|
+
}
|
|
28260
|
+
}
|
|
28261
|
+
async upload(bytes, name) {
|
|
28262
|
+
const hash = RobloxUploader.hash(bytes);
|
|
28263
|
+
if (this.cache[hash])
|
|
28264
|
+
return { assetId: this.cache[hash], cached: true };
|
|
28265
|
+
const form = new FormData;
|
|
28266
|
+
form.append("request", JSON.stringify({
|
|
28267
|
+
assetType: "Image",
|
|
28268
|
+
displayName: name.slice(0, 50) || "Figma Bridge image",
|
|
28269
|
+
description: "Uploaded by Figma Bridge (export_roblox)",
|
|
28270
|
+
creationContext: { creator: creatorFrom(this.opts.creatorId) }
|
|
28271
|
+
}));
|
|
28272
|
+
form.append("fileContent", new Blob([new Uint8Array(bytes)], { type: "image/png" }), `${name.replace(/[^\w.-]+/g, "_") || "image"}.png`);
|
|
28273
|
+
let op = await this.request(`${this.base}/assets/v1/assets`, { method: "POST", body: form });
|
|
28274
|
+
for (let i = 0;!op.done; i++) {
|
|
28275
|
+
if (i >= 20)
|
|
28276
|
+
throw new RobloxUploadError(`Roblox is still processing "${name}": try again in a minute.`, "UPLOAD_PENDING");
|
|
28277
|
+
await this.sleep(Math.min(500 * 2 ** i, 4000));
|
|
28278
|
+
const id = String(op.operationId ?? op.path ?? "").replace(/^operations\//, "");
|
|
28279
|
+
if (!id)
|
|
28280
|
+
throw new RobloxUploadError("Roblox returned no operation to wait for.", "UPLOAD_FAILED");
|
|
28281
|
+
op = await this.request(`${this.base}/assets/v1/operations/${id}`, { method: "GET" });
|
|
28282
|
+
}
|
|
28283
|
+
const assetId = String(op.response?.assetId ?? "");
|
|
28284
|
+
if (!assetId)
|
|
28285
|
+
throw new RobloxUploadError(`Upload of "${name}" finished without an asset id${op.error?.message ? `: ${op.error.message}` : ""}.`, "UPLOAD_FAILED");
|
|
28286
|
+
this.cache[hash] = assetId;
|
|
28287
|
+
mkdirSync2(dirname2(this.opts.cacheFile), { recursive: true });
|
|
28288
|
+
writeFileSync2(this.opts.cacheFile, JSON.stringify(this.cache, null, 2));
|
|
28289
|
+
return { assetId, cached: false };
|
|
28290
|
+
}
|
|
28291
|
+
}
|
|
28292
|
+
var RobloxUploadError, UPLOAD_HELP;
|
|
28293
|
+
var init_upload = __esm(() => {
|
|
28294
|
+
RobloxUploadError = class RobloxUploadError extends Error {
|
|
28295
|
+
code;
|
|
28296
|
+
constructor(message, code) {
|
|
28297
|
+
super(message);
|
|
28298
|
+
this.code = code;
|
|
28299
|
+
}
|
|
28300
|
+
};
|
|
28301
|
+
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.';
|
|
28302
|
+
});
|
|
28303
|
+
|
|
28304
|
+
// src/roblox/export.ts
|
|
28305
|
+
import { mkdirSync as mkdirSync3, writeFileSync as writeFileSync3 } from "node:fs";
|
|
28306
|
+
import { homedir } from "node:os";
|
|
28307
|
+
import { join as join2 } from "node:path";
|
|
28308
|
+
async function exportRoblox(args, deps) {
|
|
28309
|
+
const env = deps.env ?? process.env;
|
|
28310
|
+
const opts = {
|
|
28311
|
+
mode: args.mode ?? "hybrid",
|
|
28312
|
+
targetResolution: args.targetResolution ?? [1920, 1080],
|
|
28313
|
+
rasterize: args.rasterize ?? "auto",
|
|
28314
|
+
asRootFrame: args.asRootFrame,
|
|
28315
|
+
textScaled: args.textScaled,
|
|
28316
|
+
fonts: args.fonts,
|
|
28317
|
+
scale: 2
|
|
28318
|
+
};
|
|
28319
|
+
const t = await deps.request("roblox_tree", { nodeId: args.nodeId }, 120000);
|
|
28320
|
+
const warnings = [];
|
|
28321
|
+
if (t.truncated)
|
|
28322
|
+
warnings.push(`Stopped after ${t.nodes} layers: export a smaller frame for the rest.`);
|
|
28323
|
+
const wanted = pictureRequests(t.tree, opts);
|
|
28324
|
+
const pictures = new Map;
|
|
28325
|
+
for (let i = 0;i < wanted.length; i += 20) {
|
|
28326
|
+
const r = await deps.request("roblox_images", { items: wanted.slice(i, i + 20).map((w) => ({ ...w, scale: opts.scale })) }, 120000);
|
|
28327
|
+
for (const img of r.images) {
|
|
28328
|
+
if (img.b64)
|
|
28329
|
+
pictures.set(`${img.id}:${img.mode}`, { b64: img.b64, offset: img.offset, size: img.size });
|
|
28330
|
+
else
|
|
28331
|
+
warnings.push(`Picture of ${img.id} failed: ${img.error}`);
|
|
28332
|
+
}
|
|
28333
|
+
}
|
|
28334
|
+
const assets = [];
|
|
28335
|
+
const files = new Map;
|
|
28336
|
+
const refs = new Map;
|
|
28337
|
+
const names = new Map;
|
|
28338
|
+
const layerNames = (n) => {
|
|
28339
|
+
names.set(n.id, n.name);
|
|
28340
|
+
for (const c of n.children ?? [])
|
|
28341
|
+
layerNames(c);
|
|
28342
|
+
};
|
|
28343
|
+
layerNames(t.tree);
|
|
28344
|
+
const add = (key, bytes, a, ext, ref) => {
|
|
28345
|
+
const placeholder = `rbxassetid://PENDING_${assets.length + 1}`;
|
|
28346
|
+
const file = `${String(assets.length + 1).padStart(2, "0")}-${safe(a.layer)}${a.mode && a.mode !== "full" ? `-${a.mode}` : ""}.${ext}`;
|
|
28347
|
+
assets.push({ placeholder, file, ...a });
|
|
28348
|
+
files.set(file, bytes);
|
|
28349
|
+
refs.set(key, { url: placeholder, ...ref });
|
|
28350
|
+
};
|
|
28351
|
+
for (const w of wanted) {
|
|
28352
|
+
const p = pictures.get(`${w.id}:${w.mode}`);
|
|
28353
|
+
if (!p)
|
|
28354
|
+
continue;
|
|
28355
|
+
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 });
|
|
28356
|
+
}
|
|
28357
|
+
for (const hash of imageHashes(t.tree)) {
|
|
28358
|
+
const b64 = t.images[hash];
|
|
28359
|
+
if (!b64)
|
|
28360
|
+
continue;
|
|
28361
|
+
const bytes = new Uint8Array(Buffer.from(b64, "base64"));
|
|
28362
|
+
const info = imageInfo(bytes);
|
|
28363
|
+
if (info && info.format !== "png" && info.format !== "jpeg")
|
|
28364
|
+
warnings.push(`An image is ${info.format.toUpperCase()}: Roblox takes PNG, JPG, BMP or TGA, convert it before uploading.`);
|
|
28365
|
+
const owner = findImageOwner(t.tree, hash);
|
|
28366
|
+
add(`image:${hash}`, bytes, { nodeId: owner?.id ?? "", layer: owner?.name ?? "image", kind: "image" }, info?.format === "jpeg" ? "jpg" : info?.format ?? "png", {});
|
|
28367
|
+
}
|
|
28368
|
+
const mapped = mapToRoblox(t.tree, opts, (key) => refs.get(key));
|
|
28369
|
+
warnings.push(...mapped.warnings);
|
|
28370
|
+
let rbxmx = toRbxmx(mapped.root);
|
|
28371
|
+
let luau = toLuau(mapped.root, { parent: args.parent, source: `${t.tree.name}" in "${t.fileName}` });
|
|
28372
|
+
let uploaded = false;
|
|
28373
|
+
if (args.upload) {
|
|
28374
|
+
if (!env.ROBLOX_API_KEY || !env.ROBLOX_CREATOR_ID)
|
|
28375
|
+
warnings.push(`upload skipped: ROBLOX_API_KEY and ROBLOX_CREATOR_ID are not set. ${UPLOAD_HELP}`);
|
|
28376
|
+
else if (assets.length) {
|
|
28377
|
+
const uploader = new RobloxUploader({
|
|
28378
|
+
apiKey: env.ROBLOX_API_KEY,
|
|
28379
|
+
creatorId: env.ROBLOX_CREATOR_ID,
|
|
28380
|
+
cacheFile: join2(env.FIGMA_BRIDGE_HOME ?? join2(homedir(), ".figma-bridge"), "roblox-assets.json"),
|
|
28381
|
+
fetchImpl: deps.fetchImpl,
|
|
28382
|
+
sleep: deps.sleep
|
|
28383
|
+
});
|
|
28384
|
+
const ids = {};
|
|
28385
|
+
for (const a of assets) {
|
|
28386
|
+
const r = await uploader.upload(files.get(a.file), `${a.layer}${a.mode && a.mode !== "full" ? ` ${a.mode}` : ""}`);
|
|
28387
|
+
a.assetId = r.assetId;
|
|
28388
|
+
a.cached = r.cached;
|
|
28389
|
+
ids[a.placeholder] = r.assetId;
|
|
28390
|
+
}
|
|
28391
|
+
rbxmx = substituteAssets(rbxmx, ids);
|
|
28392
|
+
luau = substituteAssets(luau, ids);
|
|
28393
|
+
uploaded = true;
|
|
28394
|
+
}
|
|
28395
|
+
}
|
|
28396
|
+
const dir = args.outDir ?? join2(deps.outDir, `roblox-${safe(t.tree.name)}-${Date.now()}`);
|
|
28397
|
+
mkdirSync3(dir, { recursive: true });
|
|
28398
|
+
const base = safe(mapped.root.name);
|
|
28399
|
+
writeFileSync3(join2(dir, `${base}.rbxmx`), rbxmx);
|
|
28400
|
+
writeFileSync3(join2(dir, `${base}.luau`), luau);
|
|
28401
|
+
for (const [file, bytes] of files)
|
|
28402
|
+
writeFileSync3(join2(dir, file), bytes);
|
|
28403
|
+
writeFileSync3(join2(dir, "assets.json"), JSON.stringify(assets, null, 2));
|
|
28404
|
+
const pending = assets.filter((a) => !a.assetId);
|
|
28405
|
+
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.";
|
|
28406
|
+
return {
|
|
28407
|
+
result: {
|
|
28408
|
+
root: mapped.root.name,
|
|
28409
|
+
dir,
|
|
28410
|
+
rbxmx: join2(dir, `${base}.rbxmx`),
|
|
28411
|
+
luau: join2(dir, `${base}.luau`),
|
|
28412
|
+
assets: assets.map((a) => ({ ...a, file: join2(dir, a.file) })),
|
|
28413
|
+
uploaded,
|
|
28414
|
+
instances: mapped.counts.instances,
|
|
28415
|
+
layers: t.nodes,
|
|
28416
|
+
options: { mode: opts.mode, targetResolution: opts.targetResolution, rasterize: opts.rasterize },
|
|
28417
|
+
...Object.keys(mapped.fontSubstitutions).length ? { fontSubstitutions: mapped.fontSubstitutions } : {},
|
|
28418
|
+
...warnings.length ? { warnings: warnings.slice(0, 60) } : {},
|
|
28419
|
+
next
|
|
28420
|
+
},
|
|
28421
|
+
luau,
|
|
28422
|
+
rbxmx
|
|
28423
|
+
};
|
|
28424
|
+
}
|
|
28425
|
+
function findImageOwner(n, hash) {
|
|
28426
|
+
if ((n.fills ?? []).some((f) => f.imageHash === hash))
|
|
28427
|
+
return n;
|
|
28428
|
+
for (const c of n.children ?? []) {
|
|
28429
|
+
const hit = findImageOwner(c, hash);
|
|
28430
|
+
if (hit)
|
|
28431
|
+
return hit;
|
|
28432
|
+
}
|
|
28433
|
+
return null;
|
|
28434
|
+
}
|
|
28435
|
+
var safe = (s) => s.replace(/[^\w.-]+/g, "_").replace(/^_+|_+$/g, "").slice(0, 40) || "ui";
|
|
28436
|
+
var init_export = __esm(() => {
|
|
28437
|
+
init_image();
|
|
28438
|
+
init_map2();
|
|
28439
|
+
init_output();
|
|
28440
|
+
init_upload();
|
|
28441
|
+
});
|
|
28442
|
+
|
|
28443
|
+
// src/rest.ts
|
|
28444
|
+
function fileKeyFrom(input) {
|
|
28445
|
+
const m = /figma\.com\/(?:file|design|board|slides|proto|deck)\/([A-Za-z0-9]{10,})/.exec(input);
|
|
28446
|
+
if (m)
|
|
28447
|
+
return m[1];
|
|
28448
|
+
return /^[A-Za-z0-9]{10,}$/.test(input.trim()) ? input.trim() : null;
|
|
28449
|
+
}
|
|
28450
|
+
|
|
28451
|
+
class FigmaRest {
|
|
28452
|
+
token;
|
|
28453
|
+
fetchImpl;
|
|
28454
|
+
sleep;
|
|
28455
|
+
base;
|
|
28456
|
+
constructor(token, fetchImpl = fetch, sleep = (ms) => new Promise((r) => setTimeout(r, ms)), base = process.env.FIGMA_API_URL || "https://api.figma.com") {
|
|
28457
|
+
this.token = token;
|
|
28458
|
+
this.fetchImpl = fetchImpl;
|
|
28459
|
+
this.sleep = sleep;
|
|
28460
|
+
this.base = base;
|
|
28461
|
+
}
|
|
28462
|
+
async call(method, path, body) {
|
|
28463
|
+
let delay = 1000;
|
|
28464
|
+
for (let attempt = 0;; attempt++) {
|
|
28465
|
+
let res;
|
|
28466
|
+
try {
|
|
28467
|
+
res = await this.fetchImpl(this.base + path, {
|
|
28468
|
+
method,
|
|
28469
|
+
headers: { "X-Figma-Token": this.token, ...body !== undefined ? { "Content-Type": "application/json" } : {} },
|
|
28470
|
+
body: body !== undefined ? JSON.stringify(body) : undefined,
|
|
28471
|
+
signal: AbortSignal.timeout(60000)
|
|
28472
|
+
});
|
|
28473
|
+
} catch (e) {
|
|
28474
|
+
if (attempt >= 3)
|
|
28475
|
+
throw new RestError(`Figma API unreachable: ${e.message}`, "NETWORK");
|
|
28476
|
+
await this.sleep(delay);
|
|
28477
|
+
delay *= 2;
|
|
28478
|
+
continue;
|
|
28479
|
+
}
|
|
28480
|
+
if (res.ok)
|
|
28481
|
+
return res.status === 204 ? {} : await res.json();
|
|
28482
|
+
const retryAfter = Number(res.headers.get("retry-after"));
|
|
28483
|
+
if ((res.status === 429 || res.status >= 500) && attempt < 3) {
|
|
28484
|
+
await this.sleep(res.status === 429 && retryAfter > 0 ? Math.min(retryAfter, 120) * 1000 : delay);
|
|
28485
|
+
delay *= 2;
|
|
28486
|
+
continue;
|
|
28487
|
+
}
|
|
28488
|
+
let detail = "";
|
|
28489
|
+
try {
|
|
28490
|
+
const j = await res.json();
|
|
28491
|
+
detail = j.err ?? j.message ?? "";
|
|
28492
|
+
} catch {}
|
|
28493
|
+
if (res.status === 403)
|
|
28494
|
+
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);
|
|
28495
|
+
if (res.status === 404)
|
|
28496
|
+
throw new RestError(`Not found (404${detail ? `: ${detail}` : ""}): check the file key or id.`, "NOT_FOUND", 404);
|
|
28497
|
+
if (res.status === 429)
|
|
28498
|
+
throw new RestError("Figma API rate limit reached: try again in a minute.", "RATE_LIMITED", 429);
|
|
28499
|
+
throw new RestError(`Figma API error ${res.status}${detail ? `: ${detail}` : ""}`, "REST_ERROR", res.status);
|
|
28500
|
+
}
|
|
28501
|
+
}
|
|
28502
|
+
async comments(key) {
|
|
28503
|
+
const r = await this.call("GET", `/v1/files/${key}/comments?as_md=true`);
|
|
28504
|
+
return r.comments ?? [];
|
|
28505
|
+
}
|
|
28506
|
+
postComment(key, message, opts = {}) {
|
|
28507
|
+
const body = { message };
|
|
28508
|
+
if (opts.replyTo)
|
|
28509
|
+
body.comment_id = opts.replyTo;
|
|
28510
|
+
else if (opts.nodeId)
|
|
28511
|
+
body.client_meta = { node_id: opts.nodeId, node_offset: opts.offset ?? { x: 0, y: 0 } };
|
|
28512
|
+
return this.call("POST", `/v1/files/${key}/comments`, body);
|
|
28513
|
+
}
|
|
28514
|
+
deleteComment(key, id) {
|
|
28515
|
+
return this.call("DELETE", `/v1/files/${key}/comments/${id}`);
|
|
28516
|
+
}
|
|
28517
|
+
async versions(key, limit = 30) {
|
|
28518
|
+
const out = [];
|
|
28519
|
+
let before;
|
|
28520
|
+
while (out.length < limit) {
|
|
28521
|
+
const r = await this.call("GET", `/v1/files/${key}/versions?page_size=${Math.min(50, limit)}${before ? `&before=${before}` : ""}`);
|
|
28522
|
+
out.push(...r.versions ?? []);
|
|
28523
|
+
const next = r.pagination?.prev_page ? new URL(r.pagination.prev_page).searchParams.get("before") : null;
|
|
28524
|
+
if (!next || !r.versions?.length)
|
|
28525
|
+
break;
|
|
28526
|
+
before = next;
|
|
28527
|
+
}
|
|
28528
|
+
return out.slice(0, limit);
|
|
28529
|
+
}
|
|
28530
|
+
async nodes(key, ids, version) {
|
|
28531
|
+
const q = `ids=${encodeURIComponent(ids.join(","))}${version ? `&version=${encodeURIComponent(version)}` : ""}`;
|
|
28532
|
+
const r = await this.call("GET", `/v1/files/${key}/nodes?${q}`);
|
|
28533
|
+
const out = {};
|
|
28534
|
+
for (const [id, n] of Object.entries(r.nodes ?? {}))
|
|
28535
|
+
if (n?.document)
|
|
28536
|
+
out[id] = n.document;
|
|
28537
|
+
return out;
|
|
28538
|
+
}
|
|
28539
|
+
async pages(key, version) {
|
|
28540
|
+
const r = await this.call("GET", `/v1/files/${key}?depth=1${version ? `&version=${encodeURIComponent(version)}` : ""}`);
|
|
28541
|
+
return r.document?.children ?? [];
|
|
28542
|
+
}
|
|
28543
|
+
}
|
|
28544
|
+
function threadComments(list, includeResolved) {
|
|
28545
|
+
const replies = new Map;
|
|
28546
|
+
for (const c of list)
|
|
28547
|
+
if (c.parent_id)
|
|
28548
|
+
replies.set(c.parent_id, [...replies.get(c.parent_id) ?? [], c]);
|
|
28549
|
+
const brief = (c) => ({ id: c.id, author: c.user?.handle, message: c.message, createdAt: c.created_at });
|
|
28550
|
+
return list.filter((c) => !c.parent_id && (includeResolved || !c.resolved_at)).sort((a, b) => a.created_at.localeCompare(b.created_at)).map((c) => ({
|
|
28551
|
+
...brief(c),
|
|
28552
|
+
...c.order_id ? { number: Number(c.order_id) } : {},
|
|
28553
|
+
...c.resolved_at ? { resolvedAt: c.resolved_at } : {},
|
|
28554
|
+
...c.client_meta?.node_id ? { nodeId: c.client_meta.node_id } : {},
|
|
28555
|
+
replies: (replies.get(c.id) ?? []).sort((a, b) => a.created_at.localeCompare(b.created_at)).map(brief)
|
|
28556
|
+
}));
|
|
28557
|
+
}
|
|
28558
|
+
function flatten(root, prefix, out) {
|
|
28559
|
+
const seen = new Map;
|
|
28560
|
+
for (const c of root.children ?? []) {
|
|
28561
|
+
const n = (seen.get(c.name) ?? 0) + 1;
|
|
28562
|
+
seen.set(c.name, n);
|
|
28563
|
+
const path = `${prefix}${c.name}${n > 1 ? ` #${n}` : ""}`;
|
|
28564
|
+
out.set(path, c);
|
|
28565
|
+
flatten(c, `${path}/`, out);
|
|
28566
|
+
}
|
|
28567
|
+
}
|
|
28568
|
+
function diffTrees(before, after, max = 200) {
|
|
28569
|
+
const a = new Map;
|
|
28570
|
+
const b = new Map;
|
|
28571
|
+
flatten(before, "", a);
|
|
28572
|
+
flatten(after, "", b);
|
|
28573
|
+
const added = [];
|
|
28574
|
+
const removed = [];
|
|
28575
|
+
const changed = [];
|
|
28576
|
+
for (const [path, n] of b)
|
|
28577
|
+
if (!a.has(path))
|
|
28578
|
+
added.push({ path, type: n.type, id: n.id });
|
|
28579
|
+
for (const [path, n] of a)
|
|
28580
|
+
if (!b.has(path))
|
|
28581
|
+
removed.push({ path, type: n.type, id: n.id });
|
|
28582
|
+
for (const [path, now] of b) {
|
|
28583
|
+
const old = a.get(path);
|
|
28584
|
+
if (!old)
|
|
28585
|
+
continue;
|
|
28586
|
+
const c = [];
|
|
28587
|
+
if (old.type !== now.type)
|
|
28588
|
+
c.push(`type ${old.type} → ${now.type}`);
|
|
28589
|
+
if ((old.visible ?? true) !== (now.visible ?? true))
|
|
28590
|
+
c.push(now.visible === false ? "hidden" : "shown");
|
|
28591
|
+
if ((old.characters ?? "") !== (now.characters ?? ""))
|
|
28592
|
+
c.push(`text "${(old.characters ?? "").slice(0, 60)}" → "${(now.characters ?? "").slice(0, 60)}"`);
|
|
28593
|
+
const ob = old.absoluteBoundingBox;
|
|
28594
|
+
const nb = now.absoluteBoundingBox;
|
|
28595
|
+
if (ob && nb) {
|
|
28596
|
+
if (Math.abs(ob.width - nb.width) > 0.5 || Math.abs(ob.height - nb.height) > 0.5)
|
|
28597
|
+
c.push(`size ${Math.round(ob.width)}×${Math.round(ob.height)} → ${Math.round(nb.width)}×${Math.round(nb.height)}`);
|
|
28598
|
+
if (Math.abs(ob.x - nb.x) > 0.5 || Math.abs(ob.y - nb.y) > 0.5)
|
|
28599
|
+
c.push(`moved ${Math.round(nb.x - ob.x)}, ${Math.round(nb.y - ob.y)}`);
|
|
28600
|
+
}
|
|
28601
|
+
if (fillsOf(old) !== fillsOf(now))
|
|
28602
|
+
c.push(`fill ${fillsOf(old) || "none"} → ${fillsOf(now) || "none"}`);
|
|
28603
|
+
if (c.length)
|
|
28604
|
+
changed.push({ path, type: now.type, id: now.id, changes: c });
|
|
28605
|
+
}
|
|
28606
|
+
const movedOnly = (x) => x.changes.length === 1 && x.changes[0].startsWith("moved");
|
|
28607
|
+
const movedPaths = new Set(changed.filter(movedOnly).map((x) => x.path));
|
|
28608
|
+
const cleaned = changed.filter((x) => !(movedOnly(x) && [...movedPaths].some((p) => p !== x.path && x.path.startsWith(`${p}/`))));
|
|
28609
|
+
const top = (list) => list.filter((x) => !list.some((y) => y !== x && x.path.startsWith(`${y.path}/`)));
|
|
28610
|
+
const addedTop = top(added);
|
|
28611
|
+
const removedTop = top(removed);
|
|
28612
|
+
return {
|
|
28613
|
+
summary: { added: addedTop.length, removed: removedTop.length, changed: cleaned.length, layersBefore: a.size, layersAfter: b.size },
|
|
28614
|
+
added: addedTop.slice(0, max),
|
|
28615
|
+
removed: removedTop.slice(0, max),
|
|
28616
|
+
changed: cleaned.slice(0, max),
|
|
28617
|
+
truncated: addedTop.length > max || removedTop.length > max || cleaned.length > max
|
|
28618
|
+
};
|
|
28619
|
+
}
|
|
28620
|
+
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(",");
|
|
28621
|
+
var init_rest = __esm(() => {
|
|
28622
|
+
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.';
|
|
28623
|
+
RestError = class RestError extends Error {
|
|
28624
|
+
code;
|
|
28625
|
+
status;
|
|
28626
|
+
constructor(message, code, status) {
|
|
28627
|
+
super(message);
|
|
28628
|
+
this.code = code;
|
|
28629
|
+
this.status = status;
|
|
28630
|
+
}
|
|
28631
|
+
};
|
|
28632
|
+
});
|
|
28633
|
+
|
|
28634
|
+
// src/project/detect.ts
|
|
28635
|
+
import { existsSync as existsSync2, readdirSync, readFileSync as readFileSync3, statSync } from "node:fs";
|
|
28636
|
+
import { join as join3, relative, sep } from "node:path";
|
|
28637
|
+
function listFiles(root, filter, max = 4000) {
|
|
28638
|
+
const out = [];
|
|
28639
|
+
const walk = (dir, depth) => {
|
|
28640
|
+
if (out.length >= max || depth > 8)
|
|
28641
|
+
return;
|
|
28642
|
+
let entries;
|
|
28643
|
+
try {
|
|
28644
|
+
entries = readdirSync(dir);
|
|
28645
|
+
} catch {
|
|
28646
|
+
return;
|
|
28647
|
+
}
|
|
28648
|
+
for (const name of entries.sort()) {
|
|
28649
|
+
if (IGNORE.has(name) || name.startsWith(".") && name !== ".storybook")
|
|
28650
|
+
continue;
|
|
28651
|
+
const full = join3(dir, name);
|
|
28652
|
+
let st;
|
|
28653
|
+
try {
|
|
28654
|
+
st = statSync(full);
|
|
28655
|
+
} catch {
|
|
28656
|
+
continue;
|
|
28657
|
+
}
|
|
28658
|
+
if (st.isDirectory())
|
|
28659
|
+
walk(full, depth + 1);
|
|
28660
|
+
else {
|
|
28661
|
+
const rel = relative(root, full).split(sep).join("/");
|
|
28662
|
+
if (filter(rel))
|
|
28663
|
+
out.push(rel);
|
|
28664
|
+
}
|
|
28665
|
+
if (out.length >= max)
|
|
28666
|
+
return;
|
|
28667
|
+
}
|
|
28668
|
+
};
|
|
28669
|
+
walk(root, 0);
|
|
28670
|
+
return out;
|
|
28671
|
+
}
|
|
28672
|
+
function readJsonc(path) {
|
|
28673
|
+
try {
|
|
28674
|
+
const text = readFileSync3(path, "utf8").replace(/("(?:[^"\\]|\\.)*")|\/\/[^\n]*|\/\*[\s\S]*?\*\//g, (m, str) => str ?? "").replace(/,(\s*[}\]])/g, "$1");
|
|
28675
|
+
return JSON.parse(text);
|
|
28676
|
+
} catch {
|
|
28677
|
+
return null;
|
|
28678
|
+
}
|
|
28679
|
+
}
|
|
28680
|
+
function detectStack(root) {
|
|
28681
|
+
const pkg = readJsonc(join3(root, "package.json"));
|
|
28682
|
+
if (!pkg)
|
|
28683
|
+
throw new Error(`No package.json in ${root}: give the root folder of a JavaScript project as projectPath.`);
|
|
28684
|
+
const deps = { ...pkg.peerDependencies, ...pkg.devDependencies, ...pkg.dependencies };
|
|
28685
|
+
const has = (d) => deps[d] !== undefined;
|
|
28686
|
+
const at = (...p) => existsSync2(join3(root, ...p));
|
|
28687
|
+
const evidence = [];
|
|
28688
|
+
let kind = "html";
|
|
28689
|
+
let framework = "html";
|
|
28690
|
+
if (has("react-native") || has("expo"))
|
|
28691
|
+
[kind, framework] = [has("expo") ? "expo" : "react-native", "react-native"];
|
|
28692
|
+
else if (has("next"))
|
|
28693
|
+
[kind, framework] = ["next", "react"];
|
|
28694
|
+
else if (has("nuxt"))
|
|
28695
|
+
[kind, framework] = ["nuxt", "vue"];
|
|
28696
|
+
else if (has("vue"))
|
|
28697
|
+
[kind, framework] = ["vue", "vue"];
|
|
28698
|
+
else if (has("@sveltejs/kit"))
|
|
28699
|
+
[kind, framework] = ["sveltekit", "svelte"];
|
|
28700
|
+
else if (has("svelte"))
|
|
28701
|
+
[kind, framework] = ["svelte", "svelte"];
|
|
28702
|
+
else if (has("react"))
|
|
28703
|
+
[kind, framework] = [has("vite") ? "vite" : "react", "react"];
|
|
28704
|
+
evidence.push(`framework: ${kind}`);
|
|
28705
|
+
const tailwindConfig = ["tailwind.config.ts", "tailwind.config.js", "tailwind.config.cjs", "tailwind.config.mjs"].find((f) => at(f));
|
|
28706
|
+
let tailwind;
|
|
28707
|
+
if (has("tailwindcss") || has("nativewind")) {
|
|
28708
|
+
const v = major(deps.tailwindcss);
|
|
28709
|
+
tailwind = has("@tailwindcss/postcss") || has("@tailwindcss/vite") || v !== undefined && v >= 4 ? 4 : 3;
|
|
28710
|
+
evidence.push(`tailwindcss ${tailwind}${tailwindConfig ? ` (${tailwindConfig})` : ""}`);
|
|
28711
|
+
}
|
|
28712
|
+
const sourceFiles = listFiles(root, (f) => /\.(module\.(css|scss)|css|tsx|jsx|ts|js|vue|svelte)$/.test(f), 3000);
|
|
28713
|
+
let styling = "css";
|
|
28714
|
+
if (tailwind && (framework !== "react-native" || has("nativewind")))
|
|
28715
|
+
styling = "tailwind";
|
|
28716
|
+
else if (has("styled-components") || has("@emotion/styled"))
|
|
28717
|
+
styling = "styled-components";
|
|
28718
|
+
else if (sourceFiles.some((f) => /\.module\.(css|scss)$/.test(f)))
|
|
28719
|
+
styling = "css-modules";
|
|
28720
|
+
evidence.push(`styling: ${styling}`);
|
|
28721
|
+
const ui = [];
|
|
28722
|
+
const shadcn = readJsonc(join3(root, "components.json"));
|
|
28723
|
+
if (shadcn?.aliases || has("class-variance-authority") && sourceFiles.some((f) => /components\/ui\/button\.(t|j)sx$/.test(f)))
|
|
28724
|
+
ui.push("shadcn");
|
|
28725
|
+
if (has("@mui/material"))
|
|
28726
|
+
ui.push("mui");
|
|
28727
|
+
if (has("@chakra-ui/react"))
|
|
28728
|
+
ui.push("chakra");
|
|
28729
|
+
if (ui.length)
|
|
28730
|
+
evidence.push(`ui: ${ui.join(", ")}`);
|
|
28731
|
+
const tsconfig = readJsonc(join3(root, "tsconfig.json")) ?? readJsonc(join3(root, "jsconfig.json"));
|
|
28732
|
+
const typescript = at("tsconfig.json") || has("typescript");
|
|
28733
|
+
const aliases = [];
|
|
28734
|
+
const paths = tsconfig?.compilerOptions?.paths ?? {};
|
|
28735
|
+
const baseUrl = String(tsconfig?.compilerOptions?.baseUrl ?? ".");
|
|
28736
|
+
for (const [key, targets] of Object.entries(paths)) {
|
|
26905
28737
|
const target = Array.isArray(targets) ? String(targets[0]) : "";
|
|
26906
28738
|
if (!key.endsWith("/*") || !target.endsWith("/*"))
|
|
26907
28739
|
continue;
|
|
26908
|
-
const t =
|
|
28740
|
+
const t = join3(baseUrl, target.slice(0, -2)).split(sep).join("/").replace(/^\.\//, "");
|
|
26909
28741
|
aliases.push({ prefix: key.slice(0, -1), target: t === "." ? "" : `${t}/` });
|
|
26910
28742
|
}
|
|
26911
28743
|
if (kind === "nuxt" && !aliases.length)
|
|
@@ -26942,8 +28774,8 @@ var init_detect = __esm(() => {
|
|
|
26942
28774
|
});
|
|
26943
28775
|
|
|
26944
28776
|
// src/project/components.ts
|
|
26945
|
-
import { readFileSync as
|
|
26946
|
-
import { basename, join as
|
|
28777
|
+
import { readFileSync as readFileSync4 } from "node:fs";
|
|
28778
|
+
import { basename, join as join4 } from "node:path";
|
|
26947
28779
|
function block(text, start, open = "{", close = "}") {
|
|
26948
28780
|
let depth = 0;
|
|
26949
28781
|
for (let i = start;i < text.length; i++) {
|
|
@@ -27123,7 +28955,7 @@ function indexComponents(stack, max = 400) {
|
|
|
27123
28955
|
for (const file of files) {
|
|
27124
28956
|
let src;
|
|
27125
28957
|
try {
|
|
27126
|
-
src =
|
|
28958
|
+
src = readFileSync4(join4(stack.root, file), "utf8");
|
|
27127
28959
|
} catch {
|
|
27128
28960
|
continue;
|
|
27129
28961
|
}
|
|
@@ -27174,14 +29006,14 @@ var init_components = __esm(() => {
|
|
|
27174
29006
|
});
|
|
27175
29007
|
|
|
27176
29008
|
// src/project/mapping.ts
|
|
27177
|
-
import { existsSync as existsSync3, readFileSync as
|
|
27178
|
-
import { join as
|
|
29009
|
+
import { existsSync as existsSync3, readFileSync as readFileSync5 } from "node:fs";
|
|
29010
|
+
import { join as join5 } from "node:path";
|
|
27179
29011
|
function loadMapFile(root) {
|
|
27180
|
-
const path =
|
|
29012
|
+
const path = join5(root, MAP_FILE);
|
|
27181
29013
|
if (!existsSync3(path))
|
|
27182
29014
|
return { map: {} };
|
|
27183
29015
|
try {
|
|
27184
|
-
return { map: JSON.parse(
|
|
29016
|
+
return { map: JSON.parse(readFileSync5(path, "utf8")), path };
|
|
27185
29017
|
} catch (e) {
|
|
27186
29018
|
return { map: {}, error: `${MAP_FILE} is not valid JSON: ${e.message}` };
|
|
27187
29019
|
}
|
|
@@ -27338,8 +29170,8 @@ var init_mapping = __esm(() => {
|
|
|
27338
29170
|
});
|
|
27339
29171
|
|
|
27340
29172
|
// src/project/tokens.ts
|
|
27341
|
-
import { readFileSync as
|
|
27342
|
-
import { join as
|
|
29173
|
+
import { readFileSync as readFileSync6 } from "node:fs";
|
|
29174
|
+
import { join as join6 } from "node:path";
|
|
27343
29175
|
function oklch(l, c, h, a) {
|
|
27344
29176
|
const hr = h * Math.PI / 180;
|
|
27345
29177
|
return oklab(l, c * Math.cos(hr), c * Math.sin(hr), a);
|
|
@@ -27426,7 +29258,7 @@ function loadTokens(stack) {
|
|
|
27426
29258
|
for (const file of stack.cssFiles) {
|
|
27427
29259
|
let css;
|
|
27428
29260
|
try {
|
|
27429
|
-
css =
|
|
29261
|
+
css = readFileSync6(join6(stack.root, file), "utf8");
|
|
27430
29262
|
} catch {
|
|
27431
29263
|
continue;
|
|
27432
29264
|
}
|
|
@@ -27470,7 +29302,7 @@ function loadTokens(stack) {
|
|
|
27470
29302
|
}
|
|
27471
29303
|
if (stack.tailwind === 3 && stack.tailwindConfig) {
|
|
27472
29304
|
try {
|
|
27473
|
-
for (const t of tailwind3Colors(
|
|
29305
|
+
for (const t of tailwind3Colors(readFileSync6(join6(stack.root, stack.tailwindConfig), "utf8")))
|
|
27474
29306
|
index.add(t);
|
|
27475
29307
|
index.sources.push(stack.tailwindConfig);
|
|
27476
29308
|
} catch {}
|
|
@@ -27534,8 +29366,8 @@ var init_tokens2 = __esm(() => {
|
|
|
27534
29366
|
});
|
|
27535
29367
|
|
|
27536
29368
|
// src/project/plan.ts
|
|
27537
|
-
import { existsSync as existsSync4, mkdirSync as
|
|
27538
|
-
import { dirname as
|
|
29369
|
+
import { existsSync as existsSync4, mkdirSync as mkdirSync4, readFileSync as readFileSync7, writeFileSync as writeFileSync4 } from "node:fs";
|
|
29370
|
+
import { dirname as dirname3, join as join7, posix } from "node:path";
|
|
27539
29371
|
function planProjectExport(root, tree, assets, opts = {}) {
|
|
27540
29372
|
const stack = detectStack(root);
|
|
27541
29373
|
if (opts.framework)
|
|
@@ -27607,19 +29439,19 @@ function planProjectExport(root, tree, assets, opts = {}) {
|
|
|
27607
29439
|
}
|
|
27608
29440
|
function writePlan(root, files, overwrite) {
|
|
27609
29441
|
return files.map((f) => {
|
|
27610
|
-
const target =
|
|
29442
|
+
const target = join7(root, f.path);
|
|
27611
29443
|
const next = typeof f.content === "string" ? Buffer.from(f.content) : Buffer.from(f.content);
|
|
27612
29444
|
if (existsSync4(target)) {
|
|
27613
|
-
const before =
|
|
29445
|
+
const before = readFileSync7(target);
|
|
27614
29446
|
if (before.equals(next))
|
|
27615
29447
|
return { path: f.path, status: "unchanged" };
|
|
27616
29448
|
if (!overwrite)
|
|
27617
29449
|
return { path: f.path, status: "skipped (exists)" };
|
|
27618
|
-
|
|
29450
|
+
writeFileSync4(target, next);
|
|
27619
29451
|
return { path: f.path, status: "updated" };
|
|
27620
29452
|
}
|
|
27621
|
-
|
|
27622
|
-
|
|
29453
|
+
mkdirSync4(dirname3(target), { recursive: true });
|
|
29454
|
+
writeFileSync4(target, next);
|
|
27623
29455
|
return { path: f.path, status: "created" };
|
|
27624
29456
|
});
|
|
27625
29457
|
}
|
|
@@ -27688,7 +29520,7 @@ class FontIndex {
|
|
|
27688
29520
|
}
|
|
27689
29521
|
}
|
|
27690
29522
|
var GENERIC, STYLE_WEIGHTS;
|
|
27691
|
-
var
|
|
29523
|
+
var init_fonts2 = __esm(() => {
|
|
27692
29524
|
GENERIC = {
|
|
27693
29525
|
"sans-serif": ["Inter", "Roboto", "Arial", "Helvetica"],
|
|
27694
29526
|
"system-ui": ["Inter", "SF Pro Text", "Segoe UI", "Roboto", "Arial"],
|
|
@@ -27838,7 +29670,7 @@ function cssLiteral(v, value) {
|
|
|
27838
29670
|
if (typeof value === "boolean")
|
|
27839
29671
|
return null;
|
|
27840
29672
|
if (typeof value === "number")
|
|
27841
|
-
return unitless(v) ?
|
|
29673
|
+
return unitless(v) ? num2(value) : `${num2(value)}px`;
|
|
27842
29674
|
if (v.type === "string" && category(v) === "fontFamily")
|
|
27843
29675
|
return /^[\w-]+$/.test(String(value)) ? String(value) : quote(String(value));
|
|
27844
29676
|
if (v.type === "string")
|
|
@@ -27846,12 +29678,12 @@ function cssLiteral(v, value) {
|
|
|
27846
29678
|
return String(value);
|
|
27847
29679
|
}
|
|
27848
29680
|
function gradientCss(g) {
|
|
27849
|
-
const stops = g.gradient.map((s) => `${s.color} ${
|
|
29681
|
+
const stops = g.gradient.map((s) => `${s.color} ${num2(s.at * 100)}%`).join(", ");
|
|
27850
29682
|
if (g.type === "radial")
|
|
27851
29683
|
return `radial-gradient(circle, ${stops})`;
|
|
27852
29684
|
if (g.type)
|
|
27853
29685
|
return `conic-gradient(${stops})`;
|
|
27854
|
-
return `linear-gradient(${
|
|
29686
|
+
return `linear-gradient(${num2((g.angle ?? 0) + 90)}deg, ${stops})`;
|
|
27855
29687
|
}
|
|
27856
29688
|
function paintCss(value, ref) {
|
|
27857
29689
|
const one = (p) => typeof p === "string" ? p : p && p.alias ? ref(p.alias) : p && p.gradient ? gradientCss(p) : "transparent";
|
|
@@ -27860,7 +29692,7 @@ function paintCss(value, ref) {
|
|
|
27860
29692
|
return value.slice().reverse().map((p) => typeof p === "string" || p && p.alias ? `linear-gradient(${one(p)}, ${one(p)})` : one(p)).join(", ");
|
|
27861
29693
|
}
|
|
27862
29694
|
function shadowCss(effects, ref) {
|
|
27863
|
-
const list = effects.filter((e) => e.blur !== undefined && e.type === undefined).reverse().map((e) => `${e.inner ? "inset " : ""}${
|
|
29695
|
+
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
29696
|
return list.length ? list.join(", ") : null;
|
|
27865
29697
|
}
|
|
27866
29698
|
function textDecls(t, ref) {
|
|
@@ -27869,7 +29701,7 @@ function textDecls(t, ref) {
|
|
|
27869
29701
|
const d = [
|
|
27870
29702
|
["font-family", bound.fontFamily ? ref(bound.fontFamily) : `${/^[\w-]+$/.test(t.font) ? t.font : quote(t.font)}, sans-serif`],
|
|
27871
29703
|
["font-weight", bound.fontWeight ? ref(bound.fontWeight) : String(w.weight)],
|
|
27872
|
-
["font-size", bound.fontSize ? ref(bound.fontSize) : `${
|
|
29704
|
+
["font-size", bound.fontSize ? ref(bound.fontSize) : `${num2(t.size)}px`]
|
|
27873
29705
|
];
|
|
27874
29706
|
if (w.italic)
|
|
27875
29707
|
d.push(["font-style", "italic"]);
|
|
@@ -28146,7 +29978,7 @@ ${textDecls(t, ref).map(([k, v]) => ` ${k}: ${v};`).join(`
|
|
|
28146
29978
|
}
|
|
28147
29979
|
function tsLiteral(value) {
|
|
28148
29980
|
if (typeof value === "number")
|
|
28149
|
-
return
|
|
29981
|
+
return num2(value);
|
|
28150
29982
|
if (typeof value === "boolean")
|
|
28151
29983
|
return String(value);
|
|
28152
29984
|
return quote(String(value));
|
|
@@ -28266,7 +30098,7 @@ function dtcgType(v) {
|
|
|
28266
30098
|
function toDtcg(model) {
|
|
28267
30099
|
const out = {};
|
|
28268
30100
|
const dtcgRef = (a) => `{${[a.collection, ...a.name.split("/")].join(".")}}`;
|
|
28269
|
-
const literal = (v, x) => isAlias(x) ? dtcgRef(x.alias) : dtcgType(v) === "dimension" ? `${
|
|
30101
|
+
const literal = (v, x) => isAlias(x) ? dtcgRef(x.alias) : dtcgType(v) === "dimension" ? `${num2(Number(x))}px` : x;
|
|
28270
30102
|
for (const c of model.data.collections) {
|
|
28271
30103
|
const group = { $extensions: { "figma-bridge": { collection: true, modes: c.modes } } };
|
|
28272
30104
|
for (const v of c.variables) {
|
|
@@ -28297,9 +30129,9 @@ function toDtcg(model) {
|
|
|
28297
30129
|
}
|
|
28298
30130
|
for (const t of s.text) {
|
|
28299
30131
|
const w = styleWeight(t.style);
|
|
28300
|
-
const value = { fontFamily: t.font, fontWeight: w.weight, fontSize: `${
|
|
30132
|
+
const value = { fontFamily: t.font, fontWeight: w.weight, fontSize: `${num2(t.size)}px` };
|
|
28301
30133
|
if (t.lineHeight !== undefined)
|
|
28302
|
-
value.lineHeight = typeof t.lineHeight === "number" ? `${
|
|
30134
|
+
value.lineHeight = typeof t.lineHeight === "number" ? `${num2(t.lineHeight)}px` : t.lineHeight;
|
|
28303
30135
|
if (t.letterSpacing !== undefined)
|
|
28304
30136
|
value.letterSpacing = letterSpacingCss(t.letterSpacing);
|
|
28305
30137
|
const token = { $type: "typography", $value: value, $extensions: { "figma-bridge": { style: t.style } } };
|
|
@@ -28313,7 +30145,7 @@ function toDtcg(model) {
|
|
|
28313
30145
|
continue;
|
|
28314
30146
|
const token = {
|
|
28315
30147
|
$type: "shadow",
|
|
28316
|
-
$value: shadows.map((x) => ({ color: x.color, offsetX: `${
|
|
30148
|
+
$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
30149
|
};
|
|
28318
30150
|
if (e.description)
|
|
28319
30151
|
token.$description = e.description;
|
|
@@ -28360,9 +30192,9 @@ function exportTokenFiles(data, formats, opts = {}) {
|
|
|
28360
30192
|
}
|
|
28361
30193
|
return { files, warnings: [...data.warnings ?? [], ...model.warnings] };
|
|
28362
30194
|
}
|
|
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()),
|
|
30195
|
+
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
30196
|
var init_tokens_export = __esm(() => {
|
|
28365
|
-
|
|
30197
|
+
init_fonts2();
|
|
28366
30198
|
FORMATS = ["dtcg", "css", "tailwind", "tailwind4", "scss", "ts", "json"];
|
|
28367
30199
|
FILE_NAMES = {
|
|
28368
30200
|
dtcg: "tokens.json",
|
|
@@ -28920,6 +30752,8 @@ function snapshot(opts) {
|
|
|
28920
30752
|
}
|
|
28921
30753
|
|
|
28922
30754
|
// src/web/browser.ts
|
|
30755
|
+
import { existsSync as existsSync5 } from "node:fs";
|
|
30756
|
+
import { join as join8 } from "node:path";
|
|
28923
30757
|
import { pathToFileURL } from "node:url";
|
|
28924
30758
|
async function loadPlaywright() {
|
|
28925
30759
|
try {
|
|
@@ -28928,12 +30762,39 @@ async function loadPlaywright() {
|
|
|
28928
30762
|
throw new WebImportError("import_web needs the optional package playwright-core, which is not installed. Reinstall without --no-optional, or run `npm install -g playwright-core` (it uses your installed Chrome or Edge; no browser is downloaded).", "MISSING_DEPENDENCY");
|
|
28929
30763
|
}
|
|
28930
30764
|
}
|
|
30765
|
+
function otherChromiumBrowsers() {
|
|
30766
|
+
const env = process.env;
|
|
30767
|
+
const roots = process.platform === "win32" ? [env.ProgramFiles, env["ProgramFiles(x86)"], env.LOCALAPPDATA, "C:\\Program Files", "C:\\Program Files (x86)"].filter(Boolean) : [];
|
|
30768
|
+
const candidates = process.platform === "win32" ? [
|
|
30769
|
+
{ label: "Brave", paths: roots.map((r) => join8(r, "BraveSoftware", "Brave-Browser", "Application", "brave.exe")) },
|
|
30770
|
+
{ label: "Chromium", paths: roots.map((r) => join8(r, "Chromium", "Application", "chrome.exe")) },
|
|
30771
|
+
{ label: "Vivaldi", paths: roots.map((r) => join8(r, "Vivaldi", "Application", "vivaldi.exe")) },
|
|
30772
|
+
{ label: "Opera", paths: roots.map((r) => join8(r, "Programs", "Opera", "opera.exe")) }
|
|
30773
|
+
] : process.platform === "darwin" ? [
|
|
30774
|
+
{ label: "Brave", paths: ["/Applications/Brave Browser.app/Contents/MacOS/Brave Browser"] },
|
|
30775
|
+
{ label: "Chromium", paths: ["/Applications/Chromium.app/Contents/MacOS/Chromium"] },
|
|
30776
|
+
{ label: "Arc", paths: ["/Applications/Arc.app/Contents/MacOS/Arc"] }
|
|
30777
|
+
] : [
|
|
30778
|
+
{ label: "Brave", paths: ["/usr/bin/brave-browser", "/usr/bin/brave"] },
|
|
30779
|
+
{ label: "Chromium", paths: ["/usr/bin/chromium", "/usr/bin/chromium-browser", "/snap/bin/chromium"] }
|
|
30780
|
+
];
|
|
30781
|
+
const found = [];
|
|
30782
|
+
for (const c of candidates) {
|
|
30783
|
+
const path = c.paths.find((p) => existsSync5(p));
|
|
30784
|
+
if (path)
|
|
30785
|
+
found.push({ label: c.label, path });
|
|
30786
|
+
}
|
|
30787
|
+
return found;
|
|
30788
|
+
}
|
|
28931
30789
|
async function launchBrowser() {
|
|
28932
30790
|
const { chromium } = await loadPlaywright();
|
|
28933
30791
|
const tries = [];
|
|
28934
30792
|
if (process.env.FIGMA_BRIDGE_BROWSER)
|
|
28935
30793
|
tries.push({ label: process.env.FIGMA_BRIDGE_BROWSER, opts: { executablePath: process.env.FIGMA_BRIDGE_BROWSER } });
|
|
28936
|
-
tries.push({ label: "Chrome", opts: { channel: "chrome" } }, { label: "Edge", opts: { channel: "msedge" } }
|
|
30794
|
+
tries.push({ label: "Chrome", opts: { channel: "chrome" } }, { label: "Edge", opts: { channel: "msedge" } });
|
|
30795
|
+
for (const b of otherChromiumBrowsers())
|
|
30796
|
+
tries.push({ label: b.label, opts: { executablePath: b.path } });
|
|
30797
|
+
tries.push({ label: "Playwright Chromium", opts: {} });
|
|
28937
30798
|
const errors = [];
|
|
28938
30799
|
for (const t of tries) {
|
|
28939
30800
|
try {
|
|
@@ -28943,7 +30804,7 @@ async function launchBrowser() {
|
|
|
28943
30804
|
`)[0]}`);
|
|
28944
30805
|
}
|
|
28945
30806
|
}
|
|
28946
|
-
throw new WebImportError(`No browser found to render the page. Install
|
|
30807
|
+
throw new WebImportError(`No browser found to render the page. Install Chrome, Edge or Brave, or set FIGMA_BRIDGE_BROWSER to a Chromium-based browser. (${errors.join("; ")})`, "NO_BROWSER");
|
|
28947
30808
|
}
|
|
28948
30809
|
async function render(source, opts) {
|
|
28949
30810
|
const given = [source.url, source.html, source.path].filter((v) => v !== undefined).length;
|
|
@@ -29975,16 +31836,16 @@ var BUILD_LIMIT = 2800, isSvg = (bytes) => /^\s*(<\?xml[^>]*>\s*)?(<!--[\s\S]*?-
|
|
|
29975
31836
|
var init_import = __esm(() => {
|
|
29976
31837
|
init_browser();
|
|
29977
31838
|
init_convert();
|
|
29978
|
-
|
|
31839
|
+
init_fonts2();
|
|
29979
31840
|
});
|
|
29980
31841
|
|
|
29981
31842
|
// src/snippets.ts
|
|
29982
|
-
import { createHash } from "node:crypto";
|
|
29983
|
-
import { mkdirSync as
|
|
29984
|
-
import { homedir } from "node:os";
|
|
29985
|
-
import { join as
|
|
31843
|
+
import { createHash as createHash2 } from "node:crypto";
|
|
31844
|
+
import { mkdirSync as mkdirSync5, readdirSync as readdirSync2, readFileSync as readFileSync8, rmSync, statSync as statSync2, writeFileSync as writeFileSync5 } from "node:fs";
|
|
31845
|
+
import { homedir as homedir2 } from "node:os";
|
|
31846
|
+
import { join as join9 } from "node:path";
|
|
29986
31847
|
function ensureDir() {
|
|
29987
|
-
|
|
31848
|
+
mkdirSync5(SNIPPETS_DIR, { recursive: true });
|
|
29988
31849
|
}
|
|
29989
31850
|
function checkName(name) {
|
|
29990
31851
|
if (!NAME.test(name))
|
|
@@ -29999,14 +31860,14 @@ function files() {
|
|
|
29999
31860
|
}
|
|
30000
31861
|
function listSnippets() {
|
|
30001
31862
|
return files().map((f) => {
|
|
30002
|
-
const source =
|
|
31863
|
+
const source = readFileSync8(join9(SNIPPETS_DIR, f), "utf8");
|
|
30003
31864
|
return { name: f.slice(0, -3), description: header2(source, "description"), usage: header2(source, "usage") };
|
|
30004
31865
|
});
|
|
30005
31866
|
}
|
|
30006
31867
|
function getSnippet(name) {
|
|
30007
31868
|
checkName(name);
|
|
30008
31869
|
try {
|
|
30009
|
-
return
|
|
31870
|
+
return readFileSync8(join9(SNIPPETS_DIR, `${name}.js`), "utf8");
|
|
30010
31871
|
} catch {
|
|
30011
31872
|
throw new BridgeError(`No snippet named "${name}".`, "NOT_FOUND");
|
|
30012
31873
|
}
|
|
@@ -30019,37 +31880,37 @@ function saveSnippet(name, code, description = "", usage = "") {
|
|
|
30019
31880
|
// @usage ${usage.replace(/\n/g, " ") || `await lib.${name}(args)`}
|
|
30020
31881
|
${body}
|
|
30021
31882
|
`;
|
|
30022
|
-
const path =
|
|
30023
|
-
|
|
31883
|
+
const path = join9(SNIPPETS_DIR, `${name}.js`);
|
|
31884
|
+
writeFileSync5(path, text);
|
|
30024
31885
|
return path;
|
|
30025
31886
|
}
|
|
30026
31887
|
function deleteSnippet(name) {
|
|
30027
31888
|
checkName(name);
|
|
30028
|
-
rmSync(
|
|
31889
|
+
rmSync(join9(SNIPPETS_DIR, `${name}.js`), { force: true });
|
|
30029
31890
|
}
|
|
30030
31891
|
function loadLibrary() {
|
|
30031
31892
|
const list = files();
|
|
30032
|
-
const stamp = list.map((f) => `${f}:${statSync2(
|
|
31893
|
+
const stamp = list.map((f) => `${f}:${statSync2(join9(SNIPPETS_DIR, f)).mtimeMs}`).join("|");
|
|
30033
31894
|
if (cached2 && cached2.stamp === stamp)
|
|
30034
31895
|
return cached2;
|
|
30035
31896
|
const lib = {};
|
|
30036
31897
|
for (const f of list)
|
|
30037
|
-
lib[f.slice(0, -3)] =
|
|
30038
|
-
cached2 = { stamp, hash: stamp ?
|
|
31898
|
+
lib[f.slice(0, -3)] = readFileSync8(join9(SNIPPETS_DIR, f), "utf8");
|
|
31899
|
+
cached2 = { stamp, hash: stamp ? createHash2("sha1").update(stamp + JSON.stringify(lib)).digest("base64url").slice(0, 12) : "empty", lib };
|
|
30039
31900
|
return cached2;
|
|
30040
31901
|
}
|
|
30041
31902
|
var SNIPPETS_DIR, NAME, cached2 = null;
|
|
30042
31903
|
var init_snippets = __esm(() => {
|
|
30043
31904
|
init_bridge();
|
|
30044
|
-
SNIPPETS_DIR = process.env.FIGMA_BRIDGE_SNIPPETS ||
|
|
31905
|
+
SNIPPETS_DIR = process.env.FIGMA_BRIDGE_SNIPPETS || join9(homedir2(), ".figma-bridge", "snippets");
|
|
30045
31906
|
NAME = /^[A-Za-z_][A-Za-z0-9_]{0,63}$/;
|
|
30046
31907
|
});
|
|
30047
31908
|
|
|
30048
31909
|
// src/server.ts
|
|
30049
|
-
import { appendFileSync, mkdirSync as
|
|
31910
|
+
import { appendFileSync, mkdirSync as mkdirSync6, statSync as statSync3, truncateSync, writeFileSync as writeFileSync6 } from "node:fs";
|
|
30050
31911
|
import { mkdir, readFile, stat, writeFile } from "node:fs/promises";
|
|
30051
|
-
import { tmpdir } from "node:os";
|
|
30052
|
-
import { dirname as
|
|
31912
|
+
import { homedir as homedir3, tmpdir } from "node:os";
|
|
31913
|
+
import { dirname as dirname4, join as join10 } from "node:path";
|
|
30053
31914
|
import { fileURLToPath } from "node:url";
|
|
30054
31915
|
function log(line) {
|
|
30055
31916
|
const text = `[${new Date().toISOString().slice(11, 23)}] ${line}`;
|
|
@@ -30140,6 +32001,12 @@ async function prepareSpec(spec, defaultColor) {
|
|
|
30140
32001
|
return;
|
|
30141
32002
|
if (Array.isArray(node))
|
|
30142
32003
|
return node.forEach(walk);
|
|
32004
|
+
if (String(node.type ?? "").toLowerCase() === "diagram") {
|
|
32005
|
+
const expanded = diagramSpec(node);
|
|
32006
|
+
for (const k of Object.keys(node))
|
|
32007
|
+
delete node[k];
|
|
32008
|
+
Object.assign(node, expanded);
|
|
32009
|
+
}
|
|
30143
32010
|
if (node.icon && !node.svg) {
|
|
30144
32011
|
const size = typeof node.size === "number" ? node.size : 24;
|
|
30145
32012
|
jobs.push(iconSvg(String(node.icon), size, typeof node.color === "string" ? node.color : defaultColor).then((svg) => void (node.svg = svg)));
|
|
@@ -30164,6 +32031,8 @@ async function prepareSpec(spec, defaultColor) {
|
|
|
30164
32031
|
node.children.forEach(walk);
|
|
30165
32032
|
if (Array.isArray(node.variants))
|
|
30166
32033
|
node.variants.forEach(walk);
|
|
32034
|
+
if (Array.isArray(node.slides))
|
|
32035
|
+
node.slides.forEach(walk);
|
|
30167
32036
|
if (node.base)
|
|
30168
32037
|
walk(node.base);
|
|
30169
32038
|
};
|
|
@@ -30175,7 +32044,7 @@ async function writeIfChanged(path, content) {
|
|
|
30175
32044
|
const before = await readFile(path, "utf8").catch(() => null);
|
|
30176
32045
|
if (before === content)
|
|
30177
32046
|
return "unchanged";
|
|
30178
|
-
await mkdir(
|
|
32047
|
+
await mkdir(dirname4(path), { recursive: true });
|
|
30179
32048
|
await writeFile(path, content);
|
|
30180
32049
|
return before === null ? "created" : "updated";
|
|
30181
32050
|
}
|
|
@@ -30189,7 +32058,46 @@ function renameAssets(n, names) {
|
|
|
30189
32058
|
out.children = n.children.map((c) => renameAssets(c, names));
|
|
30190
32059
|
return out;
|
|
30191
32060
|
}
|
|
30192
|
-
|
|
32061
|
+
async function restContext(fileUrl) {
|
|
32062
|
+
const token = process.env.FIGMA_TOKEN;
|
|
32063
|
+
if (!token)
|
|
32064
|
+
throw new BridgeError(TOKEN_HELP, "NO_TOKEN");
|
|
32065
|
+
const session = await bridge.current().catch(() => null);
|
|
32066
|
+
let key = fileUrl ? fileKeyFrom(fileUrl) : null;
|
|
32067
|
+
if (fileUrl && !key)
|
|
32068
|
+
throw new BridgeError(`Not a Figma file link or key: ${fileUrl}`, "BAD_ARGS");
|
|
32069
|
+
key ??= session?.fileKey ?? null;
|
|
32070
|
+
if (!key) {
|
|
32071
|
+
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");
|
|
32072
|
+
}
|
|
32073
|
+
return { rest: new FigmaRest(token), key, session: session?.fileKey === key ? session : null };
|
|
32074
|
+
}
|
|
32075
|
+
async function layerNames(session, ids) {
|
|
32076
|
+
if (!session || !ids.length)
|
|
32077
|
+
return {};
|
|
32078
|
+
return bridge.request("node_info", { ids: [...new Set(ids)] }, 20000).catch(() => ({}));
|
|
32079
|
+
}
|
|
32080
|
+
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.
|
|
32081
|
+
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.
|
|
32082
|
+
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.
|
|
32083
|
+
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.
|
|
32084
|
+
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.
|
|
32085
|
+
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.
|
|
32086
|
+
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.
|
|
32087
|
+
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].
|
|
32088
|
+
EFFECTS: radius (n | [tl,tr,br,bl] | "var:x"), opacity, shadow (true | {x,y,blur,spread,color} | [...] | "style:Name"), blur, backgroundBlur, rotation, visible.
|
|
32089
|
+
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…>"}.
|
|
32090
|
+
INSTANCE: {component:"Button" | node id | library key, props:{Variant:"Primary", Label:"Buy"}, text:{"Label layer name":"Buy"}}.
|
|
32091
|
+
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.
|
|
32092
|
+
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.
|
|
32093
|
+
PROTOTYPE: reactions:[{trigger:"click", action:"navigate", to:"Details", transition:"smart", duration:300}] on any layer (see the prototype tool).
|
|
32094
|
+
SECTION: {type:"section", name, children (placed with x/y), w?, h?} (wraps its children when no size).
|
|
32095
|
+
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"}.
|
|
32096
|
+
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).
|
|
32097
|
+
SLIDES files: {slides:[{name, fill?, children:[…]}, …]} or {type:"slide", …}: one 1920×1080 slide per entry, children placed with x/y or auto-layout.
|
|
32098
|
+
gap/padding/radius accept "var:<number variable>". MODES: modes:{"Theme":"Dark"} sets a collection's variable mode on a frame and its children.
|
|
32099
|
+
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"}]}]}
|
|
32100
|
+
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
32101
|
var init_server3 = __esm(async () => {
|
|
30194
32102
|
init_mcp();
|
|
30195
32103
|
init_stdio2();
|
|
@@ -30198,46 +32106,81 @@ var init_server3 = __esm(async () => {
|
|
|
30198
32106
|
init_icons();
|
|
30199
32107
|
init_image();
|
|
30200
32108
|
init_codegen();
|
|
32109
|
+
init_diagram();
|
|
32110
|
+
init_guides();
|
|
32111
|
+
init_export();
|
|
32112
|
+
init_rest();
|
|
30201
32113
|
init_plan();
|
|
30202
32114
|
init_tokens();
|
|
30203
32115
|
init_tokens_export();
|
|
30204
32116
|
init_import();
|
|
32117
|
+
init_setup();
|
|
30205
32118
|
init_snippets();
|
|
30206
32119
|
init_package();
|
|
30207
32120
|
PORT = Number(process.env.FIGMA_BRIDGE_PORT) || 3055;
|
|
30208
32121
|
CHANNEL = process.env.FIGMA_BRIDGE_CHANNEL || "default";
|
|
30209
|
-
OUT_DIR = process.env.FIGMA_BRIDGE_OUT ||
|
|
30210
|
-
LOG_FILE =
|
|
32122
|
+
OUT_DIR = process.env.FIGMA_BRIDGE_OUT || join10(tmpdir(), "figma-bridge");
|
|
32123
|
+
LOG_FILE = join10(OUT_DIR, "bridge.log");
|
|
30211
32124
|
MAX_IMAGE_BYTES = 50 << 20;
|
|
30212
|
-
|
|
32125
|
+
mkdirSync6(OUT_DIR, { recursive: true });
|
|
30213
32126
|
try {
|
|
30214
32127
|
if (statSync3(LOG_FILE).size > 5 << 20)
|
|
30215
32128
|
truncateSync(LOG_FILE, 0);
|
|
30216
32129
|
} catch {}
|
|
30217
32130
|
bridge = new Bridge({ port: PORT, channel: CHANNEL, version, log });
|
|
30218
32131
|
bridge.start();
|
|
32132
|
+
if (process.env.FIGMA_BRIDGE_PLUGIN_SYNC !== "0") {
|
|
32133
|
+
try {
|
|
32134
|
+
const target = process.env.FIGMA_BRIDGE_PLUGIN_DIR || join10(homedir3(), ".figma-bridge", "plugin");
|
|
32135
|
+
const status = syncPlugin(join10(dirname4(fileURLToPath(import.meta.url)), "..", "plugin"), target, version);
|
|
32136
|
+
if (status === "installed" || status === "updated")
|
|
32137
|
+
log(`Figma plugin ${status} to v${version} in ${target}; reopen it in Figma`);
|
|
32138
|
+
} catch (e) {
|
|
32139
|
+
log(`Could not update the Figma plugin copy: ${e.message}`);
|
|
32140
|
+
}
|
|
32141
|
+
}
|
|
32142
|
+
INSTRUCTIONS = [
|
|
32143
|
+
"Figma Bridge drives the user's Figma desktop app through a local plugin. No rate limits: iterate freely.",
|
|
32144
|
+
"Workflow:",
|
|
32145
|
+
"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.",
|
|
32146
|
+
"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.",
|
|
32147
|
+
"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.",
|
|
32148
|
+
"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.",
|
|
32149
|
+
"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.",
|
|
32150
|
+
"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.",
|
|
32151
|
+
"Existing website or HTML → import_web turns it into editable auto-layout frames (one per viewport); then compare against the returned reference screenshot.",
|
|
32152
|
+
"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.",
|
|
32153
|
+
"run_script: `figma` global, top-level await, `return` a small JSON value (nodes come back as {id,name,type}).",
|
|
32154
|
+
"Script helpers: utils.loadFonts('Inter:Bold', …), utils.node(id), utils.page(name), utils.hex('#hex'), utils.solid('#hex', opacity?),",
|
|
32155
|
+
"utils.build(spec, {parentId}), utils.describe(nodeOrId, depth); lib.<name>(args) runs a saved snippet; console.log is returned as `logs`.",
|
|
32156
|
+
"Save helpers you will reuse with the snippets tool. Icons: search_icons, then insert_icon or {icon:'set:name'} in build.",
|
|
32157
|
+
"Rules: load fonts before editing text; pages load on demand (await figma.setCurrentPageAsync / getNodeByIdAsync); keep results small.",
|
|
32158
|
+
"The user can cancel a command from the plugin (code CANCELLED): build stops, a script may still finish, so check the file before retrying.",
|
|
32159
|
+
"With FIGMA_TOKEN set: comments reads and posts file comments pinned to layers, versions lists the history and diffs a version against now.",
|
|
32160
|
+
"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.",
|
|
32161
|
+
"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.",
|
|
32162
|
+
"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."
|
|
32163
|
+
];
|
|
30219
32164
|
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(`
|
|
32165
|
+
instructions: INSTRUCTIONS.join(`
|
|
30239
32166
|
`)
|
|
30240
32167
|
});
|
|
32168
|
+
SESSION_TOOLS = new Set(["list_sessions", "select_session"]);
|
|
32169
|
+
registerTool = server.registerTool.bind(server);
|
|
32170
|
+
toolDocs = [];
|
|
32171
|
+
server.registerTool = (name, config, handler) => {
|
|
32172
|
+
toolDocs.push({ name, title: config.title, description: config.description ?? "" });
|
|
32173
|
+
if (SESSION_TOOLS.has(name))
|
|
32174
|
+
return registerTool(name, config, handler);
|
|
32175
|
+
const inputSchema = {
|
|
32176
|
+
...config.inputSchema,
|
|
32177
|
+
file: string2().optional().describe("Target file name or session id when several files are connected (default: the selected file)")
|
|
32178
|
+
};
|
|
32179
|
+
return registerTool(name, { ...config, inputSchema }, (args, extra) => {
|
|
32180
|
+
const { file, ...rest } = args ?? {};
|
|
32181
|
+
return callTarget.run({ file: typeof file === "string" && file ? file : undefined }, () => handler(rest, extra));
|
|
32182
|
+
});
|
|
32183
|
+
};
|
|
30241
32184
|
server.registerTool("run_script", {
|
|
30242
32185
|
title: "Run Figma Plugin API script",
|
|
30243
32186
|
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 +32196,7 @@ var init_server3 = __esm(async () => {
|
|
|
30253
32196
|
}));
|
|
30254
32197
|
server.registerTool("build", {
|
|
30255
32198
|
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.`,
|
|
32199
|
+
description: BUILD_DESCRIPTION,
|
|
30273
32200
|
inputSchema: {
|
|
30274
32201
|
spec: object2({}).passthrough().describe("Root node: {name, layout, children:[...], ...}. Several roots: wrap them in a frame or call build again."),
|
|
30275
32202
|
parentId: string2().optional().describe("Parent frame (default: current page)"),
|
|
@@ -30356,7 +32283,7 @@ ${f.content}`).join(`
|
|
|
30356
32283
|
const single = files.length === 1 && /\.[a-z0-9]+$/i.test(args.path);
|
|
30357
32284
|
const written = [];
|
|
30358
32285
|
for (const f of files) {
|
|
30359
|
-
const target = single ? args.path :
|
|
32286
|
+
const target = single ? args.path : join10(args.path, f.path);
|
|
30360
32287
|
written.push({ format: f.format, path: target, status: await writeIfChanged(target, f.content) });
|
|
30361
32288
|
}
|
|
30362
32289
|
return ok({ counts, files: written, ...warnings.length ? { warnings } : {} });
|
|
@@ -30394,7 +32321,7 @@ ${f.content}`).join(`
|
|
|
30394
32321
|
const bytes = Buffer.from(r.bytesB64, "base64");
|
|
30395
32322
|
const info = imageInfo(bytes);
|
|
30396
32323
|
const ext = r.format === "JPG" ? "jpg" : "png";
|
|
30397
|
-
const path =
|
|
32324
|
+
const path = join10(OUT_DIR, `${safeName(r.name)}-${safeName(r.nodeId)}-${Date.now()}.${ext}`);
|
|
30398
32325
|
await writeFile(path, bytes);
|
|
30399
32326
|
const meta = { path, nodeId: r.nodeId, name: r.name, width: info?.width, height: info?.height, bytes: bytes.length };
|
|
30400
32327
|
const content = [{ type: "text", text: json(meta) }];
|
|
@@ -30421,7 +32348,7 @@ ${f.content}`).join(`
|
|
|
30421
32348
|
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
32349
|
const pct = (v) => Math.round(v * 1e4) / 100;
|
|
30423
32350
|
const toLayer = (v, axis) => Math.round((v / shot.scale + shot.offset[axis]) * 10) / 10;
|
|
30424
|
-
const heatmapPath =
|
|
32351
|
+
const heatmapPath = join10(OUT_DIR, `compare-${safeName(shot.name)}-${Date.now()}.png`);
|
|
30425
32352
|
await writeFile(heatmapPath, Buffer.from(diff.heatmap, "base64"));
|
|
30426
32353
|
const refRatio = diff.reference.width / diff.reference.height;
|
|
30427
32354
|
const ratio = diff.width / diff.height;
|
|
@@ -30495,7 +32422,7 @@ Example: {"url":"https://example.com","viewports":[1440,390]}`,
|
|
|
30495
32422
|
const built = await bridge.request("build", { spec: v.spec, images, parentId: args.parentId, x, y, select: false, label: "Web import" }, 120000);
|
|
30496
32423
|
let reference;
|
|
30497
32424
|
if (v.screenshot) {
|
|
30498
|
-
reference =
|
|
32425
|
+
reference = join10(OUT_DIR, `import-${safeName(v.title || "page")}-${v.viewport}-${Date.now()}.png`);
|
|
30499
32426
|
await writeFile(reference, v.screenshot);
|
|
30500
32427
|
}
|
|
30501
32428
|
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 +32492,7 @@ Links are added to existing interactions unless replace:true; clear:[ids] remove
|
|
|
30565
32492
|
}
|
|
30566
32493
|
}, ({ nodeId, projectPath, framework, styling, name, outDir, write, overwrite }) => track("export_code", `${nodeId ?? "(selection)"} ${projectPath ?? `${framework ?? "html"}/${styling ?? "css"}`}`, async () => {
|
|
30567
32494
|
const r = await bridge.request("export_tree", { nodeId }, 120000);
|
|
30568
|
-
const dir =
|
|
32495
|
+
const dir = join10(OUT_DIR, `export-${safeName(r.tree.name)}-${Date.now()}`);
|
|
30569
32496
|
const finalName = {};
|
|
30570
32497
|
const assetBytes = {};
|
|
30571
32498
|
for (const [file, a] of Object.entries(r.assets)) {
|
|
@@ -30581,8 +32508,8 @@ Links are added to existing interactions unless replace:true; clear:[ids] remove
|
|
|
30581
32508
|
if (projectPath) {
|
|
30582
32509
|
const plan = planProjectExport(projectPath, tree, assetBytes, { framework, styling, name, outDir });
|
|
30583
32510
|
for (const f of plan.files) {
|
|
30584
|
-
|
|
30585
|
-
|
|
32511
|
+
mkdirSync6(dirname4(join10(dir, f.path)), { recursive: true });
|
|
32512
|
+
writeFileSync6(join10(dir, f.path), typeof f.content === "string" ? f.content : Buffer.from(f.content));
|
|
30586
32513
|
}
|
|
30587
32514
|
const written = write ? writePlan(projectPath, plan.files, !!overwrite) : null;
|
|
30588
32515
|
const s = plan.stack;
|
|
@@ -30607,13 +32534,13 @@ ${f.content}`).join(`
|
|
|
30607
32534
|
… truncated: the full code is in ${dir}`;
|
|
30608
32535
|
return { content: [{ type: "text", text: json(meta) }, { type: "text", text }] };
|
|
30609
32536
|
}
|
|
30610
|
-
|
|
32537
|
+
mkdirSync6(join10(dir, "assets"), { recursive: true });
|
|
30611
32538
|
for (const [file, bytes] of Object.entries(assetBytes))
|
|
30612
|
-
|
|
32539
|
+
writeFileSync6(join10(dir, "assets", file), bytes);
|
|
30613
32540
|
const fw = framework ?? "html";
|
|
30614
32541
|
const code = generateCode(tree, { framework: fw, styling: styling ?? "css", assetPath: (f) => `assets/${f}` });
|
|
30615
32542
|
for (const f of code.files)
|
|
30616
|
-
|
|
32543
|
+
writeFileSync6(join10(dir, f.path), f.content);
|
|
30617
32544
|
warnings.push(...code.warnings);
|
|
30618
32545
|
const meta = {
|
|
30619
32546
|
dir,
|
|
@@ -30762,9 +32689,119 @@ ${f.content}`).join(`
|
|
|
30762
32689
|
return ok({ deleted: args.name });
|
|
30763
32690
|
}
|
|
30764
32691
|
}));
|
|
32692
|
+
server.registerTool("export_roblox", {
|
|
32693
|
+
title: "Export a frame to Roblox UI",
|
|
32694
|
+
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.
|
|
32695
|
+
` + "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.
|
|
32696
|
+
` + "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"}',
|
|
32697
|
+
inputSchema: {
|
|
32698
|
+
nodeId: string2().optional(),
|
|
32699
|
+
mode: _enum(["scale", "offset", "hybrid"]).optional().describe("Default hybrid"),
|
|
32700
|
+
targetResolution: array(number2().int().min(100).max(8000)).length(2).optional().describe("Screen size the UI is designed for, default [1920, 1080]"),
|
|
32701
|
+
rasterize: _enum(["auto", "none", "all"]).optional().describe("auto (default): pictures only where Roblox can't draw it; none: approximate instead; all: every styled layer"),
|
|
32702
|
+
outDir: string2().optional().describe("Folder for the files (default: a new temp folder)"),
|
|
32703
|
+
upload: boolean2().optional().describe("Upload the pictures through Open Cloud (needs ROBLOX_API_KEY and ROBLOX_CREATOR_ID)"),
|
|
32704
|
+
asRootFrame: boolean2().optional().describe("Root is a Frame instead of a ScreenGui"),
|
|
32705
|
+
textScaled: boolean2().optional().describe("TextScaled on labels (UITextSizeConstraint keeps the design size as the maximum)"),
|
|
32706
|
+
fonts: object2({}).passthrough().optional().describe('Figma family → Roblox family or rbxasset URL, e.g. {"Inter":"GothamSSm"}'),
|
|
32707
|
+
parent: string2().optional().describe('Luau expression for the parent, default game:GetService("StarterGui")')
|
|
32708
|
+
}
|
|
32709
|
+
}, (args) => track("export_roblox", `${args.nodeId ?? "(selection)"} ${args.mode ?? "hybrid"}`, async () => {
|
|
32710
|
+
const r = await exportRoblox({ ...args, targetResolution: args.targetResolution, fonts: args.fonts }, { request: (m, p, t) => bridge.request(m, p, t), outDir: OUT_DIR });
|
|
32711
|
+
let luau = r.luau;
|
|
32712
|
+
if (luau.length > MAX_OUTPUT_CHARS)
|
|
32713
|
+
luau = luau.slice(0, MAX_OUTPUT_CHARS) + `
|
|
32714
|
+
-- … truncated: the full script is in ${r.result.luau}`;
|
|
32715
|
+
return { content: [{ type: "text", text: json(r.result) }, { type: "text", text: luau }] };
|
|
32716
|
+
}));
|
|
32717
|
+
server.registerTool("comments", {
|
|
32718
|
+
title: "Read and write file comments",
|
|
32719
|
+
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"}',
|
|
32720
|
+
inputSchema: {
|
|
32721
|
+
action: _enum(["list", "post", "reply", "resolve", "delete"]).optional().describe("Default list"),
|
|
32722
|
+
fileUrl: string2().optional().describe("File link or key (default: the connected file)"),
|
|
32723
|
+
nodeId: string2().optional().describe("post: layer to pin the comment to"),
|
|
32724
|
+
x: number2().optional().describe("post: offset inside the layer, px"),
|
|
32725
|
+
y: number2().optional(),
|
|
32726
|
+
message: string2().max(5000).optional(),
|
|
32727
|
+
commentId: string2().optional().describe("reply, resolve, delete"),
|
|
32728
|
+
includeResolved: boolean2().optional().describe("list: include resolved threads")
|
|
32729
|
+
}
|
|
32730
|
+
}, (args) => track("comments", `${args.action ?? "list"} ${args.commentId ?? args.nodeId ?? ""}`, async () => {
|
|
32731
|
+
const { rest, key, session } = await restContext(args.fileUrl);
|
|
32732
|
+
const action = args.action ?? "list";
|
|
32733
|
+
const need = (v, what) => {
|
|
32734
|
+
if (!v)
|
|
32735
|
+
throw new BridgeError(`\`${what}\` is required for ${action}.`, "BAD_ARGS");
|
|
32736
|
+
return v;
|
|
32737
|
+
};
|
|
32738
|
+
if (action === "list") {
|
|
32739
|
+
const threads = threadComments(await rest.comments(key), !!args.includeResolved);
|
|
32740
|
+
const names = await layerNames(session, threads.map((t) => t.nodeId).filter((x) => !!x));
|
|
32741
|
+
return ok({
|
|
32742
|
+
fileKey: key,
|
|
32743
|
+
open: threads.filter((t) => !t.resolvedAt).length,
|
|
32744
|
+
threads: threads.map((t) => t.nodeId && names[t.nodeId] ? { ...t, layer: names[t.nodeId] } : t)
|
|
32745
|
+
});
|
|
32746
|
+
}
|
|
32747
|
+
if (action === "post") {
|
|
32748
|
+
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 });
|
|
32749
|
+
return ok({ posted: c.id, nodeId: args.nodeId ?? null });
|
|
32750
|
+
}
|
|
32751
|
+
if (action === "reply") {
|
|
32752
|
+
const c = await rest.postComment(key, need(args.message, "message"), { replyTo: need(args.commentId, "commentId") });
|
|
32753
|
+
return ok({ replied: c.id, to: args.commentId });
|
|
32754
|
+
}
|
|
32755
|
+
if (action === "resolve") {
|
|
32756
|
+
const c = await rest.postComment(key, args.message ?? "✓ Resolved", { replyTo: need(args.commentId, "commentId") });
|
|
32757
|
+
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." });
|
|
32758
|
+
}
|
|
32759
|
+
await rest.deleteComment(key, need(args.commentId, "commentId"));
|
|
32760
|
+
return ok({ deleted: args.commentId });
|
|
32761
|
+
}));
|
|
32762
|
+
server.registerTool("versions", {
|
|
32763
|
+
title: "File versions and what changed",
|
|
32764
|
+
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"}',
|
|
32765
|
+
inputSchema: {
|
|
32766
|
+
action: _enum(["list", "diff"]).optional().describe("Default list"),
|
|
32767
|
+
fileUrl: string2().optional().describe("File link or key (default: the connected file)"),
|
|
32768
|
+
versionId: string2().optional().describe("diff: the older version"),
|
|
32769
|
+
toVersionId: string2().optional().describe("diff: the newer version (default: now)"),
|
|
32770
|
+
nodeId: string2().optional().describe("diff: only this layer and its children"),
|
|
32771
|
+
limit: number2().int().min(1).max(200).optional().describe("list: max versions, default 30; diff: max items per list, default 200")
|
|
32772
|
+
}
|
|
32773
|
+
}, (args) => track("versions", `${args.action ?? "list"} ${args.versionId ?? ""}`, async () => {
|
|
32774
|
+
const { rest, key, session } = await restContext(args.fileUrl);
|
|
32775
|
+
if ((args.action ?? "list") === "list") {
|
|
32776
|
+
const list = await rest.versions(key, args.limit ?? 30);
|
|
32777
|
+
return ok({
|
|
32778
|
+
fileKey: key,
|
|
32779
|
+
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 }))
|
|
32780
|
+
});
|
|
32781
|
+
}
|
|
32782
|
+
if (!args.versionId)
|
|
32783
|
+
throw new BridgeError('`versionId` is required for diff: call versions {action:"list"} first.', "BAD_ARGS");
|
|
32784
|
+
let ids;
|
|
32785
|
+
if (args.nodeId)
|
|
32786
|
+
ids = [args.nodeId];
|
|
32787
|
+
else if (session?.pageId)
|
|
32788
|
+
ids = [session.pageId];
|
|
32789
|
+
else
|
|
32790
|
+
ids = [...new Set([...await rest.pages(key, args.versionId), ...await rest.pages(key, args.toVersionId)].map((p) => p.id))];
|
|
32791
|
+
const [before, after] = await Promise.all([rest.nodes(key, ids, args.versionId), rest.nodes(key, ids, args.toVersionId)]);
|
|
32792
|
+
const empty = (id, name) => ({ id, name, type: "PAGE", children: [] });
|
|
32793
|
+
const results = ids.map((id) => {
|
|
32794
|
+
const b = before[id];
|
|
32795
|
+
const a = after[id];
|
|
32796
|
+
const name = (a ?? b)?.name ?? id;
|
|
32797
|
+
const d = diffTrees(b ?? empty(id, name), a ?? empty(id, name), args.limit ?? 200);
|
|
32798
|
+
return { id, name, ...b ? {} : { note: "did not exist in the older version" }, ...a ? {} : { note: "no longer exists" }, ...d };
|
|
32799
|
+
});
|
|
32800
|
+
return ok({ fileKey: key, from: args.versionId, to: args.toVersionId ?? "now", scopes: results });
|
|
32801
|
+
}));
|
|
30765
32802
|
server.registerTool("list_sessions", {
|
|
30766
32803
|
title: "List connected Figma files",
|
|
30767
|
-
description: "Figma files that currently have the plugin open, plus bridge status.
|
|
32804
|
+
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
32805
|
inputSchema: {}
|
|
30769
32806
|
}, () => track("list_sessions", "", async () => {
|
|
30770
32807
|
const sessions = await bridge.sessions().catch(() => []);
|
|
@@ -30776,6 +32813,7 @@ ${f.content}`).join(`
|
|
|
30776
32813
|
fileName: s.fileName,
|
|
30777
32814
|
page: s.page,
|
|
30778
32815
|
pluginVersion: s.version,
|
|
32816
|
+
editor: s.editorType,
|
|
30779
32817
|
...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
32818
|
connectedSeconds: Math.round((Date.now() - s.connectedAt) / 1000),
|
|
30781
32819
|
selected: s.id === selected || undefined
|
|
@@ -30791,6 +32829,7 @@ ${f.content}`).join(`
|
|
|
30791
32829
|
const s = await bridge.select(name);
|
|
30792
32830
|
return ok({ selected: { id: s.id, fileName: s.fileName, page: s.page } });
|
|
30793
32831
|
}));
|
|
32832
|
+
registerGuides(server, { instructions: INSTRUCTIONS, buildSpec: BUILD_DESCRIPTION, tools: () => toolDocs });
|
|
30794
32833
|
await server.connect(new StdioServerTransport);
|
|
30795
32834
|
log(`Figma Bridge MCP ${version} ready (port ${PORT}, channel ${CHANNEL}, files in ${OUT_DIR})`);
|
|
30796
32835
|
process.stdin.on("close", shutdown);
|
|
@@ -30799,154 +32838,20 @@ ${f.content}`).join(`
|
|
|
30799
32838
|
});
|
|
30800
32839
|
|
|
30801
32840
|
// src/cli.ts
|
|
30802
|
-
|
|
30803
|
-
import { dirname as dirname4, join as join9 } from "node:path";
|
|
30804
|
-
import { fileURLToPath as fileURLToPath2 } from "node:url";
|
|
30805
|
-
|
|
30806
|
-
// src/setup.ts
|
|
30807
|
-
import { copyFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
30808
|
-
import { dirname, join } from "node:path";
|
|
30809
|
-
var SERVER_NAME = "FigmaBridge";
|
|
30810
|
-
function clientList(home, appData) {
|
|
30811
|
-
return [
|
|
30812
|
-
{ id: "claude-code", label: "Claude Code", files: [join(home, ".claude.json")], format: "json", extra: { type: "stdio" } },
|
|
30813
|
-
{ id: "claude-desktop", label: "Claude Desktop", files: [join(appData, "Claude", "claude_desktop_config.json")], format: "json" },
|
|
30814
|
-
{ id: "codex", label: "Codex", files: [join(home, ".codex", "config.toml")], format: "toml" },
|
|
30815
|
-
{
|
|
30816
|
-
id: "antigravity",
|
|
30817
|
-
label: "Antigravity",
|
|
30818
|
-
files: [join(home, ".gemini", "antigravity", "mcp_config.json"), join(home, ".gemini", "config", "mcp_config.json")],
|
|
30819
|
-
format: "json"
|
|
30820
|
-
},
|
|
30821
|
-
{ id: "gemini-cli", label: "Gemini CLI", files: [join(home, ".gemini", "settings.json")], format: "json" },
|
|
30822
|
-
{ id: "cursor", label: "Cursor", files: [join(home, ".cursor", "mcp.json")], format: "json" },
|
|
30823
|
-
{ id: "windsurf", label: "Windsurf", files: [join(home, ".codeium", "windsurf", "mcp_config.json")], format: "json" }
|
|
30824
|
-
];
|
|
30825
|
-
}
|
|
30826
|
-
function backup(path, text) {
|
|
30827
|
-
writeFileSync(path + ".bak-figma-bridge", text);
|
|
30828
|
-
}
|
|
30829
|
-
function writeJson(path, entry) {
|
|
30830
|
-
const text = existsSync(path) ? readFileSync(path, "utf8") : "";
|
|
30831
|
-
const config = text.trim() ? JSON.parse(text) : {};
|
|
30832
|
-
if (text.trim())
|
|
30833
|
-
backup(path, text);
|
|
30834
|
-
config.mcpServers = { ...config.mcpServers, [SERVER_NAME]: entry };
|
|
30835
|
-
mkdirSync(dirname(path), { recursive: true });
|
|
30836
|
-
writeFileSync(path, JSON.stringify(config, null, 2) + `
|
|
30837
|
-
`);
|
|
30838
|
-
}
|
|
30839
|
-
function tomlBlock(command, args) {
|
|
30840
|
-
return [
|
|
30841
|
-
`[mcp_servers.${SERVER_NAME}]`,
|
|
30842
|
-
`command = '${command}'`,
|
|
30843
|
-
`args = [${args.map((a) => `'${a}'`).join(", ")}]`,
|
|
30844
|
-
"startup_timeout_sec = 60",
|
|
30845
|
-
"tool_timeout_sec = 120"
|
|
30846
|
-
].join(`
|
|
30847
|
-
`);
|
|
30848
|
-
}
|
|
30849
|
-
function writeToml(path, command, args) {
|
|
30850
|
-
const text = existsSync(path) ? readFileSync(path, "utf8") : "";
|
|
30851
|
-
if (text.trim())
|
|
30852
|
-
backup(path, text);
|
|
30853
|
-
const kept = [];
|
|
30854
|
-
let skipping = false;
|
|
30855
|
-
for (const line of text.split(/\r?\n/)) {
|
|
30856
|
-
const header = /^\s*\[([^\]]+)\]\s*$/.exec(line);
|
|
30857
|
-
if (header)
|
|
30858
|
-
skipping = header[1] === `mcp_servers.${SERVER_NAME}` || header[1].startsWith(`mcp_servers.${SERVER_NAME}.`);
|
|
30859
|
-
if (!skipping)
|
|
30860
|
-
kept.push(line);
|
|
30861
|
-
}
|
|
30862
|
-
const body = kept.join(`
|
|
30863
|
-
`).trimEnd();
|
|
30864
|
-
mkdirSync(dirname(path), { recursive: true });
|
|
30865
|
-
writeFileSync(path, (body ? body + `
|
|
30866
|
-
|
|
30867
|
-
` : "") + tomlBlock(command, args) + `
|
|
30868
|
-
`);
|
|
30869
|
-
}
|
|
30870
|
-
function runSetup(opts) {
|
|
30871
|
-
const all = clientList(opts.home, opts.appData);
|
|
30872
|
-
const forced = opts.clients ?? [];
|
|
30873
|
-
const unknown = forced.filter((id) => !all.some((c) => c.id === id));
|
|
30874
|
-
if (unknown.length)
|
|
30875
|
-
throw new Error(`Unknown client(s): ${unknown.join(", ")}. Known: ${all.map((c) => c.id).join(", ")}`);
|
|
30876
|
-
const result = { updated: [], failed: [], missing: [] };
|
|
30877
|
-
for (const client of all) {
|
|
30878
|
-
const detected = client.files.filter((f) => existsSync(dirname(f)) && (client.id !== "gemini-cli" || existsSync(f)));
|
|
30879
|
-
const targets = forced.length ? forced.includes(client.id) ? detected.length ? detected : [client.files[0]] : [] : detected;
|
|
30880
|
-
if (!targets.length) {
|
|
30881
|
-
if (!forced.length)
|
|
30882
|
-
result.missing.push(client.label);
|
|
30883
|
-
continue;
|
|
30884
|
-
}
|
|
30885
|
-
for (const file of targets) {
|
|
30886
|
-
try {
|
|
30887
|
-
if (client.format === "toml")
|
|
30888
|
-
writeToml(file, opts.command, opts.args);
|
|
30889
|
-
else
|
|
30890
|
-
writeJson(file, { ...client.extra, command: opts.command, args: opts.args });
|
|
30891
|
-
result.updated.push({ client: client.id, label: client.label, file });
|
|
30892
|
-
} catch (e) {
|
|
30893
|
-
result.failed.push({ client: client.id, label: client.label, file, error: e.message });
|
|
30894
|
-
}
|
|
30895
|
-
}
|
|
30896
|
-
}
|
|
30897
|
-
return result;
|
|
30898
|
-
}
|
|
30899
|
-
function installPlugin(from, to) {
|
|
30900
|
-
mkdirSync(to, { recursive: true });
|
|
30901
|
-
for (const f of ["manifest.json", "code.js", "ui.html"])
|
|
30902
|
-
copyFileSync(join(from, f), join(to, f));
|
|
30903
|
-
return join(to, "manifest.json");
|
|
30904
|
-
}
|
|
30905
|
-
var color = (code) => (s) => process.stdout.isTTY ? `\x1B[${code}m${s}\x1B[0m` : s;
|
|
30906
|
-
var bold = color(1);
|
|
30907
|
-
var green = color(32);
|
|
30908
|
-
var dim = color(2);
|
|
30909
|
-
var yellow = color(33);
|
|
30910
|
-
function printSetup(r, rerun) {
|
|
30911
|
-
for (const label of r.missing)
|
|
30912
|
-
console.log(dim(` – ${label.padEnd(15)} not found`));
|
|
30913
|
-
for (const u of r.updated)
|
|
30914
|
-
console.log(green(` ✔ ${u.label.padEnd(15)}`) + dim(u.file));
|
|
30915
|
-
for (const f of r.failed)
|
|
30916
|
-
console.log(yellow(` ! ${f.label.padEnd(15)}could not update ${f.file}: ${f.error}`));
|
|
30917
|
-
if (!r.updated.length)
|
|
30918
|
-
console.log(yellow(`
|
|
30919
|
-
No AI client found. Use \`${rerun} --client <name>\` or \`${rerun} --print\` for manual config.`));
|
|
30920
|
-
}
|
|
30921
|
-
function printSnippets(command, args) {
|
|
30922
|
-
console.log(bold("JSON clients") + dim(" (Claude, Antigravity, Gemini CLI, Cursor, Windsurf…)"));
|
|
30923
|
-
console.log(JSON.stringify({ mcpServers: { [SERVER_NAME]: { command, args } } }, null, 2));
|
|
30924
|
-
console.log(bold(`
|
|
30925
|
-
Codex`) + dim(" (~/.codex/config.toml)"));
|
|
30926
|
-
console.log(tomlBlock(command, args) + `
|
|
30927
|
-
`);
|
|
30928
|
-
}
|
|
30929
|
-
function parseClients(argv) {
|
|
30930
|
-
const out = [];
|
|
30931
|
-
argv.forEach((a, i) => {
|
|
30932
|
-
if (a === "--client" && argv[i + 1])
|
|
30933
|
-
out.push(...argv[i + 1].split(","));
|
|
30934
|
-
else if (a.startsWith("--client="))
|
|
30935
|
-
out.push(...a.slice(9).split(","));
|
|
30936
|
-
});
|
|
30937
|
-
return out;
|
|
30938
|
-
}
|
|
30939
|
-
|
|
30940
|
-
// src/cli.ts
|
|
32841
|
+
init_setup();
|
|
30941
32842
|
init_package();
|
|
32843
|
+
import { homedir as homedir4 } from "node:os";
|
|
32844
|
+
import { dirname as dirname5, join as join11 } from "node:path";
|
|
32845
|
+
import { fileURLToPath as fileURLToPath2 } from "node:url";
|
|
30942
32846
|
var PACKAGE = "@clovis500c/figma-bridge";
|
|
30943
32847
|
var [cmd, ...rest] = process.argv.slice(2);
|
|
30944
|
-
var home =
|
|
30945
|
-
var pluginSource =
|
|
30946
|
-
var pluginTarget =
|
|
30947
|
-
var
|
|
32848
|
+
var home = homedir4();
|
|
32849
|
+
var pluginSource = join11(dirname5(fileURLToPath2(import.meta.url)), "..", "plugin");
|
|
32850
|
+
var pluginTarget = join11(home, ".figma-bridge", "plugin");
|
|
32851
|
+
var spec = `${PACKAGE}@latest`;
|
|
32852
|
+
var npx = process.platform === "win32" ? { command: "cmd", args: ["/c", "npx", "-y", spec] } : { command: "npx", args: ["-y", spec] };
|
|
30948
32853
|
function plugin() {
|
|
30949
|
-
const manifest = installPlugin(pluginSource, pluginTarget);
|
|
32854
|
+
const manifest = installPlugin(pluginSource, pluginTarget, version);
|
|
30950
32855
|
console.log(`${bold("Figma plugin")} copied to ${dim(pluginTarget)}`);
|
|
30951
32856
|
return manifest;
|
|
30952
32857
|
}
|
|
@@ -30955,7 +32860,7 @@ function next(manifest) {
|
|
|
30955
32860
|
${bold("Next")}
|
|
30956
32861
|
1. Figma desktop → Plugins → Development → Import plugin from manifest…
|
|
30957
32862
|
${green(manifest)}
|
|
30958
|
-
(once
|
|
32863
|
+
(once: updates install themselves when your AI client restarts; then reopen the plugin)
|
|
30959
32864
|
2. Restart your AI client, then run Plugins → Development → Figma Bridge in a Figma file.
|
|
30960
32865
|
`);
|
|
30961
32866
|
}
|
|
@@ -30973,7 +32878,7 @@ Figma Bridge setup
|
|
|
30973
32878
|
break;
|
|
30974
32879
|
}
|
|
30975
32880
|
try {
|
|
30976
|
-
const appData = process.env.APPDATA ||
|
|
32881
|
+
const appData = process.env.APPDATA || join11(home, "AppData", "Roaming");
|
|
30977
32882
|
printSetup(runSetup({ home, appData, command: npx.command, args: npx.args, clients: parseClients(rest) }), `npx ${PACKAGE} setup`);
|
|
30978
32883
|
} catch (e) {
|
|
30979
32884
|
console.log(yellow(e.message));
|