@clovis500c/figma-bridge 1.12.0 → 1.14.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 +7 -6
- package/dist/server.js +686 -282
- package/package.json +3 -2
- package/plugin/code.js +330 -53
- package/plugin/ui.html +17 -2
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.14.0";
|
|
51
218
|
var init_package = () => {};
|
|
52
219
|
|
|
53
220
|
// node_modules/zod/v3/helpers/util.js
|
|
@@ -27221,13 +27388,13 @@ Turn ${opt(a.target, "the selected frame")} into code for the project at ${opt(a
|
|
|
27221
27388
|
name: "figma-to-roblox",
|
|
27222
27389
|
title: "Figma to Roblox",
|
|
27223
27390
|
description: "Turn a frame into Roblox UI and place it in Studio with a Roblox Studio MCP.",
|
|
27224
|
-
args: { target: "Layer name or id (default: the selection)", mode: "scale, offset or hybrid
|
|
27391
|
+
args: { target: "Layer name or id (default: the selection)", mode: "scale (default), offset or hybrid" },
|
|
27225
27392
|
render: (a) => `
|
|
27226
27393
|
Turn ${opt(a.target, "the selected frame")} into Roblox UI that looks the same, and put it in the open Roblox Studio place.
|
|
27227
|
-
1. export_roblox {${a.target ? `nodeId:"<id of ${a.target}>", ` : ""}mode:"${opt(a.mode, "
|
|
27394
|
+
1. export_roblox {${a.target ? `nodeId:"<id of ${a.target}>", ` : ""}mode:"${opt(a.mode, "scale")}"}. Read the warnings (approximations, font substitutions).
|
|
27228
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.
|
|
27229
27396
|
3. Run the Luau with execute_luau: it creates the ScreenGui under StarterGui (replacing an earlier copy) and returns it.
|
|
27230
|
-
4. Take a Studio screenshot and compare it with screenshot {returnImage:true} of the Figma frame. Fix differences in the Luau (or in Figma, then export again).
|
|
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), keeping every size, position, padding and gap in scale (no offsets) and the PascalCase names.
|
|
27231
27398
|
5. Report the result and anything Roblox could not reproduce.`
|
|
27232
27399
|
},
|
|
27233
27400
|
{
|
|
@@ -27246,6 +27413,218 @@ Audit and improve this file${a.target ? ` (fixes limited to ${a.target})` : ""}.
|
|
|
27246
27413
|
];
|
|
27247
27414
|
});
|
|
27248
27415
|
|
|
27416
|
+
// plugin/lib/naming.ts
|
|
27417
|
+
function isGenericName(n) {
|
|
27418
|
+
const name = n.name.trim();
|
|
27419
|
+
if (!name || GENERIC_NAME.test(name))
|
|
27420
|
+
return true;
|
|
27421
|
+
if (n.kind === "text" && n.text !== undefined) {
|
|
27422
|
+
const t = n.text.trim();
|
|
27423
|
+
const a = name.toLowerCase();
|
|
27424
|
+
const b = t.toLowerCase();
|
|
27425
|
+
return a === b || a.length >= 12 && b.indexOf(a) === 0;
|
|
27426
|
+
}
|
|
27427
|
+
return false;
|
|
27428
|
+
}
|
|
27429
|
+
function visibleKids(n) {
|
|
27430
|
+
return n.children.filter(function(c) {
|
|
27431
|
+
return !c.hidden;
|
|
27432
|
+
});
|
|
27433
|
+
}
|
|
27434
|
+
function maxFont(n) {
|
|
27435
|
+
let m = n.kind === "text" && n.fontSize ? n.fontSize : 0;
|
|
27436
|
+
for (let i = 0;i < n.children.length; i++) {
|
|
27437
|
+
const c = n.children[i];
|
|
27438
|
+
if (c && !c.hidden)
|
|
27439
|
+
m = Math.max(m, maxFont(c));
|
|
27440
|
+
}
|
|
27441
|
+
return m;
|
|
27442
|
+
}
|
|
27443
|
+
function onlyKinds(list, kinds) {
|
|
27444
|
+
for (let i = 0;i < list.length; i++) {
|
|
27445
|
+
const c = list[i];
|
|
27446
|
+
if (c && kinds.indexOf(c.kind) === -1)
|
|
27447
|
+
return false;
|
|
27448
|
+
}
|
|
27449
|
+
return list.length > 0;
|
|
27450
|
+
}
|
|
27451
|
+
function buttonLike(n) {
|
|
27452
|
+
const kids = visibleKids(n);
|
|
27453
|
+
if (!n.background || n.h > 72 || kids.length === 0 || kids.length > 3)
|
|
27454
|
+
return false;
|
|
27455
|
+
let texts = 0;
|
|
27456
|
+
for (let i = 0;i < kids.length; i++) {
|
|
27457
|
+
const c = kids[i];
|
|
27458
|
+
if (!c)
|
|
27459
|
+
continue;
|
|
27460
|
+
if (c.kind === "text") {
|
|
27461
|
+
texts++;
|
|
27462
|
+
if ((c.text || "").length > 32)
|
|
27463
|
+
return false;
|
|
27464
|
+
} else if (c.kind !== "vector" && c.kind !== "image")
|
|
27465
|
+
return false;
|
|
27466
|
+
}
|
|
27467
|
+
return texts === 1;
|
|
27468
|
+
}
|
|
27469
|
+
function isScreen(n) {
|
|
27470
|
+
return SCREEN_W.indexOf(Math.round(n.w)) !== -1 && n.h >= 640 || n.w >= 1024 && n.h >= 600;
|
|
27471
|
+
}
|
|
27472
|
+
function largestText(n, parent) {
|
|
27473
|
+
const others = parent ? visibleKids(parent).filter(function(c) {
|
|
27474
|
+
return c !== n && c.kind === "text";
|
|
27475
|
+
}) : [];
|
|
27476
|
+
const size = n.fontSize || 14;
|
|
27477
|
+
return others.length > 0 && others.every(function(c) {
|
|
27478
|
+
return (c.fontSize || 14) < size;
|
|
27479
|
+
});
|
|
27480
|
+
}
|
|
27481
|
+
function covers(n, p) {
|
|
27482
|
+
return Math.abs(n.x) <= 1 && Math.abs(n.y) <= 1 && n.w >= p.w - 1 && n.h >= p.h - 1;
|
|
27483
|
+
}
|
|
27484
|
+
function roleOf(n, parent, root, top, inButton) {
|
|
27485
|
+
const kids = visibleKids(n);
|
|
27486
|
+
if (n.kind === "text") {
|
|
27487
|
+
const t = (n.text || "").trim();
|
|
27488
|
+
const size = n.fontSize || 14;
|
|
27489
|
+
if (inButton)
|
|
27490
|
+
return "Label";
|
|
27491
|
+
if (NUMERIC.test(t))
|
|
27492
|
+
return "Value";
|
|
27493
|
+
if (size >= top - 0.5 && size >= 18)
|
|
27494
|
+
return "Title";
|
|
27495
|
+
if (size >= 16 && largestText(n, parent))
|
|
27496
|
+
return "Title";
|
|
27497
|
+
if (size >= 20)
|
|
27498
|
+
return "Heading";
|
|
27499
|
+
if (t.length > 60)
|
|
27500
|
+
return "Description";
|
|
27501
|
+
if (size <= 12)
|
|
27502
|
+
return "Caption";
|
|
27503
|
+
if (t.length > 30)
|
|
27504
|
+
return "Description";
|
|
27505
|
+
return "Label";
|
|
27506
|
+
}
|
|
27507
|
+
if (n.kind === "vector")
|
|
27508
|
+
return "Icon";
|
|
27509
|
+
if (n.kind === "image" && kids.length === 0) {
|
|
27510
|
+
const round = n.ellipse || n.radius !== undefined && Math.abs(n.w - n.h) <= 1 && n.radius >= n.w / 2 - 1;
|
|
27511
|
+
return round ? "Avatar" : "Image";
|
|
27512
|
+
}
|
|
27513
|
+
if (kids.length === 0) {
|
|
27514
|
+
if (Math.min(n.w, n.h) <= 2 && Math.max(n.w, n.h) > 8)
|
|
27515
|
+
return "Divider";
|
|
27516
|
+
if (parent && covers(n, parent))
|
|
27517
|
+
return "Background";
|
|
27518
|
+
if (n.clickable)
|
|
27519
|
+
return "Button";
|
|
27520
|
+
if (n.ellipse)
|
|
27521
|
+
return n.w <= 16 ? "Dot" : "Circle";
|
|
27522
|
+
return "Shape";
|
|
27523
|
+
}
|
|
27524
|
+
if (n.clickable)
|
|
27525
|
+
return "Button";
|
|
27526
|
+
if (buttonLike(n)) {
|
|
27527
|
+
const label = kids.filter(function(c) {
|
|
27528
|
+
return c.kind === "text";
|
|
27529
|
+
})[0];
|
|
27530
|
+
return n.h <= 24 || label && (label.fontSize || 14) <= 12 ? "Badge" : "Button";
|
|
27531
|
+
}
|
|
27532
|
+
if (!parent) {
|
|
27533
|
+
if (isScreen(n))
|
|
27534
|
+
return "Screen";
|
|
27535
|
+
} else if (parent === root && isScreen(root)) {
|
|
27536
|
+
if (n.w >= root.w * 0.9 && n.y <= root.h * 0.1 && n.h <= root.h * 0.25)
|
|
27537
|
+
return "Header";
|
|
27538
|
+
if (n.w >= root.w * 0.9 && n.y + n.h >= root.h * 0.9 && n.h <= root.h * 0.25)
|
|
27539
|
+
return "Footer";
|
|
27540
|
+
if (n.h >= root.h * 0.9 && n.w <= root.w * 0.35 && n.x <= 1)
|
|
27541
|
+
return "Sidebar";
|
|
27542
|
+
}
|
|
27543
|
+
const flow = kids.filter(function(c) {
|
|
27544
|
+
return !c.absolute;
|
|
27545
|
+
});
|
|
27546
|
+
if (flow.length >= 2) {
|
|
27547
|
+
const r = baseName(flow[0]);
|
|
27548
|
+
const same = flow.every(function(c) {
|
|
27549
|
+
return baseName(c) === r;
|
|
27550
|
+
});
|
|
27551
|
+
if (same && r && r !== "Label" && r !== "Value" && r !== "Divider") {
|
|
27552
|
+
if (r === "Button")
|
|
27553
|
+
return "Actions";
|
|
27554
|
+
return r + (n.layout === "GRID" || n.wrap ? "Grid" : "List");
|
|
27555
|
+
}
|
|
27556
|
+
}
|
|
27557
|
+
if (n.background)
|
|
27558
|
+
return n.radius ? "Card" : "Panel";
|
|
27559
|
+
if (onlyKinds(kids, ["text"]))
|
|
27560
|
+
return "TextGroup";
|
|
27561
|
+
if (n.layout === "HORIZONTAL")
|
|
27562
|
+
return "Row";
|
|
27563
|
+
if (n.layout === "GRID")
|
|
27564
|
+
return "Grid";
|
|
27565
|
+
return "Container";
|
|
27566
|
+
}
|
|
27567
|
+
function baseName(n) {
|
|
27568
|
+
return pascalName(n.role || n.name).replace(/\d+$/, "");
|
|
27569
|
+
}
|
|
27570
|
+
function inferRoles(root) {
|
|
27571
|
+
const top = maxFont(root);
|
|
27572
|
+
const walk = function(n, parent, inButton) {
|
|
27573
|
+
const button = n.kind !== "text" && (n.clickable || buttonLike(n) || /button|btn/i.test(n.name));
|
|
27574
|
+
for (let i = 0;i < n.children.length; i++) {
|
|
27575
|
+
const c = n.children[i];
|
|
27576
|
+
if (c)
|
|
27577
|
+
walk(c, n, inButton || button);
|
|
27578
|
+
}
|
|
27579
|
+
if (isGenericName(n))
|
|
27580
|
+
n.role = roleOf(n, parent, root, top, inButton);
|
|
27581
|
+
};
|
|
27582
|
+
walk(root, null, false);
|
|
27583
|
+
}
|
|
27584
|
+
function pascalName(name) {
|
|
27585
|
+
let segs = name.split("/").map(function(s) {
|
|
27586
|
+
return s.replace(/^[\w-]+:/, "").trim();
|
|
27587
|
+
}).filter(function(s) {
|
|
27588
|
+
return s && !/^default$/i.test(s);
|
|
27589
|
+
});
|
|
27590
|
+
if (segs.length > 1)
|
|
27591
|
+
segs = segs.slice(1).concat([segs[0]]);
|
|
27592
|
+
const words = segs.join(" ").normalize("NFKD").replace(/[̀-ͯ]/g, "").replace(/([a-z0-9])([A-Z])/g, "$1 $2").split(/[^A-Za-z0-9]+/).filter(Boolean);
|
|
27593
|
+
const out = words.map(function(w) {
|
|
27594
|
+
return w === w.toUpperCase() && w.length <= 4 ? w : w.charAt(0).toUpperCase() + w.slice(1).toLowerCase();
|
|
27595
|
+
}).join("");
|
|
27596
|
+
return out.slice(0, 48);
|
|
27597
|
+
}
|
|
27598
|
+
function endsWithWord(name, words) {
|
|
27599
|
+
for (let i = 0;i < words.length; i++) {
|
|
27600
|
+
const w = words[i];
|
|
27601
|
+
if (name.length >= w.length && name.slice(-w.length) === w)
|
|
27602
|
+
return true;
|
|
27603
|
+
}
|
|
27604
|
+
return false;
|
|
27605
|
+
}
|
|
27606
|
+
function uniqueSiblingNames(names) {
|
|
27607
|
+
const counts = {};
|
|
27608
|
+
for (let i = 0;i < names.length; i++) {
|
|
27609
|
+
const k = names[i];
|
|
27610
|
+
counts[k] = (counts[k] || 0) + 1;
|
|
27611
|
+
}
|
|
27612
|
+
const seen = {};
|
|
27613
|
+
return names.map(function(k) {
|
|
27614
|
+
if ((counts[k] || 0) < 2)
|
|
27615
|
+
return k;
|
|
27616
|
+
seen[k] = (seen[k] || 0) + 1;
|
|
27617
|
+
return k + seen[k];
|
|
27618
|
+
});
|
|
27619
|
+
}
|
|
27620
|
+
var GENERIC_NAME, NUMERIC, SCREEN_W, ROLE_WORDS;
|
|
27621
|
+
var init_naming = __esm(() => {
|
|
27622
|
+
GENERIC_NAME = /^(frame|rectangle|ellipse|group|vector|text|line|polygon|star|component|instance|section|image|auto ?layout|union|subtract|intersect|exclude|boolean|layer|shape|slice|mask|container|wrapper|div|span|box)(\s*\d+)?(\s+copy(\s*\d+)?)?$/i;
|
|
27623
|
+
NUMERIC = /^[\s$€£¥+\-−]*[\d][\d\s.,:%/×x+\-−]*[\s$€£¥%kKmMbB]*$/;
|
|
27624
|
+
SCREEN_W = [360, 375, 390, 393, 402, 412, 414, 428, 430, 440];
|
|
27625
|
+
ROLE_WORDS = ("Actions Avatar Background Badge Banner Bar Body Button Card Column Container Content Controls Details Dialog Divider Footer Frame Grid Group Gui " + "Header Hero Hud HUD Info Item List Menu Modal Nav Navbar Navigation Overlay Panel Popup Row Screen Section Sidebar Slot Stack Stats Tab Tabs Tile " + "Toolbar Tooltip Window Wrapper").split(" ");
|
|
27626
|
+
});
|
|
27627
|
+
|
|
27249
27628
|
// src/roblox/fonts.ts
|
|
27250
27629
|
function robloxFamily(family, overrides = {}) {
|
|
27251
27630
|
const key = family.trim().toLowerCase();
|
|
@@ -27350,26 +27729,24 @@ function visible(list) {
|
|
|
27350
27729
|
function decide(n, opts) {
|
|
27351
27730
|
const approximations = [];
|
|
27352
27731
|
if (n.text)
|
|
27353
|
-
return { kind: "text",
|
|
27732
|
+
return { kind: "text", approximations };
|
|
27354
27733
|
const raster = opts.rasterize;
|
|
27355
27734
|
const fills = visible(n.fills);
|
|
27356
27735
|
const strokes = visible(n.strokes);
|
|
27357
|
-
const
|
|
27358
|
-
const drop = effects.filter((e) => e.type === "DROP_SHADOW");
|
|
27359
|
-
const other = effects.filter((e) => e.type !== "DROP_SHADOW");
|
|
27736
|
+
const other = (n.effects ?? []).filter((e) => e.type !== "DROP_SHADOW");
|
|
27360
27737
|
const hasChildren = !!n.children?.some((c) => !c.hidden);
|
|
27361
|
-
if (n.vector ||
|
|
27362
|
-
return raster === "none" ? { kind: "frame",
|
|
27738
|
+
if (n.vector || VECTOR_TYPES.includes(n.type)) {
|
|
27739
|
+
return raster === "none" ? { kind: "frame", approximations: ["vector shape skipped (rasterize is none)"] } : { kind: "picture", approximations };
|
|
27363
27740
|
}
|
|
27364
27741
|
const image = fills.length === 1 && fills[0].type === "IMAGE";
|
|
27365
|
-
if (image && !hasChildren && !strokes.length && !
|
|
27366
|
-
return { kind: "image",
|
|
27742
|
+
if (image && !hasChildren && !strokes.length && !other.length && raster !== "all")
|
|
27743
|
+
return { kind: "image", approximations };
|
|
27367
27744
|
const reasons = [];
|
|
27368
27745
|
if (fills.length > 1)
|
|
27369
27746
|
reasons.push("several fills");
|
|
27370
27747
|
if (fills.length === 1 && !["SOLID", "GRADIENT_LINEAR", "IMAGE"].includes(fills[0].type))
|
|
27371
27748
|
reasons.push(`${fills[0].type.toLowerCase().replace("_", " ")} fill`);
|
|
27372
|
-
if (image && (strokes.length ||
|
|
27749
|
+
if (image && (strokes.length || other.length))
|
|
27373
27750
|
reasons.push("image with stroke or effects");
|
|
27374
27751
|
if (strokes.length > 1 || strokes[0] && strokes[0].type !== "SOLID")
|
|
27375
27752
|
reasons.push("complex stroke");
|
|
@@ -27379,18 +27756,16 @@ function decide(n, opts) {
|
|
|
27379
27756
|
reasons.push(other.map((e) => e.type.toLowerCase().replace(/_/g, " ")).join(", "));
|
|
27380
27757
|
if (n.type === "ELLIPSE" && Math.abs(n.w - n.h) > 0.5)
|
|
27381
27758
|
reasons.push("oval");
|
|
27382
|
-
const styled = fills.length + strokes.length + effects.length > 0;
|
|
27759
|
+
const styled = fills.length + strokes.length + (n.effects ?? []).length > 0;
|
|
27383
27760
|
if ((reasons.length || raster === "all" && styled) && raster !== "none") {
|
|
27384
|
-
return { kind: hasChildren ? "panel" : "picture",
|
|
27761
|
+
return { kind: hasChildren ? "panel" : "picture", approximations };
|
|
27385
27762
|
}
|
|
27386
27763
|
if (reasons.length)
|
|
27387
27764
|
approximations.push(`${reasons.join(", ")}: approximated (rasterize is none)`);
|
|
27388
27765
|
const r = n.radius ?? [];
|
|
27389
27766
|
if (r.length && r.some((v) => Math.abs(v - r[0]) > 0.5))
|
|
27390
27767
|
approximations.push("different corner radii: used the largest");
|
|
27391
|
-
|
|
27392
|
-
approximations.push("drop shadow skipped (rasterize is none)");
|
|
27393
|
-
return { kind: "frame", shadow: drop.length > 0 && raster !== "none", approximations };
|
|
27768
|
+
return { kind: "frame", approximations };
|
|
27394
27769
|
}
|
|
27395
27770
|
function pictureRequests(root, opts) {
|
|
27396
27771
|
const out = [];
|
|
@@ -27402,8 +27777,6 @@ function pictureRequests(root, opts) {
|
|
|
27402
27777
|
out.push({ id: n.id, mode: "full" });
|
|
27403
27778
|
if (d.kind === "panel")
|
|
27404
27779
|
out.push({ id: n.id, mode: "panel" });
|
|
27405
|
-
if (d.shadow)
|
|
27406
|
-
out.push({ id: n.id, mode: "shadow" });
|
|
27407
27780
|
if (d.kind !== "picture")
|
|
27408
27781
|
for (const c of n.children ?? [])
|
|
27409
27782
|
walk(c);
|
|
@@ -27423,6 +27796,56 @@ function imageHashes(root) {
|
|
|
27423
27796
|
walk(root);
|
|
27424
27797
|
return [...out];
|
|
27425
27798
|
}
|
|
27799
|
+
function robloxName(base, flavor) {
|
|
27800
|
+
switch (flavor) {
|
|
27801
|
+
case "text":
|
|
27802
|
+
return /Label$/.test(base) ? base : `${base}Label`;
|
|
27803
|
+
case "button": {
|
|
27804
|
+
const b = base.replace(/Btn$/, "Button");
|
|
27805
|
+
return /Button$/.test(b) ? b : `${b}Button`;
|
|
27806
|
+
}
|
|
27807
|
+
case "icon":
|
|
27808
|
+
return endsWithWord(base, ICON_WORDS) ? base : `${base}Icon`;
|
|
27809
|
+
case "image":
|
|
27810
|
+
return endsWithWord(base, IMAGE_WORDS) ? base : `${base}Image`;
|
|
27811
|
+
default:
|
|
27812
|
+
return endsWithWord(base, ROLE_WORDS) ? base : `${base}${flavor === "frame" ? "Frame" : "Container"}`;
|
|
27813
|
+
}
|
|
27814
|
+
}
|
|
27815
|
+
function roleTree(n) {
|
|
27816
|
+
const fills = visible(n.fills);
|
|
27817
|
+
const kind = n.text ? "text" : n.vector || VECTOR_TYPES.includes(n.type) ? "vector" : fills.length === 1 && fills[0].type === "IMAGE" && !n.children?.length ? "image" : n.children?.length || n.layout ? "frame" : "shape";
|
|
27818
|
+
return {
|
|
27819
|
+
id: n.id,
|
|
27820
|
+
kind,
|
|
27821
|
+
name: n.name,
|
|
27822
|
+
x: n.x,
|
|
27823
|
+
y: n.y,
|
|
27824
|
+
w: n.w,
|
|
27825
|
+
h: n.h,
|
|
27826
|
+
hidden: n.hidden,
|
|
27827
|
+
text: n.text?.characters,
|
|
27828
|
+
fontSize: n.text ? Math.max(0, ...n.text.segments.map((s) => s.size)) : undefined,
|
|
27829
|
+
layout: n.layout?.mode,
|
|
27830
|
+
wrap: n.layout?.wrap,
|
|
27831
|
+
background: fills.length > 0 || visible(n.strokes).length > 0,
|
|
27832
|
+
radius: Math.max(0, ...n.radius ?? []),
|
|
27833
|
+
ellipse: n.type === "ELLIPSE",
|
|
27834
|
+
clickable: n.clickable,
|
|
27835
|
+
absolute: n.absolute,
|
|
27836
|
+
children: (n.children ?? []).map(roleTree)
|
|
27837
|
+
};
|
|
27838
|
+
}
|
|
27839
|
+
function dedupe(i) {
|
|
27840
|
+
const layers = i.children.filter((c) => !/^UI/.test(c.className));
|
|
27841
|
+
const names = uniqueSiblingNames(layers.map((c) => c.name));
|
|
27842
|
+
layers.forEach((c, k) => {
|
|
27843
|
+
c.name = names[k];
|
|
27844
|
+
c.props[0] = ["Name", S(c.name)];
|
|
27845
|
+
});
|
|
27846
|
+
for (const c of i.children)
|
|
27847
|
+
dedupe(c);
|
|
27848
|
+
}
|
|
27426
27849
|
|
|
27427
27850
|
class Mapper {
|
|
27428
27851
|
opts;
|
|
@@ -27431,48 +27854,82 @@ class Mapper {
|
|
|
27431
27854
|
fontSubstitutions = new Map;
|
|
27432
27855
|
counts = { instances: 0, pictures: 0, skippedHidden: 0 };
|
|
27433
27856
|
textFactor = 1;
|
|
27857
|
+
names = new Map;
|
|
27858
|
+
roles = new Map;
|
|
27434
27859
|
constructor(opts, assets) {
|
|
27435
27860
|
this.opts = opts;
|
|
27436
27861
|
this.assets = assets;
|
|
27437
27862
|
}
|
|
27863
|
+
get scaleOnly() {
|
|
27864
|
+
return this.opts.mode === "scale";
|
|
27865
|
+
}
|
|
27438
27866
|
warn(n, msg) {
|
|
27439
27867
|
if (this.warnings.size < 60)
|
|
27440
27868
|
this.warnings.add(n ? `${n.name}: ${msg}` : msg);
|
|
27441
27869
|
}
|
|
27870
|
+
nameLayers(root) {
|
|
27871
|
+
const tree = roleTree(root);
|
|
27872
|
+
inferRoles(tree);
|
|
27873
|
+
const walk = (r) => {
|
|
27874
|
+
const base = pascalName(r.role ?? r.name) || pascalName(r.role ?? "") || "Layer";
|
|
27875
|
+
this.names.set(r.id, base);
|
|
27876
|
+
if (r.role)
|
|
27877
|
+
this.roles.set(r.id, r.role);
|
|
27878
|
+
for (const c of r.children)
|
|
27879
|
+
walk(c);
|
|
27880
|
+
};
|
|
27881
|
+
walk(tree);
|
|
27882
|
+
}
|
|
27883
|
+
label(n, flavor) {
|
|
27884
|
+
return robloxName(this.names.get(n.id) ?? (pascalName(n.name) || "Layer"), flavor);
|
|
27885
|
+
}
|
|
27886
|
+
isButton(n) {
|
|
27887
|
+
return !!n.clickable || BUTTON_NAME.test(n.name) || this.roles.get(n.id) === "Button";
|
|
27888
|
+
}
|
|
27442
27889
|
map(root) {
|
|
27890
|
+
this.nameLayers(root);
|
|
27443
27891
|
const [tw, th] = this.opts.targetResolution;
|
|
27444
27892
|
const fullScreen = Math.abs(root.w / root.h - tw / th) < 0.1 * (tw / th) && root.w >= tw * 0.4;
|
|
27445
27893
|
if (fullScreen && this.opts.mode !== "offset")
|
|
27446
27894
|
this.textFactor = tw / root.w;
|
|
27447
27895
|
const top = this.node(root, null, 0);
|
|
27448
27896
|
const geo = this.opts.mode === "offset" ? [["AnchorPoint", V2(0.5, 0.5)], ["Position", U2(0.5, 0, 0.5, 0)], ["Size", U2(0, root.w, 0, root.h)]] : fullScreen ? [["AnchorPoint", V2(0.5, 0.5)], ["Position", U2(0.5, 0, 0.5, 0)], ["Size", U2(1, 0, 1, 0)]] : [["AnchorPoint", V2(0.5, 0.5)], ["Position", U2(0.5, 0, 0.5, 0)], ["Size", U2(root.w / tw, 0, root.h / th, 0)]];
|
|
27449
|
-
top.props = top.props.filter(([k]) => !["AnchorPoint", "Position", "Size", "LayoutOrder", "ZIndex"].includes(k));
|
|
27897
|
+
top.props = top.props.filter(([k]) => !["AnchorPoint", "Position", "Size", "LayoutOrder", "ZIndex", "AutomaticSize"].includes(k));
|
|
27450
27898
|
top.props.splice(1, 0, ...geo);
|
|
27451
|
-
top.children.push(this.inst("UIAspectRatioConstraint", "
|
|
27452
|
-
|
|
27453
|
-
|
|
27454
|
-
return
|
|
27899
|
+
top.children.push(this.inst("UIAspectRatioConstraint", "UIAspectRatioConstraint", [["AspectRatio", F(root.w / root.h)]]));
|
|
27900
|
+
const out = this.opts.asRootFrame ? top : this.inst("ScreenGui", `${(this.names.get(root.id) ?? "Ui").replace(/Gui$/, "")}Gui`, [["ResetOnSpawn", B(false)], ["ZIndexBehavior", E("ZIndexBehavior", "Sibling")], ["IgnoreGuiInset", B(true)]], [top]);
|
|
27901
|
+
dedupe(out);
|
|
27902
|
+
return out;
|
|
27455
27903
|
}
|
|
27456
27904
|
inst(className, name, props = [], children = [], source) {
|
|
27457
27905
|
this.counts.instances++;
|
|
27458
27906
|
return { className, name, props: [["Name", S(name)], ...props], children, ...source ? { source } : {} };
|
|
27459
27907
|
}
|
|
27908
|
+
frameOf(n, parent) {
|
|
27909
|
+
const inFlow = !!parent.layout && !n.absolute;
|
|
27910
|
+
const scale = this.opts.mode === "scale" || this.opts.mode === "hybrid" && !inFlow;
|
|
27911
|
+
if (!inFlow)
|
|
27912
|
+
return { scale, w: Math.max(1, parent.w), h: Math.max(1, parent.h), inFlow };
|
|
27913
|
+
const p = parent.layout.padding;
|
|
27914
|
+
return { scale, w: Math.max(1, parent.w - (p[1] ?? 0) - (p[3] ?? 0)), h: Math.max(1, parent.h - (p[0] ?? 0) - (p[2] ?? 0)), inFlow };
|
|
27915
|
+
}
|
|
27916
|
+
hugs(n, d) {
|
|
27917
|
+
if (this.scaleOnly)
|
|
27918
|
+
return { w: false, h: false };
|
|
27919
|
+
const content = d.kind === "text" || !!n.layout;
|
|
27920
|
+
return { w: n.sizing?.h === "HUG" && content, h: n.sizing?.v === "HUG" && content };
|
|
27921
|
+
}
|
|
27460
27922
|
geometry(n, parent, index, d) {
|
|
27461
27923
|
const out = [];
|
|
27462
27924
|
if (!parent)
|
|
27463
27925
|
return out;
|
|
27464
|
-
const
|
|
27465
|
-
const
|
|
27466
|
-
|
|
27467
|
-
|
|
27468
|
-
if (inFlow) {
|
|
27469
|
-
const l = parent.layout;
|
|
27470
|
-
const horizontal = l.mode === "HORIZONTAL";
|
|
27471
|
-
const pw = parent.w - (l.padding[1] ?? 0) - (l.padding[3] ?? 0);
|
|
27472
|
-
const ph = parent.h - (l.padding[0] ?? 0) - (l.padding[2] ?? 0);
|
|
27926
|
+
const f = this.frameOf(n, parent);
|
|
27927
|
+
const hug = this.hugs(n, d);
|
|
27928
|
+
if (f.inFlow) {
|
|
27929
|
+
const horizontal = parent.layout.mode === "HORIZONTAL";
|
|
27473
27930
|
const crossFill = horizontal ? n.sizing?.v === "FILL" || n.stretch : n.sizing?.h === "FILL" || n.stretch;
|
|
27474
|
-
const w =
|
|
27475
|
-
const h =
|
|
27931
|
+
const w = !horizontal && crossFill ? U(1, 0) : hug.w ? U(0, 0) : f.scale ? U(n.w / f.w, 0) : U(0, n.w);
|
|
27932
|
+
const h = horizontal && crossFill ? U(1, 0) : hug.h ? U(0, 0) : f.scale ? U(n.h / f.h, 0) : U(0, n.h);
|
|
27476
27933
|
out.push(["Size", { t: "UDim2", xs: w.s, xo: w.o, ys: h.s, yo: h.o }]);
|
|
27477
27934
|
out.push(["LayoutOrder", I(index + 1)]);
|
|
27478
27935
|
return out;
|
|
@@ -27480,7 +27937,7 @@ class Mapper {
|
|
|
27480
27937
|
const c = n.constraints ?? { h: "MIN", v: "MIN" };
|
|
27481
27938
|
const axis = (pos, size, total, k) => {
|
|
27482
27939
|
const end = total - pos - size;
|
|
27483
|
-
if (k === "SCALE" ||
|
|
27940
|
+
if (k === "SCALE" || f.scale) {
|
|
27484
27941
|
if (k === "MAX")
|
|
27485
27942
|
return { anchor: 1, p: [(pos + size) / total, 0], s: [size / total, 0] };
|
|
27486
27943
|
if (k === "CENTER")
|
|
@@ -27495,39 +27952,63 @@ class Mapper {
|
|
|
27495
27952
|
return { anchor: 0, p: [0, pos], s: [1, -(pos + end)] };
|
|
27496
27953
|
return { anchor: 0, p: [0, pos], s: [0, size] };
|
|
27497
27954
|
};
|
|
27498
|
-
const x = axis(n.x, n.w,
|
|
27499
|
-
const y = axis(n.y, n.h,
|
|
27955
|
+
const x = axis(n.x, n.w, f.w, c.h);
|
|
27956
|
+
const y = axis(n.y, n.h, f.h, c.v);
|
|
27500
27957
|
if (x.anchor || y.anchor)
|
|
27501
27958
|
out.push(["AnchorPoint", V2(x.anchor, y.anchor)]);
|
|
27502
27959
|
out.push(["Position", U2(x.p[0], x.p[1], y.p[0], y.p[1])]);
|
|
27503
|
-
out.push(["Size", U2(
|
|
27960
|
+
out.push(["Size", U2(hug.w ? 0 : x.s[0], hug.w ? 0 : x.s[1], hug.h ? 0 : y.s[0], hug.h ? 0 : y.s[1])]);
|
|
27504
27961
|
out.push(["ZIndex", I(index + 1)]);
|
|
27505
27962
|
return out;
|
|
27506
27963
|
}
|
|
27507
27964
|
automaticSize(n, d) {
|
|
27508
|
-
const
|
|
27509
|
-
|
|
27510
|
-
if (!hugW && !hugH)
|
|
27965
|
+
const hug = this.hugs(n, d);
|
|
27966
|
+
if (!hug.w && !hug.h)
|
|
27511
27967
|
return [];
|
|
27512
|
-
return [["AutomaticSize", E("AutomaticSize",
|
|
27968
|
+
return [["AutomaticSize", E("AutomaticSize", hug.w && hug.h ? "XY" : hug.w ? "X" : "Y")]];
|
|
27513
27969
|
}
|
|
27514
27970
|
corner(n) {
|
|
27515
27971
|
const r = Math.max(0, ...n.radius ?? []);
|
|
27516
27972
|
if (!r && !(n.type === "ELLIPSE"))
|
|
27517
27973
|
return null;
|
|
27518
|
-
const
|
|
27519
|
-
|
|
27974
|
+
const short = Math.max(1, Math.min(n.w, n.h));
|
|
27975
|
+
const pill = n.type === "ELLIPSE" || r >= short / 2 - 0.5;
|
|
27976
|
+
return this.inst("UICorner", "UICorner", [["CornerRadius", pill ? U(0.5, 0) : this.scaleOnly ? U(r / short, 0) : U(0, r)]]);
|
|
27520
27977
|
}
|
|
27521
27978
|
stroke(n) {
|
|
27522
27979
|
const s = visible(n.strokes)[0];
|
|
27523
27980
|
if (!s || s.type !== "SOLID" || !n.strokeWeight)
|
|
27524
27981
|
return null;
|
|
27525
|
-
|
|
27982
|
+
const short = Math.max(1, Math.min(n.w, n.h));
|
|
27983
|
+
const props = [
|
|
27526
27984
|
["ApplyStrokeMode", E("ApplyStrokeMode", "Border")],
|
|
27527
27985
|
["Color", C3(s.color)],
|
|
27528
|
-
["Thickness", F(n.strokeWeight)],
|
|
27986
|
+
["Thickness", F(this.scaleOnly ? n.strokeWeight / short : n.strokeWeight)],
|
|
27529
27987
|
["Transparency", F(1 - s.opacity)],
|
|
27530
|
-
["LineJoinMode", E("LineJoinMode", "Round")]
|
|
27988
|
+
["LineJoinMode", E("LineJoinMode", "Round")],
|
|
27989
|
+
["BorderStrokePosition", E("BorderStrokePosition", n.strokeAlign === "OUTSIDE" ? "Outer" : n.strokeAlign === "CENTER" ? "Center" : "Inner")]
|
|
27990
|
+
];
|
|
27991
|
+
if (this.scaleOnly)
|
|
27992
|
+
props.push(["StrokeSizingMode", E("StrokeSizingMode", "ScaledSize")]);
|
|
27993
|
+
return this.inst("UIStroke", "UIStroke", props);
|
|
27994
|
+
}
|
|
27995
|
+
shadow(n) {
|
|
27996
|
+
const list = (n.effects ?? []).filter((e) => e.type === "DROP_SHADOW");
|
|
27997
|
+
if (!list.length)
|
|
27998
|
+
return null;
|
|
27999
|
+
if (list.length > 1)
|
|
28000
|
+
this.warn(n, "several drop shadows: kept the largest");
|
|
28001
|
+
const e = list.reduce((a, b) => b.blur + (b.spread ?? 0) > a.blur + (a.spread ?? 0) ? b : a);
|
|
28002
|
+
const w = Math.max(1, n.w);
|
|
28003
|
+
const h = Math.max(1, n.h);
|
|
28004
|
+
const spread = (e.spread ?? 0) * 2;
|
|
28005
|
+
const s = this.scaleOnly;
|
|
28006
|
+
return this.inst("UIShadow", "UIShadow", [
|
|
28007
|
+
["Color", C3(e.color ?? "#000000")],
|
|
28008
|
+
["Transparency", F(1 - (e.alpha ?? 0.25))],
|
|
28009
|
+
["BlurRadius", s ? U(e.blur / Math.min(w, h), 0) : U(0, e.blur)],
|
|
28010
|
+
["Offset", s ? U2((e.x ?? 0) / w, 0, (e.y ?? 0) / h, 0) : U2(0, e.x ?? 0, 0, e.y ?? 0)],
|
|
28011
|
+
["Spread", s ? U2(spread / w, 0, spread / h, 0) : U2(0, spread, 0, spread)]
|
|
27531
28012
|
]);
|
|
27532
28013
|
}
|
|
27533
28014
|
gradient(p) {
|
|
@@ -27550,18 +28031,26 @@ class Mapper {
|
|
|
27550
28031
|
}
|
|
27551
28032
|
layoutChildren(n) {
|
|
27552
28033
|
const l = n.layout;
|
|
28034
|
+
const s = this.scaleOnly;
|
|
27553
28035
|
const out = [];
|
|
27554
|
-
const [t, r, b, lft] = l.padding;
|
|
28036
|
+
const [t = 0, r = 0, b = 0, lft = 0] = l.padding;
|
|
28037
|
+
const w = Math.max(1, n.w);
|
|
28038
|
+
const h = Math.max(1, n.h);
|
|
28039
|
+
const cw = Math.max(1, w - r - lft);
|
|
28040
|
+
const ch = Math.max(1, h - t - b);
|
|
28041
|
+
const pad = (v, total) => s ? U(v / total, 0) : U(0, v);
|
|
27555
28042
|
if (t || r || b || lft)
|
|
27556
|
-
out.push(this.inst("UIPadding", "UIPadding", [["PaddingTop",
|
|
28043
|
+
out.push(this.inst("UIPadding", "UIPadding", [["PaddingTop", pad(t, h)], ["PaddingRight", pad(r, w)], ["PaddingBottom", pad(b, h)], ["PaddingLeft", pad(lft, w)]]));
|
|
27557
28044
|
if (l.mode === "GRID") {
|
|
27558
28045
|
const kids = (n.children ?? []).filter((c) => !c.hidden && !c.absolute);
|
|
27559
28046
|
const first = kids[0];
|
|
27560
28047
|
if (kids.some((k) => Math.abs(k.w - (first?.w ?? 0)) > 1 || Math.abs(k.h - (first?.h ?? 0)) > 1))
|
|
27561
28048
|
this.warn(n, "grid cells of different sizes: UIGridLayout uses the first cell's size");
|
|
28049
|
+
const fw = first?.w ?? 100;
|
|
28050
|
+
const fh = first?.h ?? 100;
|
|
27562
28051
|
out.push(this.inst("UIGridLayout", "UIGridLayout", [
|
|
27563
|
-
["CellSize", U2(0,
|
|
27564
|
-
["CellPadding", U2(0, l.columnGap ?? 0, 0, l.rowGap ?? 0)],
|
|
28052
|
+
["CellSize", s ? U2(fw / cw, 0, fh / ch, 0) : U2(0, fw, 0, fh)],
|
|
28053
|
+
["CellPadding", s ? U2((l.columnGap ?? 0) / cw, 0, (l.rowGap ?? 0) / ch, 0) : U2(0, l.columnGap ?? 0, 0, l.rowGap ?? 0)],
|
|
27565
28054
|
["FillDirectionMaxCells", I(l.columns ?? 0)],
|
|
27566
28055
|
["SortOrder", E("SortOrder", "LayoutOrder")]
|
|
27567
28056
|
]));
|
|
@@ -27570,10 +28059,11 @@ class Mapper {
|
|
|
27570
28059
|
const horizontal = l.mode === "HORIZONTAL";
|
|
27571
28060
|
const main = l.primary === "CENTER" ? "Center" : l.primary === "MAX" ? horizontal ? "Right" : "Bottom" : horizontal ? "Left" : "Top";
|
|
27572
28061
|
const cross = l.counter === "CENTER" ? "Center" : l.counter === "MAX" ? horizontal ? "Bottom" : "Right" : horizontal ? "Top" : "Left";
|
|
28062
|
+
const gap = l.primary === "SPACE_BETWEEN" ? 0 : l.gap;
|
|
27573
28063
|
const props = [
|
|
27574
28064
|
["FillDirection", E("FillDirection", horizontal ? "Horizontal" : "Vertical")],
|
|
27575
28065
|
["SortOrder", E("SortOrder", "LayoutOrder")],
|
|
27576
|
-
["Padding",
|
|
28066
|
+
["Padding", pad(gap, horizontal ? cw : ch)],
|
|
27577
28067
|
["HorizontalAlignment", E("HorizontalAlignment", horizontal ? main === "Right" ? "Right" : main === "Center" ? "Center" : "Left" : cross === "Right" ? "Right" : cross === "Center" ? "Center" : "Left")],
|
|
27578
28068
|
["VerticalAlignment", E("VerticalAlignment", horizontal ? cross === "Bottom" ? "Bottom" : cross === "Center" ? "Center" : "Top" : main === "Bottom" ? "Bottom" : main === "Center" ? "Center" : "Top")]
|
|
27579
28069
|
];
|
|
@@ -27636,10 +28126,12 @@ class Mapper {
|
|
|
27636
28126
|
props.push(["LineHeight", F(Math.max(1, Math.min(3, base.lineHeight / (base.size * 1.2))))]);
|
|
27637
28127
|
if (t.maxLines)
|
|
27638
28128
|
props.push(["TextTruncate", E("TextTruncate", "AtEnd")]);
|
|
27639
|
-
if (this.opts.textScaled)
|
|
28129
|
+
if (this.opts.textScaled || this.scaleOnly)
|
|
27640
28130
|
props.push(["TextScaled", B(true)]);
|
|
27641
28131
|
if (segs.some((s) => s.letterSpacing))
|
|
27642
28132
|
this.warn(n, "letter spacing has no Roblox equivalent: ignored");
|
|
28133
|
+
if (rich && this.scaleOnly && segs.some((s) => s.size !== base.size))
|
|
28134
|
+
this.warn(n, "several text sizes with TextScaled: the size tags may not scale with the label");
|
|
27643
28135
|
return props;
|
|
27644
28136
|
}
|
|
27645
28137
|
richRun(s, base) {
|
|
@@ -27677,7 +28169,7 @@ class Mapper {
|
|
|
27677
28169
|
const d = decide(n, this.opts);
|
|
27678
28170
|
for (const a of d.approximations)
|
|
27679
28171
|
this.warn(n, a);
|
|
27680
|
-
const button = !!parent &&
|
|
28172
|
+
const button = !!parent && this.isButton(n) && d.kind !== "text";
|
|
27681
28173
|
const geo = this.geometry(n, parent, index, d);
|
|
27682
28174
|
const common = [...geo, ...this.automaticSize(n, d)];
|
|
27683
28175
|
if (n.rotation)
|
|
@@ -27686,26 +28178,32 @@ class Mapper {
|
|
|
27686
28178
|
let className = "Frame";
|
|
27687
28179
|
let props = [];
|
|
27688
28180
|
if (d.kind === "text") {
|
|
27689
|
-
|
|
28181
|
+
const isButton = !!n.clickable || BUTTON_NAME.test(n.name);
|
|
28182
|
+
className = isButton ? "TextButton" : "TextLabel";
|
|
27690
28183
|
props = this.textProps(n);
|
|
27691
|
-
if (
|
|
28184
|
+
if (isButton)
|
|
27692
28185
|
props.push(["AutoButtonColor", B(false)]);
|
|
28186
|
+
if ((n.effects ?? []).some((e) => e.type === "DROP_SHADOW"))
|
|
28187
|
+
this.warn(n, "text shadow has no Roblox equivalent: ignored");
|
|
27693
28188
|
const size = props.find(([k]) => k === "TextSize")[1].v;
|
|
27694
|
-
|
|
27695
|
-
|
|
28189
|
+
const tight = n.text.autoResize === "WIDTH_AND_HEIGHT" || n.text.autoResize === "HEIGHT";
|
|
28190
|
+
const max = this.scaleOnly && tight ? size * 2 : size;
|
|
28191
|
+
children.push(this.inst("UITextSizeConstraint", "UITextSizeConstraint", [["MaxTextSize", I(max)], ["MinTextSize", I(Math.max(1, Math.round(size * 0.5)))]]));
|
|
28192
|
+
return this.inst(className, this.label(n, isButton ? "button" : "text"), [...common, ...props], children, n.id);
|
|
27696
28193
|
}
|
|
27697
28194
|
if (d.kind === "picture") {
|
|
27698
28195
|
this.counts.pictures++;
|
|
27699
28196
|
const a = this.assetUrl(`${n.id}:full`, n);
|
|
27700
28197
|
className = button ? "ImageButton" : "ImageLabel";
|
|
27701
|
-
const pic = this.pictureGeometry(n, a, geo);
|
|
28198
|
+
const pic = parent ? this.pictureGeometry(n, a, geo, parent) : geo;
|
|
27702
28199
|
props = [["BackgroundTransparency", F(1)], ["Image", { t: "Content", url: a.url }], ["ScaleType", E("ScaleType", "Stretch")]];
|
|
27703
28200
|
if (n.opacity !== undefined && n.opacity < 1)
|
|
27704
28201
|
props.push(["ImageTransparency", F(1 - n.opacity)]);
|
|
27705
28202
|
if (button)
|
|
27706
28203
|
props.push(["AutoButtonColor", B(false)]);
|
|
27707
28204
|
children.push(this.inst("UIAspectRatioConstraint", "UIAspectRatioConstraint", [["AspectRatio", F((a.size?.w ?? n.w) / Math.max(1, a.size?.h ?? n.h))]]));
|
|
27708
|
-
|
|
28205
|
+
const flavor = button ? "button" : n.vector || VECTOR_TYPES.includes(n.type) ? "icon" : "image";
|
|
28206
|
+
return this.inst(className, this.label(n, flavor), [...pic, ...props], children, n.id);
|
|
27709
28207
|
}
|
|
27710
28208
|
const fills = visible(n.fills);
|
|
27711
28209
|
const opacity = n.opacity ?? 1;
|
|
@@ -27723,8 +28221,11 @@ class Mapper {
|
|
|
27723
28221
|
const corner = this.corner(n);
|
|
27724
28222
|
if (corner)
|
|
27725
28223
|
children.push(corner);
|
|
28224
|
+
const shadow = this.shadow(n);
|
|
28225
|
+
if (shadow)
|
|
28226
|
+
children.push(shadow);
|
|
27726
28227
|
children.push(this.inst("UIAspectRatioConstraint", "UIAspectRatioConstraint", [["AspectRatio", F(n.w / Math.max(1, n.h))]]));
|
|
27727
|
-
return this.inst(className, n
|
|
28228
|
+
return this.inst(className, this.label(n, button ? "button" : "image"), [...common, ...props], children, n.id);
|
|
27728
28229
|
}
|
|
27729
28230
|
if (d.kind === "panel") {
|
|
27730
28231
|
this.counts.pictures++;
|
|
@@ -27747,6 +28248,13 @@ class Mapper {
|
|
|
27747
28248
|
props.push(["ImageTransparency", F(1 - opacity)]);
|
|
27748
28249
|
if (button)
|
|
27749
28250
|
props.push(["AutoButtonColor", B(false)]);
|
|
28251
|
+
const shadow = this.shadow(n);
|
|
28252
|
+
if (shadow) {
|
|
28253
|
+
const corner = this.corner(n);
|
|
28254
|
+
if (corner)
|
|
28255
|
+
children.push(corner);
|
|
28256
|
+
children.push(shadow);
|
|
28257
|
+
}
|
|
27750
28258
|
} else {
|
|
27751
28259
|
className = button ? "TextButton" : hasKids && opacity < 1 ? "CanvasGroup" : "Frame";
|
|
27752
28260
|
const f = fills[0];
|
|
@@ -27763,7 +28271,7 @@ class Mapper {
|
|
|
27763
28271
|
} else if (f.type === "IMAGE") {
|
|
27764
28272
|
props.push(["BackgroundTransparency", F(1)]);
|
|
27765
28273
|
const a = this.assetUrl(`image:${f.imageHash}`, n);
|
|
27766
|
-
const bg = this.inst("ImageLabel", "
|
|
28274
|
+
const bg = this.inst("ImageLabel", "BackgroundImage", [["Size", U2(1, 0, 1, 0)], ["ZIndex", I(0)], ["BackgroundTransparency", F(1)], ["Image", { t: "Content", url: a.url }], ["ScaleType", E("ScaleType", f.scaleMode === "FIT" ? "Fit" : "Crop")]]);
|
|
27767
28275
|
const corner = this.corner(n);
|
|
27768
28276
|
if (corner)
|
|
27769
28277
|
bg.children.push(corner);
|
|
@@ -27779,6 +28287,9 @@ class Mapper {
|
|
|
27779
28287
|
const stroke = this.stroke(n);
|
|
27780
28288
|
if (stroke)
|
|
27781
28289
|
children.push(stroke);
|
|
28290
|
+
const shadow = this.shadow(n);
|
|
28291
|
+
if (shadow)
|
|
28292
|
+
children.push(shadow);
|
|
27782
28293
|
}
|
|
27783
28294
|
props.push(["BorderSizePixel", I(0)]);
|
|
27784
28295
|
if (n.clip)
|
|
@@ -27787,8 +28298,7 @@ class Mapper {
|
|
|
27787
28298
|
const flow = n.layout ? kids.filter((k) => !k.absolute) : kids;
|
|
27788
28299
|
const loose = n.layout ? kids.filter((k) => k.absolute) : [];
|
|
27789
28300
|
const me = { w: n.w, h: n.h, layout: n.layout };
|
|
27790
|
-
const
|
|
27791
|
-
const flowMapped = mapped(flow, me);
|
|
28301
|
+
const flowMapped = flow.map((k, i) => this.node(k, me, i)).filter((x) => !!x);
|
|
27792
28302
|
if (n.layout && n.layout.mode !== "GRID") {
|
|
27793
28303
|
for (const k of flow) {
|
|
27794
28304
|
const target = k.grow && !k.hidden ? flowMapped.find((c) => c.source === k.id) : undefined;
|
|
@@ -27806,44 +28316,24 @@ class Mapper {
|
|
|
27806
28316
|
children.push(...this.layoutChildren(n));
|
|
27807
28317
|
children.push(...flowMapped);
|
|
27808
28318
|
}
|
|
27809
|
-
const
|
|
27810
|
-
|
|
27811
|
-
return self;
|
|
27812
|
-
this.counts.pictures++;
|
|
27813
|
-
const a = this.assetUrl(`${n.id}:shadow`, n);
|
|
27814
|
-
const scale = this.opts.scale ?? 2;
|
|
27815
|
-
const sh = (n.effects ?? []).filter((e) => e.type === "DROP_SHADOW");
|
|
27816
|
-
const reach = Math.max(...sh.map((e) => e.blur + (e.spread ?? 0) + Math.max(Math.abs(e.x ?? 0), Math.abs(e.y ?? 0))));
|
|
27817
|
-
const off = a.offset ?? { x: 0, y: 0 };
|
|
27818
|
-
const size = a.size ?? { w: n.w, h: n.h };
|
|
27819
|
-
const inset = Math.ceil((Math.max(0, ...n.radius ?? [0]) + reach) * scale);
|
|
27820
|
-
const pw = Math.round(size.w * scale);
|
|
27821
|
-
const ph = Math.round(size.h * scale);
|
|
27822
|
-
const ix = Math.min(inset, Math.floor(pw / 2) - 1);
|
|
27823
|
-
const iy = Math.min(inset, Math.floor(ph / 2) - 1);
|
|
27824
|
-
const shadow = this.inst("ImageLabel", "Shadow", [
|
|
27825
|
-
["Position", U2(0, off.x, 0, off.y)],
|
|
27826
|
-
["Size", U2(1, size.w - n.w, 1, size.h - n.h)],
|
|
27827
|
-
["ZIndex", I(1)],
|
|
27828
|
-
["BackgroundTransparency", F(1)],
|
|
27829
|
-
["Image", { t: "Content", url: a.url }],
|
|
27830
|
-
["ScaleType", E("ScaleType", "Slice")],
|
|
27831
|
-
["SliceCenter", { t: "Rect", x0: ix, y0: iy, x1: pw - ix, y1: ph - iy }],
|
|
27832
|
-
["SliceScale", F(1 / scale)]
|
|
27833
|
-
]);
|
|
27834
|
-
self.props = self.props.filter(([k]) => !["AnchorPoint", "Position", "Size", "LayoutOrder", "AutomaticSize"].includes(k));
|
|
27835
|
-
self.props.splice(1, 0, ["Size", U2(1, 0, 1, 0)], ["ZIndex", I(2)]);
|
|
27836
|
-
return this.inst("Frame", n.name, [...common.filter(([k]) => k !== "AutomaticSize"), ["BackgroundTransparency", F(1)], ["BorderSizePixel", I(0)]], [shadow, self], n.id);
|
|
28319
|
+
const flavor = button ? "button" : fills.length || visible(n.strokes).length || d.kind === "panel" ? "frame" : "container";
|
|
28320
|
+
return this.inst(className, this.label(n, flavor), [...common, ...props], children, n.id);
|
|
27837
28321
|
}
|
|
27838
|
-
pictureGeometry(n, a, geo) {
|
|
28322
|
+
pictureGeometry(n, a, geo, parent) {
|
|
27839
28323
|
if (!a.offset || !a.size || Math.abs(a.offset.x) < 0.5 && Math.abs(a.offset.y) < 0.5 && Math.abs(a.size.w - n.w) < 0.5 && Math.abs(a.size.h - n.h) < 0.5)
|
|
27840
28324
|
return geo;
|
|
28325
|
+
const f = this.frameOf(n, parent);
|
|
28326
|
+
const dw = a.size.w - n.w;
|
|
28327
|
+
const dh = a.size.h - n.h;
|
|
28328
|
+
const ox = a.offset.x;
|
|
28329
|
+
const oy = a.offset.y;
|
|
27841
28330
|
return geo.map(([k, v]) => {
|
|
27842
|
-
if (k
|
|
27843
|
-
return [k,
|
|
27844
|
-
if (
|
|
27845
|
-
return [k, { ...v,
|
|
27846
|
-
|
|
28331
|
+
if (v.t !== "UDim2" || k !== "Size" && k !== "Position")
|
|
28332
|
+
return [k, v];
|
|
28333
|
+
if (f.scale) {
|
|
28334
|
+
return k === "Size" ? [k, { ...v, xs: num(v.xs + dw / f.w), ys: num(v.ys + dh / f.h) }] : [k, { ...v, xs: num(v.xs + ox / f.w), ys: num(v.ys + oy / f.h) }];
|
|
28335
|
+
}
|
|
28336
|
+
return k === "Size" ? [k, { ...v, xo: Math.round(v.xo + dw), yo: Math.round(v.yo + dh) }] : [k, { ...v, xo: Math.round(v.xo + ox), yo: Math.round(v.yo + oy) }];
|
|
27847
28337
|
});
|
|
27848
28338
|
}
|
|
27849
28339
|
}
|
|
@@ -27854,10 +28344,14 @@ function mapToRoblox(root, opts, assets) {
|
|
|
27854
28344
|
m.warn(null, `${m.counts.skippedHidden} hidden layer(s) skipped`);
|
|
27855
28345
|
return { root: out, warnings: [...m.warnings], fontSubstitutions: Object.fromEntries(m.fontSubstitutions), counts: m.counts };
|
|
27856
28346
|
}
|
|
27857
|
-
var num = (v) => Math.round(v *
|
|
28347
|
+
var num = (v) => Math.round(v * 1e4) / 1e4, 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, VECTOR_TYPES, ICON_WORDS, IMAGE_WORDS;
|
|
27858
28348
|
var init_map2 = __esm(() => {
|
|
28349
|
+
init_naming();
|
|
27859
28350
|
init_fonts();
|
|
27860
|
-
BUTTON_NAME =
|
|
28351
|
+
BUTTON_NAME = /button|btn|\bcta\b/i;
|
|
28352
|
+
VECTOR_TYPES = ["VECTOR", "BOOLEAN_OPERATION", "STAR", "POLYGON", "LINE"];
|
|
28353
|
+
ICON_WORDS = ["Icon", "Logo", "Image", "Illustration", "Avatar", "Emoji"];
|
|
28354
|
+
IMAGE_WORDS = ["Image", "Icon", "Avatar", "Logo", "Thumbnail", "Picture", "Photo", "Banner", "Background", "Illustration", "Art", "Cover"];
|
|
27861
28355
|
});
|
|
27862
28356
|
|
|
27863
28357
|
// src/roblox/output.ts
|
|
@@ -28019,7 +28513,7 @@ return ${rootVar}
|
|
|
28019
28513
|
function substituteAssets(text, ids) {
|
|
28020
28514
|
return text.replace(/rbxassetid:\/\/PENDING_\d+/g, (m) => ids[m] ? `rbxassetid://${ids[m]}` : m);
|
|
28021
28515
|
}
|
|
28022
|
-
var ENUMS, n = (v) => String(Math.round(v *
|
|
28516
|
+
var ENUMS, n = (v) => String(Math.round(v * 1e4) / 1e4), xml = (s) => s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """), KEYWORDS, rgb = (v) => Math.round(v * 255);
|
|
28023
28517
|
var init_output = __esm(() => {
|
|
28024
28518
|
ENUMS = {
|
|
28025
28519
|
ZIndexBehavior: { Global: 0, Sibling: 1 },
|
|
@@ -28035,7 +28529,9 @@ var init_output = __esm(() => {
|
|
|
28035
28529
|
ScaleType: { Stretch: 0, Slice: 1, Tile: 2, Fit: 3, Crop: 4 },
|
|
28036
28530
|
ApplyStrokeMode: { Contextual: 0, Border: 1 },
|
|
28037
28531
|
LineJoinMode: { Round: 0, Bevel: 1, Miter: 2 },
|
|
28038
|
-
TextTruncate: { None: 0, AtEnd: 1, SplitWord: 2 }
|
|
28532
|
+
TextTruncate: { None: 0, AtEnd: 1, SplitWord: 2 },
|
|
28533
|
+
StrokeSizingMode: { FixedSize: 0, ScaledSize: 1 },
|
|
28534
|
+
BorderStrokePosition: { Outer: 0, Center: 1, Inner: 2 }
|
|
28039
28535
|
};
|
|
28040
28536
|
KEYWORDS = new Set(["and", "break", "do", "else", "elseif", "end", "false", "for", "function", "if", "in", "local", "nil", "not", "or", "repeat", "return", "then", "true", "until", "while", "continue", "export", "type", "game", "workspace", "script", "PARENT", "existing"]);
|
|
28041
28537
|
});
|
|
@@ -28141,7 +28637,7 @@ import { join as join2 } from "node:path";
|
|
|
28141
28637
|
async function exportRoblox(args, deps) {
|
|
28142
28638
|
const env = deps.env ?? process.env;
|
|
28143
28639
|
const opts = {
|
|
28144
|
-
mode: args.mode ?? "
|
|
28640
|
+
mode: args.mode ?? "scale",
|
|
28145
28641
|
targetResolution: args.targetResolution ?? [1920, 1080],
|
|
28146
28642
|
rasterize: args.rasterize ?? "auto",
|
|
28147
28643
|
asRootFrame: args.asRootFrame,
|
|
@@ -30585,6 +31081,8 @@ function snapshot(opts) {
|
|
|
30585
31081
|
}
|
|
30586
31082
|
|
|
30587
31083
|
// src/web/browser.ts
|
|
31084
|
+
import { existsSync as existsSync5 } from "node:fs";
|
|
31085
|
+
import { join as join8 } from "node:path";
|
|
30588
31086
|
import { pathToFileURL } from "node:url";
|
|
30589
31087
|
async function loadPlaywright() {
|
|
30590
31088
|
try {
|
|
@@ -30593,12 +31091,39 @@ async function loadPlaywright() {
|
|
|
30593
31091
|
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");
|
|
30594
31092
|
}
|
|
30595
31093
|
}
|
|
31094
|
+
function otherChromiumBrowsers() {
|
|
31095
|
+
const env = process.env;
|
|
31096
|
+
const roots = process.platform === "win32" ? [env.ProgramFiles, env["ProgramFiles(x86)"], env.LOCALAPPDATA, "C:\\Program Files", "C:\\Program Files (x86)"].filter(Boolean) : [];
|
|
31097
|
+
const candidates = process.platform === "win32" ? [
|
|
31098
|
+
{ label: "Brave", paths: roots.map((r) => join8(r, "BraveSoftware", "Brave-Browser", "Application", "brave.exe")) },
|
|
31099
|
+
{ label: "Chromium", paths: roots.map((r) => join8(r, "Chromium", "Application", "chrome.exe")) },
|
|
31100
|
+
{ label: "Vivaldi", paths: roots.map((r) => join8(r, "Vivaldi", "Application", "vivaldi.exe")) },
|
|
31101
|
+
{ label: "Opera", paths: roots.map((r) => join8(r, "Programs", "Opera", "opera.exe")) }
|
|
31102
|
+
] : process.platform === "darwin" ? [
|
|
31103
|
+
{ label: "Brave", paths: ["/Applications/Brave Browser.app/Contents/MacOS/Brave Browser"] },
|
|
31104
|
+
{ label: "Chromium", paths: ["/Applications/Chromium.app/Contents/MacOS/Chromium"] },
|
|
31105
|
+
{ label: "Arc", paths: ["/Applications/Arc.app/Contents/MacOS/Arc"] }
|
|
31106
|
+
] : [
|
|
31107
|
+
{ label: "Brave", paths: ["/usr/bin/brave-browser", "/usr/bin/brave"] },
|
|
31108
|
+
{ label: "Chromium", paths: ["/usr/bin/chromium", "/usr/bin/chromium-browser", "/snap/bin/chromium"] }
|
|
31109
|
+
];
|
|
31110
|
+
const found = [];
|
|
31111
|
+
for (const c of candidates) {
|
|
31112
|
+
const path = c.paths.find((p) => existsSync5(p));
|
|
31113
|
+
if (path)
|
|
31114
|
+
found.push({ label: c.label, path });
|
|
31115
|
+
}
|
|
31116
|
+
return found;
|
|
31117
|
+
}
|
|
30596
31118
|
async function launchBrowser() {
|
|
30597
31119
|
const { chromium } = await loadPlaywright();
|
|
30598
31120
|
const tries = [];
|
|
30599
31121
|
if (process.env.FIGMA_BRIDGE_BROWSER)
|
|
30600
31122
|
tries.push({ label: process.env.FIGMA_BRIDGE_BROWSER, opts: { executablePath: process.env.FIGMA_BRIDGE_BROWSER } });
|
|
30601
|
-
tries.push({ label: "Chrome", opts: { channel: "chrome" } }, { label: "Edge", opts: { channel: "msedge" } }
|
|
31123
|
+
tries.push({ label: "Chrome", opts: { channel: "chrome" } }, { label: "Edge", opts: { channel: "msedge" } });
|
|
31124
|
+
for (const b of otherChromiumBrowsers())
|
|
31125
|
+
tries.push({ label: b.label, opts: { executablePath: b.path } });
|
|
31126
|
+
tries.push({ label: "Playwright Chromium", opts: {} });
|
|
30602
31127
|
const errors = [];
|
|
30603
31128
|
for (const t of tries) {
|
|
30604
31129
|
try {
|
|
@@ -30608,7 +31133,7 @@ async function launchBrowser() {
|
|
|
30608
31133
|
`)[0]}`);
|
|
30609
31134
|
}
|
|
30610
31135
|
}
|
|
30611
|
-
throw new WebImportError(`No browser found to render the page. Install
|
|
31136
|
+
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");
|
|
30612
31137
|
}
|
|
30613
31138
|
async function render(source, opts) {
|
|
30614
31139
|
const given = [source.url, source.html, source.path].filter((v) => v !== undefined).length;
|
|
@@ -31647,7 +32172,7 @@ var init_import = __esm(() => {
|
|
|
31647
32172
|
import { createHash as createHash2 } from "node:crypto";
|
|
31648
32173
|
import { mkdirSync as mkdirSync5, readdirSync as readdirSync2, readFileSync as readFileSync8, rmSync, statSync as statSync2, writeFileSync as writeFileSync5 } from "node:fs";
|
|
31649
32174
|
import { homedir as homedir2 } from "node:os";
|
|
31650
|
-
import { join as
|
|
32175
|
+
import { join as join9 } from "node:path";
|
|
31651
32176
|
function ensureDir() {
|
|
31652
32177
|
mkdirSync5(SNIPPETS_DIR, { recursive: true });
|
|
31653
32178
|
}
|
|
@@ -31664,14 +32189,14 @@ function files() {
|
|
|
31664
32189
|
}
|
|
31665
32190
|
function listSnippets() {
|
|
31666
32191
|
return files().map((f) => {
|
|
31667
|
-
const source = readFileSync8(
|
|
32192
|
+
const source = readFileSync8(join9(SNIPPETS_DIR, f), "utf8");
|
|
31668
32193
|
return { name: f.slice(0, -3), description: header2(source, "description"), usage: header2(source, "usage") };
|
|
31669
32194
|
});
|
|
31670
32195
|
}
|
|
31671
32196
|
function getSnippet(name) {
|
|
31672
32197
|
checkName(name);
|
|
31673
32198
|
try {
|
|
31674
|
-
return readFileSync8(
|
|
32199
|
+
return readFileSync8(join9(SNIPPETS_DIR, `${name}.js`), "utf8");
|
|
31675
32200
|
} catch {
|
|
31676
32201
|
throw new BridgeError(`No snippet named "${name}".`, "NOT_FOUND");
|
|
31677
32202
|
}
|
|
@@ -31684,37 +32209,37 @@ function saveSnippet(name, code, description = "", usage = "") {
|
|
|
31684
32209
|
// @usage ${usage.replace(/\n/g, " ") || `await lib.${name}(args)`}
|
|
31685
32210
|
${body}
|
|
31686
32211
|
`;
|
|
31687
|
-
const path =
|
|
32212
|
+
const path = join9(SNIPPETS_DIR, `${name}.js`);
|
|
31688
32213
|
writeFileSync5(path, text);
|
|
31689
32214
|
return path;
|
|
31690
32215
|
}
|
|
31691
32216
|
function deleteSnippet(name) {
|
|
31692
32217
|
checkName(name);
|
|
31693
|
-
rmSync(
|
|
32218
|
+
rmSync(join9(SNIPPETS_DIR, `${name}.js`), { force: true });
|
|
31694
32219
|
}
|
|
31695
32220
|
function loadLibrary() {
|
|
31696
32221
|
const list = files();
|
|
31697
|
-
const stamp = list.map((f) => `${f}:${statSync2(
|
|
32222
|
+
const stamp = list.map((f) => `${f}:${statSync2(join9(SNIPPETS_DIR, f)).mtimeMs}`).join("|");
|
|
31698
32223
|
if (cached2 && cached2.stamp === stamp)
|
|
31699
32224
|
return cached2;
|
|
31700
32225
|
const lib = {};
|
|
31701
32226
|
for (const f of list)
|
|
31702
|
-
lib[f.slice(0, -3)] = readFileSync8(
|
|
32227
|
+
lib[f.slice(0, -3)] = readFileSync8(join9(SNIPPETS_DIR, f), "utf8");
|
|
31703
32228
|
cached2 = { stamp, hash: stamp ? createHash2("sha1").update(stamp + JSON.stringify(lib)).digest("base64url").slice(0, 12) : "empty", lib };
|
|
31704
32229
|
return cached2;
|
|
31705
32230
|
}
|
|
31706
32231
|
var SNIPPETS_DIR, NAME, cached2 = null;
|
|
31707
32232
|
var init_snippets = __esm(() => {
|
|
31708
32233
|
init_bridge();
|
|
31709
|
-
SNIPPETS_DIR = process.env.FIGMA_BRIDGE_SNIPPETS ||
|
|
32234
|
+
SNIPPETS_DIR = process.env.FIGMA_BRIDGE_SNIPPETS || join9(homedir2(), ".figma-bridge", "snippets");
|
|
31710
32235
|
NAME = /^[A-Za-z_][A-Za-z0-9_]{0,63}$/;
|
|
31711
32236
|
});
|
|
31712
32237
|
|
|
31713
32238
|
// src/server.ts
|
|
31714
32239
|
import { appendFileSync, mkdirSync as mkdirSync6, statSync as statSync3, truncateSync, writeFileSync as writeFileSync6 } from "node:fs";
|
|
31715
32240
|
import { mkdir, readFile, stat, writeFile } from "node:fs/promises";
|
|
31716
|
-
import { tmpdir } from "node:os";
|
|
31717
|
-
import { dirname as dirname4, join as
|
|
32241
|
+
import { homedir as homedir3, tmpdir } from "node:os";
|
|
32242
|
+
import { dirname as dirname4, join as join10 } from "node:path";
|
|
31718
32243
|
import { fileURLToPath } from "node:url";
|
|
31719
32244
|
function log(line) {
|
|
31720
32245
|
const text = `[${new Date().toISOString().slice(11, 23)}] ${line}`;
|
|
@@ -31882,7 +32407,7 @@ async function layerNames(session, ids) {
|
|
|
31882
32407
|
return bridge.request("node_info", { ids: [...new Set(ids)] }, 20000).catch(() => ({}));
|
|
31883
32408
|
}
|
|
31884
32409
|
var PORT, CHANNEL, OUT_DIR, LOG_FILE, MAX_OUTPUT_CHARS = 60000, MAX_IMAGE_BYTES, FIGMA_MAX_IMAGE_DIM = 4096, VIEW_MAX_DIM = 1568, bridge, ok = (value) => ({ content: [{ type: "text", text: json(value) }] }), plain = (text) => ({ content: [{ type: "text", text }] }), oneLine = (s, max = 120) => s.replace(/\s+/g, " ").trim().slice(0, max), safeName = (s) => s.replace(/[^\w.-]+/g, "_").replace(/^_+|_+$/g, "").slice(0, 40) || "node", INSTRUCTIONS, BUILD_DESCRIPTION = `Create a whole layout in ONE call from a declarative spec. Fonts load automatically, icons are fetched, images loaded. Much faster and safer than run_script for new UI.
|
|
31885
|
-
Node: {type?, name?, ...props, children?: Node[]}. type is inferred (text/spans→text, icon→icon, src→image, component→instance, variants→componentSet, else frame). Types: frame, component, componentSet, text, rect, ellipse, line, icon, image, svg, instance.
|
|
32410
|
+
Node: {type?, name?, ...props, children?: Node[]}. Name layers by role (Header, Card, CardList, Title, Description, PrimaryButton, Icon); unnamed layers get a role name (Container, Row, Card, Title, Label, Button, Icon, Divider…). type is inferred (text/spans→text, icon→icon, src→image, component→instance, variants→componentSet, else frame). Types: frame, component, componentSet, text, rect, ellipse, line, icon, image, svg, instance.
|
|
31886
32411
|
FRAME: layout "row"|"column"|"grid" (auto-layout; omit for free positioning), gap (number|"auto"), padding (n | [v,h] | [t,r,b,l]), align (cross axis: start|center|end|baseline), justify (main axis: start|center|end|between), wrap, rowGap, clip. Frames have no fill unless set.
|
|
31887
32412
|
GRID: layout:"grid", columns (count | tracks like [200,"1fr","2fr","hug"]), rows (same; default: enough rows, hugging), columnGap, rowGap (or gap). Children: span:[rows,cols] or colSpan/rowSpan, cellAlign/cellValign. Give the grid a w for "fr" columns.
|
|
31888
32413
|
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.
|
|
@@ -31918,12 +32443,13 @@ var init_server3 = __esm(async () => {
|
|
|
31918
32443
|
init_tokens();
|
|
31919
32444
|
init_tokens_export();
|
|
31920
32445
|
init_import();
|
|
32446
|
+
init_setup();
|
|
31921
32447
|
init_snippets();
|
|
31922
32448
|
init_package();
|
|
31923
32449
|
PORT = Number(process.env.FIGMA_BRIDGE_PORT) || 3055;
|
|
31924
32450
|
CHANNEL = process.env.FIGMA_BRIDGE_CHANNEL || "default";
|
|
31925
|
-
OUT_DIR = process.env.FIGMA_BRIDGE_OUT ||
|
|
31926
|
-
LOG_FILE =
|
|
32451
|
+
OUT_DIR = process.env.FIGMA_BRIDGE_OUT || join10(tmpdir(), "figma-bridge");
|
|
32452
|
+
LOG_FILE = join10(OUT_DIR, "bridge.log");
|
|
31927
32453
|
MAX_IMAGE_BYTES = 50 << 20;
|
|
31928
32454
|
mkdirSync6(OUT_DIR, { recursive: true });
|
|
31929
32455
|
try {
|
|
@@ -31932,6 +32458,16 @@ var init_server3 = __esm(async () => {
|
|
|
31932
32458
|
} catch {}
|
|
31933
32459
|
bridge = new Bridge({ port: PORT, channel: CHANNEL, version, log });
|
|
31934
32460
|
bridge.start();
|
|
32461
|
+
if (process.env.FIGMA_BRIDGE_PLUGIN_SYNC !== "0") {
|
|
32462
|
+
try {
|
|
32463
|
+
const target = process.env.FIGMA_BRIDGE_PLUGIN_DIR || join10(homedir3(), ".figma-bridge", "plugin");
|
|
32464
|
+
const status = syncPlugin(join10(dirname4(fileURLToPath(import.meta.url)), "..", "plugin"), target, version);
|
|
32465
|
+
if (status === "installed" || status === "updated")
|
|
32466
|
+
log(`Figma plugin ${status} to v${version} in ${target}; reopen it in Figma`);
|
|
32467
|
+
} catch (e) {
|
|
32468
|
+
log(`Could not update the Figma plugin copy: ${e.message}`);
|
|
32469
|
+
}
|
|
32470
|
+
}
|
|
31935
32471
|
INSTRUCTIONS = [
|
|
31936
32472
|
"Figma Bridge drives the user's Figma desktop app through a local plugin. No rate limits: iterate freely.",
|
|
31937
32473
|
"Workflow:",
|
|
@@ -31950,7 +32486,8 @@ var init_server3 = __esm(async () => {
|
|
|
31950
32486
|
"Rules: load fonts before editing text; pages load on demand (await figma.setCurrentPageAsync / getNodeByIdAsync); keep results small.",
|
|
31951
32487
|
"The user can cancel a command from the plugin (code CANCELLED): build stops, a script may still finish, so check the file before retrying.",
|
|
31952
32488
|
"With FIGMA_TOKEN set: comments reads and posts file comments pinned to layers, versions lists the history and diffs a version against now.",
|
|
31953
|
-
"
|
|
32489
|
+
"Names: give every layer you create a professional name for its role, as a design system or a developer would: Screen, Header, Navbar, Sidebar, Section, Container, Card, CardList, Row, Grid, Title, Subtitle, Description, Label, Price, PrimaryButton, Icon, Avatar, Badge, Input, Divider, Background. Never leave Frame 12, Rectangle 3 or a text named after its content; build names unnamed layers by role and audit {fix:true} renames default names.",
|
|
32490
|
+
"Roblox: export_roblox turns a frame into a ScreenGui (.rbxmx + Luau for execute_luau + PNG assets), in scale only (no offsets) with PascalCase names (ShopGui, ItemCard, TitleLabel, BuyButton); with a Roblox Studio MCP, upload the assets, run the Luau and check a Studio screenshot. Any Roblox UI you write by hand follows the same rules: UDim2.fromScale, scale padding and gaps, UIStroke ScaledSize, TextScaled + UITextSizeConstraint, UIAspectRatioConstraint on the root.",
|
|
31954
32491
|
"Prompts new-screen, apply-design-system, reproduce-screenshot, import-website, figma-to-code, audit-and-fix and figma-to-roblox are ready-made workflows; the resources figma-bridge://docs/build-spec and figma-bridge://docs/workflow are the full reference.",
|
|
31955
32492
|
"If no file is connected, tell the user to run Plugins → Development → Figma Bridge in Figma (Ctrl+Alt+P re-runs it). If list_sessions reports versionMismatch, ask them to reopen or update the plugin."
|
|
31956
32493
|
];
|
|
@@ -32076,7 +32613,7 @@ ${f.content}`).join(`
|
|
|
32076
32613
|
const single = files.length === 1 && /\.[a-z0-9]+$/i.test(args.path);
|
|
32077
32614
|
const written = [];
|
|
32078
32615
|
for (const f of files) {
|
|
32079
|
-
const target = single ? args.path :
|
|
32616
|
+
const target = single ? args.path : join10(args.path, f.path);
|
|
32080
32617
|
written.push({ format: f.format, path: target, status: await writeIfChanged(target, f.content) });
|
|
32081
32618
|
}
|
|
32082
32619
|
return ok({ counts, files: written, ...warnings.length ? { warnings } : {} });
|
|
@@ -32114,7 +32651,7 @@ ${f.content}`).join(`
|
|
|
32114
32651
|
const bytes = Buffer.from(r.bytesB64, "base64");
|
|
32115
32652
|
const info = imageInfo(bytes);
|
|
32116
32653
|
const ext = r.format === "JPG" ? "jpg" : "png";
|
|
32117
|
-
const path =
|
|
32654
|
+
const path = join10(OUT_DIR, `${safeName(r.name)}-${safeName(r.nodeId)}-${Date.now()}.${ext}`);
|
|
32118
32655
|
await writeFile(path, bytes);
|
|
32119
32656
|
const meta = { path, nodeId: r.nodeId, name: r.name, width: info?.width, height: info?.height, bytes: bytes.length };
|
|
32120
32657
|
const content = [{ type: "text", text: json(meta) }];
|
|
@@ -32141,7 +32678,7 @@ ${f.content}`).join(`
|
|
|
32141
32678
|
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);
|
|
32142
32679
|
const pct = (v) => Math.round(v * 1e4) / 100;
|
|
32143
32680
|
const toLayer = (v, axis) => Math.round((v / shot.scale + shot.offset[axis]) * 10) / 10;
|
|
32144
|
-
const heatmapPath =
|
|
32681
|
+
const heatmapPath = join10(OUT_DIR, `compare-${safeName(shot.name)}-${Date.now()}.png`);
|
|
32145
32682
|
await writeFile(heatmapPath, Buffer.from(diff.heatmap, "base64"));
|
|
32146
32683
|
const refRatio = diff.reference.width / diff.reference.height;
|
|
32147
32684
|
const ratio = diff.width / diff.height;
|
|
@@ -32215,7 +32752,7 @@ Example: {"url":"https://example.com","viewports":[1440,390]}`,
|
|
|
32215
32752
|
const built = await bridge.request("build", { spec: v.spec, images, parentId: args.parentId, x, y, select: false, label: "Web import" }, 120000);
|
|
32216
32753
|
let reference;
|
|
32217
32754
|
if (v.screenshot) {
|
|
32218
|
-
reference =
|
|
32755
|
+
reference = join10(OUT_DIR, `import-${safeName(v.title || "page")}-${v.viewport}-${Date.now()}.png`);
|
|
32219
32756
|
await writeFile(reference, v.screenshot);
|
|
32220
32757
|
}
|
|
32221
32758
|
frames.push({ viewport: v.viewport, rootId: built.rootId, name: v.spec.name, layers: built.created, size: { width: v.width, height: v.height }, reference });
|
|
@@ -32285,7 +32822,7 @@ Links are added to existing interactions unless replace:true; clear:[ids] remove
|
|
|
32285
32822
|
}
|
|
32286
32823
|
}, ({ nodeId, projectPath, framework, styling, name, outDir, write, overwrite }) => track("export_code", `${nodeId ?? "(selection)"} ${projectPath ?? `${framework ?? "html"}/${styling ?? "css"}`}`, async () => {
|
|
32287
32824
|
const r = await bridge.request("export_tree", { nodeId }, 120000);
|
|
32288
|
-
const dir =
|
|
32825
|
+
const dir = join10(OUT_DIR, `export-${safeName(r.tree.name)}-${Date.now()}`);
|
|
32289
32826
|
const finalName = {};
|
|
32290
32827
|
const assetBytes = {};
|
|
32291
32828
|
for (const [file, a] of Object.entries(r.assets)) {
|
|
@@ -32301,8 +32838,8 @@ Links are added to existing interactions unless replace:true; clear:[ids] remove
|
|
|
32301
32838
|
if (projectPath) {
|
|
32302
32839
|
const plan = planProjectExport(projectPath, tree, assetBytes, { framework, styling, name, outDir });
|
|
32303
32840
|
for (const f of plan.files) {
|
|
32304
|
-
mkdirSync6(dirname4(
|
|
32305
|
-
writeFileSync6(
|
|
32841
|
+
mkdirSync6(dirname4(join10(dir, f.path)), { recursive: true });
|
|
32842
|
+
writeFileSync6(join10(dir, f.path), typeof f.content === "string" ? f.content : Buffer.from(f.content));
|
|
32306
32843
|
}
|
|
32307
32844
|
const written = write ? writePlan(projectPath, plan.files, !!overwrite) : null;
|
|
32308
32845
|
const s = plan.stack;
|
|
@@ -32327,13 +32864,13 @@ ${f.content}`).join(`
|
|
|
32327
32864
|
… truncated: the full code is in ${dir}`;
|
|
32328
32865
|
return { content: [{ type: "text", text: json(meta) }, { type: "text", text }] };
|
|
32329
32866
|
}
|
|
32330
|
-
mkdirSync6(
|
|
32867
|
+
mkdirSync6(join10(dir, "assets"), { recursive: true });
|
|
32331
32868
|
for (const [file, bytes] of Object.entries(assetBytes))
|
|
32332
|
-
writeFileSync6(
|
|
32869
|
+
writeFileSync6(join10(dir, "assets", file), bytes);
|
|
32333
32870
|
const fw = framework ?? "html";
|
|
32334
32871
|
const code = generateCode(tree, { framework: fw, styling: styling ?? "css", assetPath: (f) => `assets/${f}` });
|
|
32335
32872
|
for (const f of code.files)
|
|
32336
|
-
writeFileSync6(
|
|
32873
|
+
writeFileSync6(join10(dir, f.path), f.content);
|
|
32337
32874
|
warnings.push(...code.warnings);
|
|
32338
32875
|
const meta = {
|
|
32339
32876
|
dir,
|
|
@@ -32485,21 +33022,22 @@ ${f.content}`).join(`
|
|
|
32485
33022
|
server.registerTool("export_roblox", {
|
|
32486
33023
|
title: "Export a frame to Roblox UI",
|
|
32487
33024
|
description: "Turn a Figma frame (default: selection) into Roblox UI that looks the same: a .rbxmx model (a ScreenGui, or a Frame with asRootFrame), an equivalent Luau builder " + `script for execute_luau (creates the UI under PARENT, default StarterGui, replaces a previous copy with the same name, returns the root), and the PNG assets.
|
|
32488
|
-
` + "
|
|
32489
|
-
` + "
|
|
33025
|
+
` + "Everything is in SCALE by default: sizes, positions, UIPadding, list gaps, grid cells, UICorner, UIStroke (StrokeSizingMode ScaledSize) and UIShadow are relative, " + "text uses TextScaled with a UITextSizeConstraint, and nothing is in offset. Instances get professional PascalCase names (ShopGui, ItemCard, TitleLabel, BuyButton, CoinIcon, " + `CardList, Header): the layer's own name, or its role when Figma named it (Frame 12 → Card, Container, Row…).
|
|
33026
|
+
` + "Frames become Frames with UICorner, UIStroke, UIGradient, UIShadow, UIPadding; auto-layout becomes UIListLayout (UIFlexItem for fill) or UIGridLayout; " + "constraints become AnchorPoint + UDim2; text becomes TextLabel with FontFace (Inter → Builder Sans…), RichText for mixed styles; 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.
|
|
33027
|
+
` + "mode: scale (default, recommended); offset (pixels) or hybrid (pixels inside auto-layout) only when the user asks for them. " + "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. " + 'When writing Roblox UI yourself, follow the same rules: scale only, PascalCase role names. Example: {"nodeId":"12:34"}',
|
|
32490
33028
|
inputSchema: {
|
|
32491
33029
|
nodeId: string2().optional(),
|
|
32492
|
-
mode: _enum(["scale", "offset", "hybrid"]).optional().describe("Default hybrid"),
|
|
33030
|
+
mode: _enum(["scale", "offset", "hybrid"]).optional().describe("Default scale (no offsets); offset or hybrid only if the user asks"),
|
|
32493
33031
|
targetResolution: array(number2().int().min(100).max(8000)).length(2).optional().describe("Screen size the UI is designed for, default [1920, 1080]"),
|
|
32494
33032
|
rasterize: _enum(["auto", "none", "all"]).optional().describe("auto (default): pictures only where Roblox can't draw it; none: approximate instead; all: every styled layer"),
|
|
32495
33033
|
outDir: string2().optional().describe("Folder for the files (default: a new temp folder)"),
|
|
32496
33034
|
upload: boolean2().optional().describe("Upload the pictures through Open Cloud (needs ROBLOX_API_KEY and ROBLOX_CREATOR_ID)"),
|
|
32497
33035
|
asRootFrame: boolean2().optional().describe("Root is a Frame instead of a ScreenGui"),
|
|
32498
|
-
textScaled: boolean2().optional().describe("TextScaled on labels
|
|
33036
|
+
textScaled: boolean2().optional().describe("TextScaled on labels in offset or hybrid mode (always on in scale mode)"),
|
|
32499
33037
|
fonts: object2({}).passthrough().optional().describe('Figma family → Roblox family or rbxasset URL, e.g. {"Inter":"GothamSSm"}'),
|
|
32500
33038
|
parent: string2().optional().describe('Luau expression for the parent, default game:GetService("StarterGui")')
|
|
32501
33039
|
}
|
|
32502
|
-
}, (args) => track("export_roblox", `${args.nodeId ?? "(selection)"} ${args.mode ?? "
|
|
33040
|
+
}, (args) => track("export_roblox", `${args.nodeId ?? "(selection)"} ${args.mode ?? "scale"}`, async () => {
|
|
32503
33041
|
const r = await exportRoblox({ ...args, targetResolution: args.targetResolution, fonts: args.fonts }, { request: (m, p, t) => bridge.request(m, p, t), outDir: OUT_DIR });
|
|
32504
33042
|
let luau = r.luau;
|
|
32505
33043
|
if (luau.length > MAX_OUTPUT_CHARS)
|
|
@@ -32631,154 +33169,20 @@ ${f.content}`).join(`
|
|
|
32631
33169
|
});
|
|
32632
33170
|
|
|
32633
33171
|
// src/cli.ts
|
|
32634
|
-
|
|
32635
|
-
import { dirname as dirname5, join as join10 } from "node:path";
|
|
32636
|
-
import { fileURLToPath as fileURLToPath2 } from "node:url";
|
|
32637
|
-
|
|
32638
|
-
// src/setup.ts
|
|
32639
|
-
import { copyFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
32640
|
-
import { dirname, join } from "node:path";
|
|
32641
|
-
var SERVER_NAME = "FigmaBridge";
|
|
32642
|
-
function clientList(home, appData) {
|
|
32643
|
-
return [
|
|
32644
|
-
{ id: "claude-code", label: "Claude Code", files: [join(home, ".claude.json")], format: "json", extra: { type: "stdio" } },
|
|
32645
|
-
{ id: "claude-desktop", label: "Claude Desktop", files: [join(appData, "Claude", "claude_desktop_config.json")], format: "json" },
|
|
32646
|
-
{ id: "codex", label: "Codex", files: [join(home, ".codex", "config.toml")], format: "toml" },
|
|
32647
|
-
{
|
|
32648
|
-
id: "antigravity",
|
|
32649
|
-
label: "Antigravity",
|
|
32650
|
-
files: [join(home, ".gemini", "antigravity", "mcp_config.json"), join(home, ".gemini", "config", "mcp_config.json")],
|
|
32651
|
-
format: "json"
|
|
32652
|
-
},
|
|
32653
|
-
{ id: "gemini-cli", label: "Gemini CLI", files: [join(home, ".gemini", "settings.json")], format: "json" },
|
|
32654
|
-
{ id: "cursor", label: "Cursor", files: [join(home, ".cursor", "mcp.json")], format: "json" },
|
|
32655
|
-
{ id: "windsurf", label: "Windsurf", files: [join(home, ".codeium", "windsurf", "mcp_config.json")], format: "json" }
|
|
32656
|
-
];
|
|
32657
|
-
}
|
|
32658
|
-
function backup(path, text) {
|
|
32659
|
-
writeFileSync(path + ".bak-figma-bridge", text);
|
|
32660
|
-
}
|
|
32661
|
-
function writeJson(path, entry) {
|
|
32662
|
-
const text = existsSync(path) ? readFileSync(path, "utf8") : "";
|
|
32663
|
-
const config = text.trim() ? JSON.parse(text) : {};
|
|
32664
|
-
if (text.trim())
|
|
32665
|
-
backup(path, text);
|
|
32666
|
-
config.mcpServers = { ...config.mcpServers, [SERVER_NAME]: entry };
|
|
32667
|
-
mkdirSync(dirname(path), { recursive: true });
|
|
32668
|
-
writeFileSync(path, JSON.stringify(config, null, 2) + `
|
|
32669
|
-
`);
|
|
32670
|
-
}
|
|
32671
|
-
function tomlBlock(command, args) {
|
|
32672
|
-
return [
|
|
32673
|
-
`[mcp_servers.${SERVER_NAME}]`,
|
|
32674
|
-
`command = '${command}'`,
|
|
32675
|
-
`args = [${args.map((a) => `'${a}'`).join(", ")}]`,
|
|
32676
|
-
"startup_timeout_sec = 60",
|
|
32677
|
-
"tool_timeout_sec = 120"
|
|
32678
|
-
].join(`
|
|
32679
|
-
`);
|
|
32680
|
-
}
|
|
32681
|
-
function writeToml(path, command, args) {
|
|
32682
|
-
const text = existsSync(path) ? readFileSync(path, "utf8") : "";
|
|
32683
|
-
if (text.trim())
|
|
32684
|
-
backup(path, text);
|
|
32685
|
-
const kept = [];
|
|
32686
|
-
let skipping = false;
|
|
32687
|
-
for (const line of text.split(/\r?\n/)) {
|
|
32688
|
-
const header = /^\s*\[([^\]]+)\]\s*$/.exec(line);
|
|
32689
|
-
if (header)
|
|
32690
|
-
skipping = header[1] === `mcp_servers.${SERVER_NAME}` || header[1].startsWith(`mcp_servers.${SERVER_NAME}.`);
|
|
32691
|
-
if (!skipping)
|
|
32692
|
-
kept.push(line);
|
|
32693
|
-
}
|
|
32694
|
-
const body = kept.join(`
|
|
32695
|
-
`).trimEnd();
|
|
32696
|
-
mkdirSync(dirname(path), { recursive: true });
|
|
32697
|
-
writeFileSync(path, (body ? body + `
|
|
32698
|
-
|
|
32699
|
-
` : "") + tomlBlock(command, args) + `
|
|
32700
|
-
`);
|
|
32701
|
-
}
|
|
32702
|
-
function runSetup(opts) {
|
|
32703
|
-
const all = clientList(opts.home, opts.appData);
|
|
32704
|
-
const forced = opts.clients ?? [];
|
|
32705
|
-
const unknown = forced.filter((id) => !all.some((c) => c.id === id));
|
|
32706
|
-
if (unknown.length)
|
|
32707
|
-
throw new Error(`Unknown client(s): ${unknown.join(", ")}. Known: ${all.map((c) => c.id).join(", ")}`);
|
|
32708
|
-
const result = { updated: [], failed: [], missing: [] };
|
|
32709
|
-
for (const client of all) {
|
|
32710
|
-
const detected = client.files.filter((f) => existsSync(dirname(f)) && (client.id !== "gemini-cli" || existsSync(f)));
|
|
32711
|
-
const targets = forced.length ? forced.includes(client.id) ? detected.length ? detected : [client.files[0]] : [] : detected;
|
|
32712
|
-
if (!targets.length) {
|
|
32713
|
-
if (!forced.length)
|
|
32714
|
-
result.missing.push(client.label);
|
|
32715
|
-
continue;
|
|
32716
|
-
}
|
|
32717
|
-
for (const file of targets) {
|
|
32718
|
-
try {
|
|
32719
|
-
if (client.format === "toml")
|
|
32720
|
-
writeToml(file, opts.command, opts.args);
|
|
32721
|
-
else
|
|
32722
|
-
writeJson(file, { ...client.extra, command: opts.command, args: opts.args });
|
|
32723
|
-
result.updated.push({ client: client.id, label: client.label, file });
|
|
32724
|
-
} catch (e) {
|
|
32725
|
-
result.failed.push({ client: client.id, label: client.label, file, error: e.message });
|
|
32726
|
-
}
|
|
32727
|
-
}
|
|
32728
|
-
}
|
|
32729
|
-
return result;
|
|
32730
|
-
}
|
|
32731
|
-
function installPlugin(from, to) {
|
|
32732
|
-
mkdirSync(to, { recursive: true });
|
|
32733
|
-
for (const f of ["manifest.json", "code.js", "ui.html"])
|
|
32734
|
-
copyFileSync(join(from, f), join(to, f));
|
|
32735
|
-
return join(to, "manifest.json");
|
|
32736
|
-
}
|
|
32737
|
-
var color = (code) => (s) => process.stdout.isTTY ? `\x1B[${code}m${s}\x1B[0m` : s;
|
|
32738
|
-
var bold = color(1);
|
|
32739
|
-
var green = color(32);
|
|
32740
|
-
var dim = color(2);
|
|
32741
|
-
var yellow = color(33);
|
|
32742
|
-
function printSetup(r, rerun) {
|
|
32743
|
-
for (const label of r.missing)
|
|
32744
|
-
console.log(dim(` – ${label.padEnd(15)} not found`));
|
|
32745
|
-
for (const u of r.updated)
|
|
32746
|
-
console.log(green(` ✔ ${u.label.padEnd(15)}`) + dim(u.file));
|
|
32747
|
-
for (const f of r.failed)
|
|
32748
|
-
console.log(yellow(` ! ${f.label.padEnd(15)}could not update ${f.file}: ${f.error}`));
|
|
32749
|
-
if (!r.updated.length)
|
|
32750
|
-
console.log(yellow(`
|
|
32751
|
-
No AI client found. Use \`${rerun} --client <name>\` or \`${rerun} --print\` for manual config.`));
|
|
32752
|
-
}
|
|
32753
|
-
function printSnippets(command, args) {
|
|
32754
|
-
console.log(bold("JSON clients") + dim(" (Claude, Antigravity, Gemini CLI, Cursor, Windsurf…)"));
|
|
32755
|
-
console.log(JSON.stringify({ mcpServers: { [SERVER_NAME]: { command, args } } }, null, 2));
|
|
32756
|
-
console.log(bold(`
|
|
32757
|
-
Codex`) + dim(" (~/.codex/config.toml)"));
|
|
32758
|
-
console.log(tomlBlock(command, args) + `
|
|
32759
|
-
`);
|
|
32760
|
-
}
|
|
32761
|
-
function parseClients(argv) {
|
|
32762
|
-
const out = [];
|
|
32763
|
-
argv.forEach((a, i) => {
|
|
32764
|
-
if (a === "--client" && argv[i + 1])
|
|
32765
|
-
out.push(...argv[i + 1].split(","));
|
|
32766
|
-
else if (a.startsWith("--client="))
|
|
32767
|
-
out.push(...a.slice(9).split(","));
|
|
32768
|
-
});
|
|
32769
|
-
return out;
|
|
32770
|
-
}
|
|
32771
|
-
|
|
32772
|
-
// src/cli.ts
|
|
33172
|
+
init_setup();
|
|
32773
33173
|
init_package();
|
|
33174
|
+
import { homedir as homedir4 } from "node:os";
|
|
33175
|
+
import { dirname as dirname5, join as join11 } from "node:path";
|
|
33176
|
+
import { fileURLToPath as fileURLToPath2 } from "node:url";
|
|
32774
33177
|
var PACKAGE = "@clovis500c/figma-bridge";
|
|
32775
33178
|
var [cmd, ...rest] = process.argv.slice(2);
|
|
32776
|
-
var home =
|
|
32777
|
-
var pluginSource =
|
|
32778
|
-
var pluginTarget =
|
|
32779
|
-
var
|
|
33179
|
+
var home = homedir4();
|
|
33180
|
+
var pluginSource = join11(dirname5(fileURLToPath2(import.meta.url)), "..", "plugin");
|
|
33181
|
+
var pluginTarget = join11(home, ".figma-bridge", "plugin");
|
|
33182
|
+
var spec = `${PACKAGE}@latest`;
|
|
33183
|
+
var npx = process.platform === "win32" ? { command: "cmd", args: ["/c", "npx", "-y", spec] } : { command: "npx", args: ["-y", spec] };
|
|
32780
33184
|
function plugin() {
|
|
32781
|
-
const manifest = installPlugin(pluginSource, pluginTarget);
|
|
33185
|
+
const manifest = installPlugin(pluginSource, pluginTarget, version);
|
|
32782
33186
|
console.log(`${bold("Figma plugin")} copied to ${dim(pluginTarget)}`);
|
|
32783
33187
|
return manifest;
|
|
32784
33188
|
}
|
|
@@ -32787,7 +33191,7 @@ function next(manifest) {
|
|
|
32787
33191
|
${bold("Next")}
|
|
32788
33192
|
1. Figma desktop → Plugins → Development → Import plugin from manifest…
|
|
32789
33193
|
${green(manifest)}
|
|
32790
|
-
(once
|
|
33194
|
+
(once: updates install themselves when your AI client restarts; then reopen the plugin)
|
|
32791
33195
|
2. Restart your AI client, then run Plugins → Development → Figma Bridge in a Figma file.
|
|
32792
33196
|
`);
|
|
32793
33197
|
}
|
|
@@ -32805,7 +33209,7 @@ Figma Bridge setup
|
|
|
32805
33209
|
break;
|
|
32806
33210
|
}
|
|
32807
33211
|
try {
|
|
32808
|
-
const appData = process.env.APPDATA ||
|
|
33212
|
+
const appData = process.env.APPDATA || join11(home, "AppData", "Roaming");
|
|
32809
33213
|
printSetup(runSetup({ home, appData, command: npx.command, args: npx.args, clients: parseClients(rest) }), `npx ${PACKAGE} setup`);
|
|
32810
33214
|
} catch (e) {
|
|
32811
33215
|
console.log(yellow(e.message));
|