figma-coder-mcp 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +83 -0
- package/dist/bin.js +2154 -0
- package/package.json +53 -0
package/dist/bin.js
ADDED
|
@@ -0,0 +1,2154 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { createRequire as __createRequire } from 'module';
|
|
3
|
+
const require = __createRequire(import.meta.url);
|
|
4
|
+
var __create = Object.create;
|
|
5
|
+
var __defProp = Object.defineProperty;
|
|
6
|
+
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
7
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
8
|
+
var __getProtoOf = Object.getPrototypeOf;
|
|
9
|
+
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
10
|
+
var __require = /* @__PURE__ */ ((x) => typeof require !== "undefined" ? require : typeof Proxy !== "undefined" ? new Proxy(x, {
|
|
11
|
+
get: (a, b) => (typeof require !== "undefined" ? require : a)[b]
|
|
12
|
+
}) : x)(function(x) {
|
|
13
|
+
if (typeof require !== "undefined") return require.apply(this, arguments);
|
|
14
|
+
throw Error('Dynamic require of "' + x + '" is not supported');
|
|
15
|
+
});
|
|
16
|
+
var __commonJS = (cb, mod) => function __require2() {
|
|
17
|
+
return mod || (0, cb[__getOwnPropNames(cb)[0]])((mod = { exports: {} }).exports, mod), mod.exports;
|
|
18
|
+
};
|
|
19
|
+
var __copyProps = (to, from, except, desc) => {
|
|
20
|
+
if (from && typeof from === "object" || typeof from === "function") {
|
|
21
|
+
for (let key of __getOwnPropNames(from))
|
|
22
|
+
if (!__hasOwnProp.call(to, key) && key !== except)
|
|
23
|
+
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
|
|
24
|
+
}
|
|
25
|
+
return to;
|
|
26
|
+
};
|
|
27
|
+
var __toESM = (mod, isNodeMode, target2) => (target2 = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(
|
|
28
|
+
// If the importer is in node compatibility mode or this is not an ESM
|
|
29
|
+
// file that has been converted to a CommonJS file using a Babel-
|
|
30
|
+
// compatible transform (i.e. "__esModule" has not been set), then set
|
|
31
|
+
// "default" to the CommonJS "module.exports" for node compatibility.
|
|
32
|
+
isNodeMode || !mod || !mod.__esModule ? __defProp(target2, "default", { value: mod, enumerable: true }) : target2,
|
|
33
|
+
mod
|
|
34
|
+
));
|
|
35
|
+
|
|
36
|
+
// ../packages/figma-core/dist/errors.js
|
|
37
|
+
var require_errors = __commonJS({
|
|
38
|
+
"../packages/figma-core/dist/errors.js"(exports) {
|
|
39
|
+
"use strict";
|
|
40
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
41
|
+
exports.FigmaApiError = exports.FigmaInputError = void 0;
|
|
42
|
+
var FigmaInputError = class extends Error {
|
|
43
|
+
constructor(message) {
|
|
44
|
+
super(message);
|
|
45
|
+
this.status = 400;
|
|
46
|
+
this.name = "FigmaInputError";
|
|
47
|
+
}
|
|
48
|
+
};
|
|
49
|
+
exports.FigmaInputError = FigmaInputError;
|
|
50
|
+
var FigmaApiError = class extends Error {
|
|
51
|
+
constructor(message, status, retryAfter, rateLimitType) {
|
|
52
|
+
super(message);
|
|
53
|
+
this.status = status;
|
|
54
|
+
this.retryAfter = retryAfter;
|
|
55
|
+
this.rateLimitType = rateLimitType;
|
|
56
|
+
this.name = "FigmaApiError";
|
|
57
|
+
}
|
|
58
|
+
};
|
|
59
|
+
exports.FigmaApiError = FigmaApiError;
|
|
60
|
+
}
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
// ../packages/figma-core/dist/adapters.js
|
|
64
|
+
var require_adapters = __commonJS({
|
|
65
|
+
"../packages/figma-core/dist/adapters.js"(exports) {
|
|
66
|
+
"use strict";
|
|
67
|
+
var __createBinding = exports && exports.__createBinding || (Object.create ? (function(o, m, k, k2) {
|
|
68
|
+
if (k2 === void 0) k2 = k;
|
|
69
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
70
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
71
|
+
desc = { enumerable: true, get: function() {
|
|
72
|
+
return m[k];
|
|
73
|
+
} };
|
|
74
|
+
}
|
|
75
|
+
Object.defineProperty(o, k2, desc);
|
|
76
|
+
}) : (function(o, m, k, k2) {
|
|
77
|
+
if (k2 === void 0) k2 = k;
|
|
78
|
+
o[k2] = m[k];
|
|
79
|
+
}));
|
|
80
|
+
var __setModuleDefault = exports && exports.__setModuleDefault || (Object.create ? (function(o, v) {
|
|
81
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
82
|
+
}) : function(o, v) {
|
|
83
|
+
o["default"] = v;
|
|
84
|
+
});
|
|
85
|
+
var __importStar = exports && exports.__importStar || /* @__PURE__ */ (function() {
|
|
86
|
+
var ownKeys = function(o) {
|
|
87
|
+
ownKeys = Object.getOwnPropertyNames || function(o2) {
|
|
88
|
+
var ar = [];
|
|
89
|
+
for (var k in o2) if (Object.prototype.hasOwnProperty.call(o2, k)) ar[ar.length] = k;
|
|
90
|
+
return ar;
|
|
91
|
+
};
|
|
92
|
+
return ownKeys(o);
|
|
93
|
+
};
|
|
94
|
+
return function(mod) {
|
|
95
|
+
if (mod && mod.__esModule) return mod;
|
|
96
|
+
var result = {};
|
|
97
|
+
if (mod != null) {
|
|
98
|
+
for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
99
|
+
}
|
|
100
|
+
__setModuleDefault(result, mod);
|
|
101
|
+
return result;
|
|
102
|
+
};
|
|
103
|
+
})();
|
|
104
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
105
|
+
exports.NoopLogger = exports.ConsoleLogger = exports.DiskCache = exports.MemoryCache = void 0;
|
|
106
|
+
var crypto_1 = __require("crypto");
|
|
107
|
+
var fs_1 = __require("fs");
|
|
108
|
+
var path3 = __importStar(__require("path"));
|
|
109
|
+
var MemoryCache = class {
|
|
110
|
+
constructor() {
|
|
111
|
+
this.mem = /* @__PURE__ */ new Map();
|
|
112
|
+
}
|
|
113
|
+
async get(key) {
|
|
114
|
+
return this.mem.get(key);
|
|
115
|
+
}
|
|
116
|
+
async set(key, value) {
|
|
117
|
+
this.mem.set(key, value);
|
|
118
|
+
}
|
|
119
|
+
};
|
|
120
|
+
exports.MemoryCache = MemoryCache;
|
|
121
|
+
var DiskCache = class {
|
|
122
|
+
constructor(dir, logger) {
|
|
123
|
+
this.logger = logger;
|
|
124
|
+
this.mem = /* @__PURE__ */ new Map();
|
|
125
|
+
this.dir = dir ?? path3.resolve(process.cwd(), ".cache");
|
|
126
|
+
}
|
|
127
|
+
file(key) {
|
|
128
|
+
const hash = (0, crypto_1.createHash)("sha1").update(key).digest("hex");
|
|
129
|
+
return path3.join(this.dir, `${hash}.json`);
|
|
130
|
+
}
|
|
131
|
+
async get(key) {
|
|
132
|
+
if (this.mem.has(key))
|
|
133
|
+
return this.mem.get(key);
|
|
134
|
+
try {
|
|
135
|
+
const raw = await fs_1.promises.readFile(this.file(key), "utf8");
|
|
136
|
+
const value = JSON.parse(raw);
|
|
137
|
+
this.mem.set(key, value);
|
|
138
|
+
return value;
|
|
139
|
+
} catch {
|
|
140
|
+
return void 0;
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
async set(key, value) {
|
|
144
|
+
this.mem.set(key, value);
|
|
145
|
+
try {
|
|
146
|
+
await fs_1.promises.mkdir(this.dir, { recursive: true });
|
|
147
|
+
await fs_1.promises.writeFile(this.file(key), JSON.stringify(value));
|
|
148
|
+
} catch (err) {
|
|
149
|
+
this.logger?.warn(`cache write failed for "${key}": ${err.message}`);
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
};
|
|
153
|
+
exports.DiskCache = DiskCache;
|
|
154
|
+
var ConsoleLogger = class {
|
|
155
|
+
constructor(prefix = "figma-core") {
|
|
156
|
+
this.prefix = prefix;
|
|
157
|
+
}
|
|
158
|
+
log(m) {
|
|
159
|
+
console.error(`[${this.prefix}] ${m}`);
|
|
160
|
+
}
|
|
161
|
+
warn(m) {
|
|
162
|
+
console.error(`[${this.prefix}] WARN ${m}`);
|
|
163
|
+
}
|
|
164
|
+
error(m) {
|
|
165
|
+
console.error(`[${this.prefix}] ERROR ${m}`);
|
|
166
|
+
}
|
|
167
|
+
};
|
|
168
|
+
exports.ConsoleLogger = ConsoleLogger;
|
|
169
|
+
var NoopLogger = class {
|
|
170
|
+
log() {
|
|
171
|
+
}
|
|
172
|
+
warn() {
|
|
173
|
+
}
|
|
174
|
+
error() {
|
|
175
|
+
}
|
|
176
|
+
};
|
|
177
|
+
exports.NoopLogger = NoopLogger;
|
|
178
|
+
}
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
// ../packages/figma-core/dist/figma/url.js
|
|
182
|
+
var require_url = __commonJS({
|
|
183
|
+
"../packages/figma-core/dist/figma/url.js"(exports) {
|
|
184
|
+
"use strict";
|
|
185
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
186
|
+
exports.parseFigmaUrl = parseFigmaUrl;
|
|
187
|
+
exports.normalizeNodeId = normalizeNodeId;
|
|
188
|
+
exports.resolveFigmaTarget = resolveFigmaTarget;
|
|
189
|
+
function parseFigmaUrl(url) {
|
|
190
|
+
const keyMatch = url.match(/figma\.com\/(?:file|design)\/([A-Za-z0-9]+)/);
|
|
191
|
+
if (!keyMatch) {
|
|
192
|
+
throw new Error(`Could not extract fileKey from Figma URL: ${url}`);
|
|
193
|
+
}
|
|
194
|
+
const fileKey = keyMatch[1];
|
|
195
|
+
let nodeId;
|
|
196
|
+
const nodeMatch = url.match(/[?&]node-id=([^&]+)/);
|
|
197
|
+
if (nodeMatch) {
|
|
198
|
+
nodeId = normalizeNodeId(decodeURIComponent(nodeMatch[1]));
|
|
199
|
+
}
|
|
200
|
+
return { fileKey, nodeId };
|
|
201
|
+
}
|
|
202
|
+
function normalizeNodeId(nodeId) {
|
|
203
|
+
return nodeId.includes(":") ? nodeId : nodeId.replace("-", ":");
|
|
204
|
+
}
|
|
205
|
+
function resolveFigmaTarget(input) {
|
|
206
|
+
if (input.figmaUrl) {
|
|
207
|
+
const parsed = parseFigmaUrl(input.figmaUrl);
|
|
208
|
+
return {
|
|
209
|
+
fileKey: parsed.fileKey,
|
|
210
|
+
nodeId: input.nodeId ? normalizeNodeId(input.nodeId) : parsed.nodeId
|
|
211
|
+
};
|
|
212
|
+
}
|
|
213
|
+
if (input.fileKey) {
|
|
214
|
+
return {
|
|
215
|
+
fileKey: input.fileKey,
|
|
216
|
+
nodeId: input.nodeId ? normalizeNodeId(input.nodeId) : void 0
|
|
217
|
+
};
|
|
218
|
+
}
|
|
219
|
+
throw new Error("Provide either figmaUrl or fileKey");
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
// ../packages/figma-core/dist/figma/client.js
|
|
225
|
+
var require_client = __commonJS({
|
|
226
|
+
"../packages/figma-core/dist/figma/client.js"(exports) {
|
|
227
|
+
"use strict";
|
|
228
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
229
|
+
exports.FigmaClient = void 0;
|
|
230
|
+
var url_1 = require_url();
|
|
231
|
+
var errors_1 = require_errors();
|
|
232
|
+
var FIGMA_API = "https://api.figma.com/v1";
|
|
233
|
+
var FigmaClient = class {
|
|
234
|
+
constructor(opts) {
|
|
235
|
+
this.cache = opts.cache;
|
|
236
|
+
this.logger = opts.logger;
|
|
237
|
+
this.fallbackToken = opts.fallbackToken;
|
|
238
|
+
}
|
|
239
|
+
resolveAuth(requestAuth) {
|
|
240
|
+
if (requestAuth?.token)
|
|
241
|
+
return requestAuth;
|
|
242
|
+
if (this.fallbackToken)
|
|
243
|
+
return { token: this.fallbackToken, bearer: false };
|
|
244
|
+
throw new errors_1.FigmaInputError("No Figma credentials. Provide a token (PAT or OAuth) or configure a fallback token.");
|
|
245
|
+
}
|
|
246
|
+
async get(path3, auth) {
|
|
247
|
+
const res = await fetch(`${FIGMA_API}${path3}`, {
|
|
248
|
+
headers: auth.bearer ? { Authorization: `Bearer ${auth.token}` } : { "X-Figma-Token": auth.token }
|
|
249
|
+
});
|
|
250
|
+
if (!res.ok) {
|
|
251
|
+
const body = await res.text().catch(() => "");
|
|
252
|
+
if (res.status === 429) {
|
|
253
|
+
const retryAfter = Number(res.headers.get("retry-after")) || void 0;
|
|
254
|
+
throw new errors_1.FigmaApiError(`Figma rate limit exceeded for ${path3}.` + (retryAfter ? ` Retry after ~${retryAfter}s (~${Math.round(retryAfter / 3600)}h).` : ""), 429, retryAfter, res.headers.get("x-figma-rate-limit-type") || void 0);
|
|
255
|
+
}
|
|
256
|
+
if (res.status === 403) {
|
|
257
|
+
throw new errors_1.FigmaApiError(auth.bearer ? 'Figma denied access (403). The logged-in Figma account has no access to this file, or the OAuth app is missing the "file_content:read" scope. Open a file your account can access (or duplicate the Community file into your drafts), and ensure the scope is enabled.' : "Figma denied access (403). This token cannot access the file. Check the token and that the account has access to the file.", 403);
|
|
258
|
+
}
|
|
259
|
+
throw new errors_1.FigmaApiError(`Figma API ${res.status} for ${path3}: ${body.slice(0, 300)}`, res.status);
|
|
260
|
+
}
|
|
261
|
+
return await res.json();
|
|
262
|
+
}
|
|
263
|
+
/**
|
|
264
|
+
* Fetch a single node subtree. If nodeId is omitted, fetches the whole file
|
|
265
|
+
* and returns its document root.
|
|
266
|
+
*/
|
|
267
|
+
async fetchNode(fileKey, nodeId, requestAuth) {
|
|
268
|
+
const auth = this.resolveAuth(requestAuth);
|
|
269
|
+
const cacheKey = `node:${fileKey}:${nodeId ?? "ROOT"}`;
|
|
270
|
+
const cached = await this.cache.get(cacheKey);
|
|
271
|
+
if (cached) {
|
|
272
|
+
this.logger.log(`fetchNode cache hit (${cacheKey})`);
|
|
273
|
+
return cached;
|
|
274
|
+
}
|
|
275
|
+
let document;
|
|
276
|
+
if (!nodeId) {
|
|
277
|
+
const file = await this.get(`/files/${fileKey}`, auth);
|
|
278
|
+
document = file.document;
|
|
279
|
+
} else {
|
|
280
|
+
const id = (0, url_1.normalizeNodeId)(nodeId);
|
|
281
|
+
const data = await this.get(`/files/${fileKey}/nodes?ids=${encodeURIComponent(id)}`, auth);
|
|
282
|
+
const entry = data.nodes[id];
|
|
283
|
+
if (!entry?.document) {
|
|
284
|
+
throw new errors_1.FigmaInputError(`Node ${id} not found in file ${fileKey}`);
|
|
285
|
+
}
|
|
286
|
+
document = entry.document;
|
|
287
|
+
}
|
|
288
|
+
await this.cache.set(cacheKey, document);
|
|
289
|
+
return document;
|
|
290
|
+
}
|
|
291
|
+
/**
|
|
292
|
+
* Render image fills for the given node ids (used by Phase 1 asset export
|
|
293
|
+
* and Phase 4 visual verification).
|
|
294
|
+
*/
|
|
295
|
+
async renderImages(fileKey, nodeIds, format = "svg", requestAuth, scale = 1) {
|
|
296
|
+
const auth = this.resolveAuth(requestAuth);
|
|
297
|
+
const ids = nodeIds.map(url_1.normalizeNodeId).join(",");
|
|
298
|
+
const data = await this.get(`/images/${fileKey}?ids=${encodeURIComponent(ids)}&format=${format}&scale=${scale}`, auth);
|
|
299
|
+
if (data.err) {
|
|
300
|
+
throw new errors_1.FigmaApiError(`Figma image render error: ${data.err}`, 502);
|
|
301
|
+
}
|
|
302
|
+
return data.images;
|
|
303
|
+
}
|
|
304
|
+
/**
|
|
305
|
+
* Fetch every image FILL URL in the file (imageRef -> CDN URL) in one call.
|
|
306
|
+
*
|
|
307
|
+
* Unlike /v1/images (server-side render — the most rate-limited, cost-based
|
|
308
|
+
* Figma endpoint), this returns the originals already uploaded to Figma's
|
|
309
|
+
* CDN. So all image fills cost a single cheap request regardless of how many
|
|
310
|
+
* nodes use them, and survive when the render endpoint is throttled.
|
|
311
|
+
*/
|
|
312
|
+
async getImageFills(fileKey, requestAuth) {
|
|
313
|
+
const auth = this.resolveAuth(requestAuth);
|
|
314
|
+
const data = await this.get(`/files/${fileKey}/images`, auth);
|
|
315
|
+
return data.meta?.images ?? {};
|
|
316
|
+
}
|
|
317
|
+
};
|
|
318
|
+
exports.FigmaClient = FigmaClient;
|
|
319
|
+
}
|
|
320
|
+
});
|
|
321
|
+
|
|
322
|
+
// ../packages/figma-core/dist/figma/types.js
|
|
323
|
+
var require_types = __commonJS({
|
|
324
|
+
"../packages/figma-core/dist/figma/types.js"(exports) {
|
|
325
|
+
"use strict";
|
|
326
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
327
|
+
}
|
|
328
|
+
});
|
|
329
|
+
|
|
330
|
+
// ../packages/figma-core/dist/converter/color.js
|
|
331
|
+
var require_color = __commonJS({
|
|
332
|
+
"../packages/figma-core/dist/converter/color.js"(exports) {
|
|
333
|
+
"use strict";
|
|
334
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
335
|
+
exports.figmaColorToCss = figmaColorToCss;
|
|
336
|
+
exports.px = px;
|
|
337
|
+
function channel(v) {
|
|
338
|
+
return Math.round(Math.max(0, Math.min(1, v)) * 255);
|
|
339
|
+
}
|
|
340
|
+
function toHex2(n) {
|
|
341
|
+
return n.toString(16).padStart(2, "0");
|
|
342
|
+
}
|
|
343
|
+
function figmaColorToCss(color, extraOpacity = 1) {
|
|
344
|
+
const r = channel(color.r);
|
|
345
|
+
const g = channel(color.g);
|
|
346
|
+
const b = channel(color.b);
|
|
347
|
+
const a = Math.max(0, Math.min(1, (color.a ?? 1) * extraOpacity));
|
|
348
|
+
if (a >= 1) {
|
|
349
|
+
return `#${toHex2(r)}${toHex2(g)}${toHex2(b)}`;
|
|
350
|
+
}
|
|
351
|
+
return `rgba(${r}, ${g}, ${b}, ${Number(a.toFixed(3))})`;
|
|
352
|
+
}
|
|
353
|
+
function px(value) {
|
|
354
|
+
return `${Number(value.toFixed(2))}px`;
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
});
|
|
358
|
+
|
|
359
|
+
// ../packages/figma-core/dist/converter/style-ir.js
|
|
360
|
+
var require_style_ir = __commonJS({
|
|
361
|
+
"../packages/figma-core/dist/converter/style-ir.js"(exports) {
|
|
362
|
+
"use strict";
|
|
363
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
364
|
+
exports.StyleIr = void 0;
|
|
365
|
+
var color_1 = require_color();
|
|
366
|
+
var ALIGN_MAIN = {
|
|
367
|
+
MIN: "flex-start",
|
|
368
|
+
CENTER: "center",
|
|
369
|
+
MAX: "flex-end",
|
|
370
|
+
SPACE_BETWEEN: "space-between"
|
|
371
|
+
};
|
|
372
|
+
var ALIGN_CROSS = {
|
|
373
|
+
MIN: "flex-start",
|
|
374
|
+
CENTER: "center",
|
|
375
|
+
MAX: "flex-end",
|
|
376
|
+
BASELINE: "baseline"
|
|
377
|
+
};
|
|
378
|
+
var StyleIr = class {
|
|
379
|
+
/** Convert a Figma node subtree into the Style IR tree. */
|
|
380
|
+
convert(root) {
|
|
381
|
+
return this.walk(root, void 0);
|
|
382
|
+
}
|
|
383
|
+
walk(node, parent) {
|
|
384
|
+
const css = {};
|
|
385
|
+
const asset = this.detectAsset(node);
|
|
386
|
+
this.applyLayout(node, parent, css);
|
|
387
|
+
this.applySize(node, parent, css);
|
|
388
|
+
if (!node.isMask) {
|
|
389
|
+
this.applyFills(node, css, asset);
|
|
390
|
+
this.applyStroke(node, css);
|
|
391
|
+
this.applyRadius(node, css);
|
|
392
|
+
this.applyEffects(node, css);
|
|
393
|
+
}
|
|
394
|
+
this.applyOpacity(node, css);
|
|
395
|
+
const isText = node.type === "TEXT";
|
|
396
|
+
if (isText)
|
|
397
|
+
this.applyText(node, css);
|
|
398
|
+
const tag = isText ? "p" : "div";
|
|
399
|
+
const visibleChildren = (node.children ?? []).filter((c) => c.visible !== false);
|
|
400
|
+
const maskChild = visibleChildren.find((c) => c.isMask);
|
|
401
|
+
if (maskChild) {
|
|
402
|
+
css.overflow = "hidden";
|
|
403
|
+
if (!css.position)
|
|
404
|
+
css.position = "relative";
|
|
405
|
+
this.applyRadius(maskChild, css);
|
|
406
|
+
}
|
|
407
|
+
const children = visibleChildren.map((c) => this.walk(c, node));
|
|
408
|
+
return {
|
|
409
|
+
id: node.id,
|
|
410
|
+
name: node.name,
|
|
411
|
+
figmaType: node.type,
|
|
412
|
+
tag,
|
|
413
|
+
...isText ? { text: node.characters ?? "" } : {},
|
|
414
|
+
css,
|
|
415
|
+
...asset ? { asset } : {},
|
|
416
|
+
children
|
|
417
|
+
};
|
|
418
|
+
}
|
|
419
|
+
// ---- layout -------------------------------------------------------------
|
|
420
|
+
applyLayout(node, parent, css) {
|
|
421
|
+
const auto = node.layoutMode === "HORIZONTAL" || node.layoutMode === "VERTICAL";
|
|
422
|
+
if (auto) {
|
|
423
|
+
css.display = "flex";
|
|
424
|
+
css["flex-direction"] = node.layoutMode === "VERTICAL" ? "column" : "row";
|
|
425
|
+
if (node.layoutWrap === "WRAP")
|
|
426
|
+
css["flex-wrap"] = "wrap";
|
|
427
|
+
if (node.primaryAxisAlignItems && ALIGN_MAIN[node.primaryAxisAlignItems]) {
|
|
428
|
+
css["justify-content"] = ALIGN_MAIN[node.primaryAxisAlignItems];
|
|
429
|
+
}
|
|
430
|
+
if (node.counterAxisAlignItems && ALIGN_CROSS[node.counterAxisAlignItems]) {
|
|
431
|
+
css["align-items"] = ALIGN_CROSS[node.counterAxisAlignItems];
|
|
432
|
+
}
|
|
433
|
+
if (node.itemSpacing)
|
|
434
|
+
css.gap = (0, color_1.px)(node.itemSpacing);
|
|
435
|
+
const pt = node.paddingTop ?? 0;
|
|
436
|
+
const pr = node.paddingRight ?? 0;
|
|
437
|
+
const pb = node.paddingBottom ?? 0;
|
|
438
|
+
const pl = node.paddingLeft ?? 0;
|
|
439
|
+
if (pt || pr || pb || pl) {
|
|
440
|
+
css.padding = `${(0, color_1.px)(pt)} ${(0, color_1.px)(pr)} ${(0, color_1.px)(pb)} ${(0, color_1.px)(pl)}`;
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
const parentAuto = parent && (parent.layoutMode === "HORIZONTAL" || parent.layoutMode === "VERTICAL");
|
|
444
|
+
if (parent && !parentAuto && node.absoluteBoundingBox && parent.absoluteBoundingBox) {
|
|
445
|
+
css.position = "absolute";
|
|
446
|
+
css.left = (0, color_1.px)(node.absoluteBoundingBox.x - parent.absoluteBoundingBox.x);
|
|
447
|
+
css.top = (0, color_1.px)(node.absoluteBoundingBox.y - parent.absoluteBoundingBox.y);
|
|
448
|
+
}
|
|
449
|
+
const hasChildren = (node.children?.length ?? 0) > 0;
|
|
450
|
+
if (!auto && hasChildren && !css.position) {
|
|
451
|
+
css.position = "relative";
|
|
452
|
+
}
|
|
453
|
+
if (node.clipsContent)
|
|
454
|
+
css.overflow = "hidden";
|
|
455
|
+
}
|
|
456
|
+
applySize(node, parent, css) {
|
|
457
|
+
const box = node.absoluteBoundingBox;
|
|
458
|
+
if (!box)
|
|
459
|
+
return;
|
|
460
|
+
css.width = (0, color_1.px)(box.width);
|
|
461
|
+
css.height = (0, color_1.px)(box.height);
|
|
462
|
+
}
|
|
463
|
+
// ---- paints -------------------------------------------------------------
|
|
464
|
+
visiblePaints(paints) {
|
|
465
|
+
return (paints ?? []).filter((p) => p.visible !== false);
|
|
466
|
+
}
|
|
467
|
+
applyFills(node, css, asset) {
|
|
468
|
+
if (node.type === "TEXT")
|
|
469
|
+
return;
|
|
470
|
+
if (asset?.kind === "vector")
|
|
471
|
+
return;
|
|
472
|
+
const fills = this.visiblePaints(node.fills);
|
|
473
|
+
if (!fills.length)
|
|
474
|
+
return;
|
|
475
|
+
const paint = fills[fills.length - 1];
|
|
476
|
+
if (paint.type === "SOLID" && paint.color) {
|
|
477
|
+
css["background-color"] = (0, color_1.figmaColorToCss)(paint.color, paint.opacity ?? 1);
|
|
478
|
+
} else if (paint.type === "GRADIENT_LINEAR") {
|
|
479
|
+
const grad = this.linearGradient(paint);
|
|
480
|
+
if (grad)
|
|
481
|
+
css["background-image"] = grad;
|
|
482
|
+
} else if (paint.type === "IMAGE") {
|
|
483
|
+
css["background-size"] = "cover";
|
|
484
|
+
css["background-position"] = "center";
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
linearGradient(paint) {
|
|
488
|
+
const stops = paint.gradientStops ?? [];
|
|
489
|
+
if (stops.length < 2)
|
|
490
|
+
return null;
|
|
491
|
+
let angle = 180;
|
|
492
|
+
const handles = paint.gradientHandlePositions;
|
|
493
|
+
if (handles && handles.length >= 2) {
|
|
494
|
+
const dx = handles[1].x - handles[0].x;
|
|
495
|
+
const dy = handles[1].y - handles[0].y;
|
|
496
|
+
angle = Math.round(Math.atan2(dy, dx) * 180 / Math.PI + 90);
|
|
497
|
+
}
|
|
498
|
+
const stopStr = stops.map((s) => `${(0, color_1.figmaColorToCss)(s.color, paint.opacity ?? 1)} ${Math.round(s.position * 100)}%`).join(", ");
|
|
499
|
+
return `linear-gradient(${angle}deg, ${stopStr})`;
|
|
500
|
+
}
|
|
501
|
+
applyStroke(node, css) {
|
|
502
|
+
const strokes = this.visiblePaints(node.strokes);
|
|
503
|
+
if (!strokes.length || !node.strokeWeight)
|
|
504
|
+
return;
|
|
505
|
+
const paint = strokes[strokes.length - 1];
|
|
506
|
+
if (paint.type === "SOLID" && paint.color) {
|
|
507
|
+
css.border = `${(0, color_1.px)(node.strokeWeight)} solid ${(0, color_1.figmaColorToCss)(paint.color, paint.opacity ?? 1)}`;
|
|
508
|
+
}
|
|
509
|
+
}
|
|
510
|
+
applyRadius(node, css) {
|
|
511
|
+
if (node.rectangleCornerRadii) {
|
|
512
|
+
const [tl, tr, br, bl] = node.rectangleCornerRadii;
|
|
513
|
+
if (tl || tr || br || bl) {
|
|
514
|
+
css["border-radius"] = `${(0, color_1.px)(tl)} ${(0, color_1.px)(tr)} ${(0, color_1.px)(br)} ${(0, color_1.px)(bl)}`;
|
|
515
|
+
}
|
|
516
|
+
} else if (node.cornerRadius) {
|
|
517
|
+
css["border-radius"] = (0, color_1.px)(node.cornerRadius);
|
|
518
|
+
}
|
|
519
|
+
}
|
|
520
|
+
applyEffects(node, css) {
|
|
521
|
+
const effects = (node.effects ?? []).filter((e) => e.visible !== false);
|
|
522
|
+
const shadows = [];
|
|
523
|
+
for (const e of effects) {
|
|
524
|
+
shadows.push(...this.effectToCss(e, css));
|
|
525
|
+
}
|
|
526
|
+
if (shadows.length)
|
|
527
|
+
css["box-shadow"] = shadows.join(", ");
|
|
528
|
+
}
|
|
529
|
+
effectToCss(e, css) {
|
|
530
|
+
if (e.type === "DROP_SHADOW" || e.type === "INNER_SHADOW") {
|
|
531
|
+
const c = e.color ? (0, color_1.figmaColorToCss)(e.color) : "rgba(0,0,0,0.25)";
|
|
532
|
+
const x = (0, color_1.px)(e.offset?.x ?? 0);
|
|
533
|
+
const y = (0, color_1.px)(e.offset?.y ?? 0);
|
|
534
|
+
const blur = (0, color_1.px)(e.radius ?? 0);
|
|
535
|
+
const spread = (0, color_1.px)(e.spread ?? 0);
|
|
536
|
+
const inset = e.type === "INNER_SHADOW" ? "inset " : "";
|
|
537
|
+
return [`${inset}${x} ${y} ${blur} ${spread} ${c}`];
|
|
538
|
+
}
|
|
539
|
+
if (e.type === "LAYER_BLUR") {
|
|
540
|
+
css.filter = `blur(${(0, color_1.px)(e.radius ?? 0)})`;
|
|
541
|
+
}
|
|
542
|
+
if (e.type === "BACKGROUND_BLUR") {
|
|
543
|
+
css["backdrop-filter"] = `blur(${(0, color_1.px)(e.radius ?? 0)})`;
|
|
544
|
+
}
|
|
545
|
+
return [];
|
|
546
|
+
}
|
|
547
|
+
applyOpacity(node, css) {
|
|
548
|
+
if (node.opacity !== void 0 && node.opacity < 1) {
|
|
549
|
+
css.opacity = String(Number(node.opacity.toFixed(3)));
|
|
550
|
+
}
|
|
551
|
+
}
|
|
552
|
+
// ---- text ---------------------------------------------------------------
|
|
553
|
+
applyText(node, css) {
|
|
554
|
+
const s = node.style;
|
|
555
|
+
if (s) {
|
|
556
|
+
if (s.fontFamily)
|
|
557
|
+
css["font-family"] = `'${s.fontFamily}', sans-serif`;
|
|
558
|
+
if (s.fontSize)
|
|
559
|
+
css["font-size"] = (0, color_1.px)(s.fontSize);
|
|
560
|
+
if (s.fontWeight)
|
|
561
|
+
css["font-weight"] = String(s.fontWeight);
|
|
562
|
+
if (s.lineHeightPx)
|
|
563
|
+
css["line-height"] = (0, color_1.px)(s.lineHeightPx);
|
|
564
|
+
if (s.letterSpacing)
|
|
565
|
+
css["letter-spacing"] = (0, color_1.px)(s.letterSpacing);
|
|
566
|
+
if (s.italic)
|
|
567
|
+
css["font-style"] = "italic";
|
|
568
|
+
if (s.textAlignHorizontal)
|
|
569
|
+
css["text-align"] = s.textAlignHorizontal.toLowerCase();
|
|
570
|
+
if (s.textDecoration === "UNDERLINE")
|
|
571
|
+
css["text-decoration"] = "underline";
|
|
572
|
+
if (s.textDecoration === "STRIKETHROUGH")
|
|
573
|
+
css["text-decoration"] = "line-through";
|
|
574
|
+
if (s.textCase === "UPPER")
|
|
575
|
+
css["text-transform"] = "uppercase";
|
|
576
|
+
if (s.textCase === "LOWER")
|
|
577
|
+
css["text-transform"] = "lowercase";
|
|
578
|
+
if (s.textCase === "TITLE")
|
|
579
|
+
css["text-transform"] = "capitalize";
|
|
580
|
+
if (s.textAutoResize === "WIDTH_AND_HEIGHT")
|
|
581
|
+
css["white-space"] = "pre";
|
|
582
|
+
else if (s.textAutoResize === "HEIGHT")
|
|
583
|
+
css["white-space"] = "pre-wrap";
|
|
584
|
+
}
|
|
585
|
+
const fills = this.visiblePaints(node.fills);
|
|
586
|
+
const paint = fills[fills.length - 1];
|
|
587
|
+
if (paint?.type === "SOLID" && paint.color) {
|
|
588
|
+
css.color = (0, color_1.figmaColorToCss)(paint.color, paint.opacity ?? 1);
|
|
589
|
+
}
|
|
590
|
+
}
|
|
591
|
+
// ---- assets -------------------------------------------------------------
|
|
592
|
+
detectAsset(node) {
|
|
593
|
+
const vectorTypes = ["VECTOR", "STAR", "LINE", "ELLIPSE", "REGULAR_POLYGON", "BOOLEAN_OPERATION"];
|
|
594
|
+
if (vectorTypes.includes(node.type))
|
|
595
|
+
return { kind: "vector" };
|
|
596
|
+
const imagePaint = [...this.visiblePaints(node.fills)].reverse().find((p) => p.type === "IMAGE" && p.imageRef);
|
|
597
|
+
if (imagePaint)
|
|
598
|
+
return { kind: "image", imageRef: imagePaint.imageRef };
|
|
599
|
+
return void 0;
|
|
600
|
+
}
|
|
601
|
+
};
|
|
602
|
+
exports.StyleIr = StyleIr;
|
|
603
|
+
}
|
|
604
|
+
});
|
|
605
|
+
|
|
606
|
+
// ../packages/figma-core/dist/converter/tailwind-mapper.js
|
|
607
|
+
var require_tailwind_mapper = __commonJS({
|
|
608
|
+
"../packages/figma-core/dist/converter/tailwind-mapper.js"(exports) {
|
|
609
|
+
"use strict";
|
|
610
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
611
|
+
exports.cssToTailwind = cssToTailwind;
|
|
612
|
+
function arb(value) {
|
|
613
|
+
return value.replace(/\s+/g, "_");
|
|
614
|
+
}
|
|
615
|
+
var JUSTIFY = {
|
|
616
|
+
"flex-start": "justify-start",
|
|
617
|
+
center: "justify-center",
|
|
618
|
+
"flex-end": "justify-end",
|
|
619
|
+
"space-between": "justify-between"
|
|
620
|
+
};
|
|
621
|
+
var ITEMS = {
|
|
622
|
+
"flex-start": "items-start",
|
|
623
|
+
center: "items-center",
|
|
624
|
+
"flex-end": "items-end",
|
|
625
|
+
baseline: "items-baseline"
|
|
626
|
+
};
|
|
627
|
+
var TEXT_ALIGN = {
|
|
628
|
+
left: "text-left",
|
|
629
|
+
center: "text-center",
|
|
630
|
+
right: "text-right",
|
|
631
|
+
justify: "text-justify"
|
|
632
|
+
};
|
|
633
|
+
function cssToTailwind(css) {
|
|
634
|
+
const classes = [];
|
|
635
|
+
const leftover = {};
|
|
636
|
+
const push = (c) => {
|
|
637
|
+
if (c)
|
|
638
|
+
classes.push(c);
|
|
639
|
+
};
|
|
640
|
+
for (const [prop, value] of Object.entries(css)) {
|
|
641
|
+
switch (prop) {
|
|
642
|
+
case "display":
|
|
643
|
+
if (value === "flex")
|
|
644
|
+
push("flex");
|
|
645
|
+
else if (value === "block")
|
|
646
|
+
push("block");
|
|
647
|
+
else if (value === "none")
|
|
648
|
+
push("hidden");
|
|
649
|
+
else
|
|
650
|
+
leftover[prop] = value;
|
|
651
|
+
break;
|
|
652
|
+
case "flex-direction":
|
|
653
|
+
push(value === "column" ? "flex-col" : "flex-row");
|
|
654
|
+
break;
|
|
655
|
+
case "flex-wrap":
|
|
656
|
+
if (value === "wrap")
|
|
657
|
+
push("flex-wrap");
|
|
658
|
+
break;
|
|
659
|
+
case "justify-content":
|
|
660
|
+
JUSTIFY[value] ? push(JUSTIFY[value]) : leftover[prop] = value;
|
|
661
|
+
break;
|
|
662
|
+
case "align-items":
|
|
663
|
+
ITEMS[value] ? push(ITEMS[value]) : leftover[prop] = value;
|
|
664
|
+
break;
|
|
665
|
+
case "gap":
|
|
666
|
+
push(`gap-[${arb(value)}]`);
|
|
667
|
+
break;
|
|
668
|
+
case "padding":
|
|
669
|
+
applyPadding(value, push, leftover);
|
|
670
|
+
break;
|
|
671
|
+
case "width":
|
|
672
|
+
push(`w-[${arb(value)}]`);
|
|
673
|
+
break;
|
|
674
|
+
case "height":
|
|
675
|
+
push(`h-[${arb(value)}]`);
|
|
676
|
+
break;
|
|
677
|
+
case "position":
|
|
678
|
+
if (value === "absolute")
|
|
679
|
+
push("absolute");
|
|
680
|
+
else if (value === "relative")
|
|
681
|
+
push("relative");
|
|
682
|
+
else
|
|
683
|
+
leftover[prop] = value;
|
|
684
|
+
break;
|
|
685
|
+
case "left":
|
|
686
|
+
push(`left-[${arb(value)}]`);
|
|
687
|
+
break;
|
|
688
|
+
case "top":
|
|
689
|
+
push(`top-[${arb(value)}]`);
|
|
690
|
+
break;
|
|
691
|
+
case "overflow":
|
|
692
|
+
if (value === "hidden")
|
|
693
|
+
push("overflow-hidden");
|
|
694
|
+
else
|
|
695
|
+
leftover[prop] = value;
|
|
696
|
+
break;
|
|
697
|
+
case "background-color":
|
|
698
|
+
push(`bg-[${arb(value)}]`);
|
|
699
|
+
break;
|
|
700
|
+
case "background-image":
|
|
701
|
+
push(`bg-[${arb(value)}]`);
|
|
702
|
+
break;
|
|
703
|
+
case "background-size":
|
|
704
|
+
push(value === "cover" ? "bg-cover" : `bg-[length:${arb(value)}]`);
|
|
705
|
+
break;
|
|
706
|
+
case "background-position":
|
|
707
|
+
if (value === "center")
|
|
708
|
+
push("bg-center");
|
|
709
|
+
else
|
|
710
|
+
leftover[prop] = value;
|
|
711
|
+
break;
|
|
712
|
+
case "border":
|
|
713
|
+
applyBorder(value, push, leftover);
|
|
714
|
+
break;
|
|
715
|
+
case "border-radius":
|
|
716
|
+
value.includes(" ") ? leftover[prop] = value : push(`rounded-[${arb(value)}]`);
|
|
717
|
+
break;
|
|
718
|
+
case "box-shadow":
|
|
719
|
+
push(`shadow-[${arb(value)}]`);
|
|
720
|
+
break;
|
|
721
|
+
case "opacity":
|
|
722
|
+
push(`opacity-[${arb(value)}]`);
|
|
723
|
+
break;
|
|
724
|
+
case "filter":
|
|
725
|
+
applyBlur(value, "blur", push, leftover, prop);
|
|
726
|
+
break;
|
|
727
|
+
case "backdrop-filter":
|
|
728
|
+
applyBlur(value, "backdrop-blur", push, leftover, prop);
|
|
729
|
+
break;
|
|
730
|
+
// text
|
|
731
|
+
case "color":
|
|
732
|
+
push(`text-[${arb(value)}]`);
|
|
733
|
+
break;
|
|
734
|
+
case "font-size":
|
|
735
|
+
push(`text-[${arb(value)}]`);
|
|
736
|
+
break;
|
|
737
|
+
case "font-weight":
|
|
738
|
+
push(`font-[${arb(value)}]`);
|
|
739
|
+
break;
|
|
740
|
+
case "line-height":
|
|
741
|
+
push(`leading-[${arb(value)}]`);
|
|
742
|
+
break;
|
|
743
|
+
case "letter-spacing":
|
|
744
|
+
push(`tracking-[${arb(value)}]`);
|
|
745
|
+
break;
|
|
746
|
+
case "font-family":
|
|
747
|
+
push(`font-[${arb(value.replace(/['"]/g, ""))}]`);
|
|
748
|
+
break;
|
|
749
|
+
case "font-style":
|
|
750
|
+
if (value === "italic")
|
|
751
|
+
push("italic");
|
|
752
|
+
break;
|
|
753
|
+
case "text-align":
|
|
754
|
+
TEXT_ALIGN[value] ? push(TEXT_ALIGN[value]) : leftover[prop] = value;
|
|
755
|
+
break;
|
|
756
|
+
case "text-decoration":
|
|
757
|
+
if (value === "underline")
|
|
758
|
+
push("underline");
|
|
759
|
+
else if (value === "line-through")
|
|
760
|
+
push("line-through");
|
|
761
|
+
break;
|
|
762
|
+
case "text-transform":
|
|
763
|
+
push(value);
|
|
764
|
+
break;
|
|
765
|
+
default:
|
|
766
|
+
leftover[prop] = value;
|
|
767
|
+
}
|
|
768
|
+
}
|
|
769
|
+
return { classes, leftover };
|
|
770
|
+
}
|
|
771
|
+
function applyPadding(value, push, leftover) {
|
|
772
|
+
const parts = value.split(/\s+/);
|
|
773
|
+
if (parts.length === 4) {
|
|
774
|
+
const [t, r, b, l] = parts;
|
|
775
|
+
push(`pt-[${arb(t)}]`);
|
|
776
|
+
push(`pr-[${arb(r)}]`);
|
|
777
|
+
push(`pb-[${arb(b)}]`);
|
|
778
|
+
push(`pl-[${arb(l)}]`);
|
|
779
|
+
} else if (parts.length === 1) {
|
|
780
|
+
push(`p-[${arb(parts[0])}]`);
|
|
781
|
+
} else {
|
|
782
|
+
leftover.padding = value;
|
|
783
|
+
}
|
|
784
|
+
}
|
|
785
|
+
function applyBorder(value, push, leftover) {
|
|
786
|
+
const m = value.match(/^(\S+)\s+solid\s+(.+)$/);
|
|
787
|
+
if (m) {
|
|
788
|
+
push(`border-[${arb(m[1])}]`);
|
|
789
|
+
push("border-solid");
|
|
790
|
+
push(`border-[${arb(m[2])}]`);
|
|
791
|
+
} else {
|
|
792
|
+
leftover.border = value;
|
|
793
|
+
}
|
|
794
|
+
}
|
|
795
|
+
function applyBlur(value, prefix, push, leftover, prop) {
|
|
796
|
+
const m = value.match(/blur\((.+)\)/);
|
|
797
|
+
if (m)
|
|
798
|
+
push(`${prefix}-[${arb(m[1])}]`);
|
|
799
|
+
else
|
|
800
|
+
leftover[prop] = value;
|
|
801
|
+
}
|
|
802
|
+
}
|
|
803
|
+
});
|
|
804
|
+
|
|
805
|
+
// ../packages/figma-core/dist/converter/html-renderer.js
|
|
806
|
+
var require_html_renderer = __commonJS({
|
|
807
|
+
"../packages/figma-core/dist/converter/html-renderer.js"(exports) {
|
|
808
|
+
"use strict";
|
|
809
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
810
|
+
exports.HtmlRenderer = void 0;
|
|
811
|
+
var tailwind_mapper_1 = require_tailwind_mapper();
|
|
812
|
+
function escapeHtml(s) {
|
|
813
|
+
return s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
|
814
|
+
}
|
|
815
|
+
function styleString(css) {
|
|
816
|
+
return Object.entries(css).map(([k, v]) => `${k}: ${v}`).join("; ");
|
|
817
|
+
}
|
|
818
|
+
var HtmlRenderer = class _HtmlRenderer {
|
|
819
|
+
/** Render the Style IR tree into an HTML fragment string. */
|
|
820
|
+
renderFragment(node, opts = {}) {
|
|
821
|
+
return this.render(node, opts.mode ?? "tailwind", 0);
|
|
822
|
+
}
|
|
823
|
+
/** Render a standalone, previewable HTML document (Tailwind Play CDN). */
|
|
824
|
+
renderDocument(node, opts = {}) {
|
|
825
|
+
const body = this.render(node, opts.mode ?? "tailwind", 2);
|
|
826
|
+
return this.wrapDocument(body, node.name || "Figma Export", this.fontLinks(node));
|
|
827
|
+
}
|
|
828
|
+
/** Wrap an arbitrary HTML fragment (e.g. LLM output) into a previewable document. */
|
|
829
|
+
wrapDocument(bodyHtml, title = "Figma Export", fontLinks = "") {
|
|
830
|
+
return `<!doctype html>
|
|
831
|
+
<html lang="en">
|
|
832
|
+
<head>
|
|
833
|
+
<meta charset="utf-8" />
|
|
834
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
835
|
+
<title>${escapeHtml(title)}</title>
|
|
836
|
+
${fontLinks ? fontLinks + "\n" : ""}<script src="https://cdn.tailwindcss.com"></script>
|
|
837
|
+
</head>
|
|
838
|
+
<body>
|
|
839
|
+
${bodyHtml}
|
|
840
|
+
</body>
|
|
841
|
+
</html>`;
|
|
842
|
+
}
|
|
843
|
+
/** Collect (family -> weights) used across the IR. */
|
|
844
|
+
collectFonts(node, acc = /* @__PURE__ */ new Map()) {
|
|
845
|
+
const fam = node.css["font-family"];
|
|
846
|
+
if (fam) {
|
|
847
|
+
const family = fam.split(",")[0].replace(/['"]/g, "").trim();
|
|
848
|
+
const weight = parseInt(node.css["font-weight"] ?? "400", 10) || 400;
|
|
849
|
+
if (!acc.has(family))
|
|
850
|
+
acc.set(family, /* @__PURE__ */ new Set());
|
|
851
|
+
acc.get(family).add(weight);
|
|
852
|
+
}
|
|
853
|
+
for (const child of node.children)
|
|
854
|
+
this.collectFonts(child, acc);
|
|
855
|
+
return acc;
|
|
856
|
+
}
|
|
857
|
+
/** Build Google Fonts <link> tags (one per family) for the fonts used. */
|
|
858
|
+
fontLinks(node) {
|
|
859
|
+
const fonts = this.collectFonts(node);
|
|
860
|
+
const links = [];
|
|
861
|
+
for (const [family, weights] of fonts) {
|
|
862
|
+
if (_HtmlRenderer.NON_GOOGLE.has(family.toLowerCase()))
|
|
863
|
+
continue;
|
|
864
|
+
const wght = [...weights].sort((a, b) => a - b).join(";");
|
|
865
|
+
const fam = encodeURIComponent(family).replace(/%20/g, "+");
|
|
866
|
+
links.push(`<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=${fam}:wght@${wght}&display=swap" />`);
|
|
867
|
+
}
|
|
868
|
+
return links.join("\n");
|
|
869
|
+
}
|
|
870
|
+
render(node, mode, depth) {
|
|
871
|
+
const indent = " ".repeat(depth);
|
|
872
|
+
const isVectorAsset = node.asset?.kind === "vector" && !!node.assetSrc;
|
|
873
|
+
const isImageAsset = node.asset?.kind === "image" && !!node.assetSrc;
|
|
874
|
+
const extraStyle = {};
|
|
875
|
+
if (isImageAsset) {
|
|
876
|
+
extraStyle["background-image"] = `url(${node.assetSrc})`;
|
|
877
|
+
}
|
|
878
|
+
const attrs = [];
|
|
879
|
+
if (mode === "inline") {
|
|
880
|
+
const css = { ...node.css, ...extraStyle };
|
|
881
|
+
if (Object.keys(css).length)
|
|
882
|
+
attrs.push(`style="${escapeHtml(styleString(css))}"`);
|
|
883
|
+
} else {
|
|
884
|
+
const { classes, leftover } = (0, tailwind_mapper_1.cssToTailwind)(node.css);
|
|
885
|
+
const style = { ...leftover, ...extraStyle };
|
|
886
|
+
if (classes.length)
|
|
887
|
+
attrs.push(`class="${classes.join(" ")}"`);
|
|
888
|
+
if (Object.keys(style).length)
|
|
889
|
+
attrs.push(`style="${escapeHtml(styleString(style))}"`);
|
|
890
|
+
}
|
|
891
|
+
attrs.push(`data-figma-name="${escapeHtml(node.name)}"`);
|
|
892
|
+
const attrStr = attrs.length ? " " + attrs.join(" ") : "";
|
|
893
|
+
if (isVectorAsset) {
|
|
894
|
+
return `${indent}<img${attrStr} src="${node.assetSrc}" alt="${escapeHtml(node.name)}" />`;
|
|
895
|
+
}
|
|
896
|
+
if (node.text !== void 0) {
|
|
897
|
+
return `${indent}<${node.tag}${attrStr}>${escapeHtml(node.text)}</${node.tag}>`;
|
|
898
|
+
}
|
|
899
|
+
if (!node.children.length) {
|
|
900
|
+
return `${indent}<${node.tag}${attrStr}></${node.tag}>`;
|
|
901
|
+
}
|
|
902
|
+
const childHtml = node.children.map((c) => this.render(c, mode, depth + 1)).join("\n");
|
|
903
|
+
return `${indent}<${node.tag}${attrStr}>
|
|
904
|
+
${childHtml}
|
|
905
|
+
${indent}</${node.tag}>`;
|
|
906
|
+
}
|
|
907
|
+
};
|
|
908
|
+
exports.HtmlRenderer = HtmlRenderer;
|
|
909
|
+
HtmlRenderer.NON_GOOGLE = /* @__PURE__ */ new Set([
|
|
910
|
+
"arial",
|
|
911
|
+
"helvetica",
|
|
912
|
+
"helvetica neue",
|
|
913
|
+
"sans-serif",
|
|
914
|
+
"serif",
|
|
915
|
+
"monospace",
|
|
916
|
+
"times",
|
|
917
|
+
"times new roman",
|
|
918
|
+
"courier",
|
|
919
|
+
"courier new",
|
|
920
|
+
"system-ui",
|
|
921
|
+
"segoe ui",
|
|
922
|
+
"-apple-system",
|
|
923
|
+
"gilroy",
|
|
924
|
+
"gilroy-extrabold",
|
|
925
|
+
"google sans",
|
|
926
|
+
"product sans",
|
|
927
|
+
"sf pro",
|
|
928
|
+
"sf pro text",
|
|
929
|
+
"sf pro display"
|
|
930
|
+
]);
|
|
931
|
+
}
|
|
932
|
+
});
|
|
933
|
+
|
|
934
|
+
// ../packages/figma-core/dist/assets/asset-resolver.js
|
|
935
|
+
var require_asset_resolver = __commonJS({
|
|
936
|
+
"../packages/figma-core/dist/assets/asset-resolver.js"(exports) {
|
|
937
|
+
"use strict";
|
|
938
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
939
|
+
exports.AssetResolver = void 0;
|
|
940
|
+
var AssetResolver = class {
|
|
941
|
+
constructor(figma, cache, logger) {
|
|
942
|
+
this.figma = figma;
|
|
943
|
+
this.cache = cache;
|
|
944
|
+
this.logger = logger;
|
|
945
|
+
this.fetchConcurrency = 8;
|
|
946
|
+
}
|
|
947
|
+
async resolve(root, fileKey, auth, scale = 2) {
|
|
948
|
+
const vectors = [];
|
|
949
|
+
const images = [];
|
|
950
|
+
this.collect(root, vectors, images);
|
|
951
|
+
if (!vectors.length && !images.length) {
|
|
952
|
+
return { vectors: 0, images: 0, embedded: 0, fromCache: 0 };
|
|
953
|
+
}
|
|
954
|
+
let fromCache = 0;
|
|
955
|
+
fromCache += await this.fillFromCache(vectors, fileKey, "svg", 1);
|
|
956
|
+
fromCache += await this.fillImageFillsFromCache(images, fileKey);
|
|
957
|
+
const vMissing = vectors.filter((n) => !n.assetSrc);
|
|
958
|
+
let svgUrls = {};
|
|
959
|
+
if (vMissing.length) {
|
|
960
|
+
try {
|
|
961
|
+
svgUrls = await this.figma.renderImages(fileKey, vMissing.map((n) => n.id), "svg", auth);
|
|
962
|
+
await this.retryMissing(vMissing, svgUrls, fileKey, "svg", auth, 1);
|
|
963
|
+
} catch (err) {
|
|
964
|
+
this.logger.warn(`Vector render failed (keeping cached vectors + image fills): ${err.message}`);
|
|
965
|
+
}
|
|
966
|
+
}
|
|
967
|
+
const iMissing = images.filter((n) => !n.assetSrc && n.asset?.imageRef);
|
|
968
|
+
let embedded = 0;
|
|
969
|
+
embedded += await this.embed(vMissing, svgUrls, "image/svg+xml", fileKey, "svg", 1);
|
|
970
|
+
embedded += await this.embedImageFills(iMissing, fileKey, auth);
|
|
971
|
+
this.logger.log(`Assets: ${vectors.length} vectors, ${images.length} image fills, ${fromCache} from cache, ${embedded} newly embedded`);
|
|
972
|
+
return { vectors: vectors.length, images: images.length, embedded, fromCache };
|
|
973
|
+
}
|
|
974
|
+
assetKey(fileKey, id, format, scale) {
|
|
975
|
+
return `asset:${fileKey}:${id}:${format}:${scale}`;
|
|
976
|
+
}
|
|
977
|
+
imageFillKey(fileKey, imageRef) {
|
|
978
|
+
return `imagefill:${fileKey}:${imageRef}`;
|
|
979
|
+
}
|
|
980
|
+
/** Populate vector assetSrc from cache where available; returns the hit count. */
|
|
981
|
+
async fillFromCache(nodes, fileKey, format, scale) {
|
|
982
|
+
let hits = 0;
|
|
983
|
+
for (const node of nodes) {
|
|
984
|
+
const uri = await this.cache.get(this.assetKey(fileKey, node.id, format, scale));
|
|
985
|
+
if (uri) {
|
|
986
|
+
node.assetSrc = uri;
|
|
987
|
+
hits++;
|
|
988
|
+
}
|
|
989
|
+
}
|
|
990
|
+
return hits;
|
|
991
|
+
}
|
|
992
|
+
/** Populate image-fill assetSrc from cache (keyed by imageRef); hit count. */
|
|
993
|
+
async fillImageFillsFromCache(nodes, fileKey) {
|
|
994
|
+
let hits = 0;
|
|
995
|
+
for (const node of nodes) {
|
|
996
|
+
const ref = node.asset?.imageRef;
|
|
997
|
+
if (!ref)
|
|
998
|
+
continue;
|
|
999
|
+
const uri = await this.cache.get(this.imageFillKey(fileKey, ref));
|
|
1000
|
+
if (uri) {
|
|
1001
|
+
node.assetSrc = uri;
|
|
1002
|
+
hits++;
|
|
1003
|
+
}
|
|
1004
|
+
}
|
|
1005
|
+
return hits;
|
|
1006
|
+
}
|
|
1007
|
+
/**
|
|
1008
|
+
* Resolve image fills via the cheap image-fills endpoint: one call for every
|
|
1009
|
+
* fill URL in the file, then download each unique imageRef once (a single
|
|
1010
|
+
* uploaded image can back many nodes) and share the data URI across them.
|
|
1011
|
+
*/
|
|
1012
|
+
async embedImageFills(nodes, fileKey, auth) {
|
|
1013
|
+
if (!nodes.length)
|
|
1014
|
+
return 0;
|
|
1015
|
+
const byRef = /* @__PURE__ */ new Map();
|
|
1016
|
+
for (const node of nodes) {
|
|
1017
|
+
const ref = node.asset.imageRef;
|
|
1018
|
+
if (!byRef.has(ref))
|
|
1019
|
+
byRef.set(ref, []);
|
|
1020
|
+
byRef.get(ref).push(node);
|
|
1021
|
+
}
|
|
1022
|
+
const fillUrls = await this.figma.getImageFills(fileKey, auth);
|
|
1023
|
+
let count = 0;
|
|
1024
|
+
const refs = [...byRef.keys()];
|
|
1025
|
+
for (let i = 0; i < refs.length; i += this.fetchConcurrency) {
|
|
1026
|
+
const chunk = refs.slice(i, i + this.fetchConcurrency);
|
|
1027
|
+
await Promise.all(chunk.map(async (ref) => {
|
|
1028
|
+
const url = fillUrls[ref];
|
|
1029
|
+
if (!url)
|
|
1030
|
+
return;
|
|
1031
|
+
try {
|
|
1032
|
+
const dataUri = await this.toDataUri(url, "image/png");
|
|
1033
|
+
await this.cache.set(this.imageFillKey(fileKey, ref), dataUri);
|
|
1034
|
+
for (const node of byRef.get(ref))
|
|
1035
|
+
node.assetSrc = dataUri;
|
|
1036
|
+
count++;
|
|
1037
|
+
} catch (err) {
|
|
1038
|
+
this.logger.warn(`Image fill ${ref} embed failed: ${err.message}`);
|
|
1039
|
+
}
|
|
1040
|
+
}));
|
|
1041
|
+
}
|
|
1042
|
+
return count;
|
|
1043
|
+
}
|
|
1044
|
+
/**
|
|
1045
|
+
* Collect asset nodes. A vector node is rendered as a single SVG and its
|
|
1046
|
+
* subtree is NOT traversed further (collapses icon groups into one image).
|
|
1047
|
+
* An image fill is a background, so traversal continues into its children.
|
|
1048
|
+
*/
|
|
1049
|
+
collect(node, vectors, images) {
|
|
1050
|
+
if (node.asset?.kind === "vector") {
|
|
1051
|
+
vectors.push(node);
|
|
1052
|
+
return;
|
|
1053
|
+
}
|
|
1054
|
+
if (node.asset?.kind === "image") {
|
|
1055
|
+
images.push(node);
|
|
1056
|
+
}
|
|
1057
|
+
for (const child of node.children)
|
|
1058
|
+
this.collect(child, vectors, images);
|
|
1059
|
+
}
|
|
1060
|
+
async retryMissing(nodes, urlMap, fileKey, format, auth, scale) {
|
|
1061
|
+
const missing = nodes.filter((n) => !urlMap[n.id]).map((n) => n.id);
|
|
1062
|
+
if (!missing.length)
|
|
1063
|
+
return;
|
|
1064
|
+
try {
|
|
1065
|
+
const retry = await this.figma.renderImages(fileKey, missing, format, auth, scale);
|
|
1066
|
+
for (const [id, url] of Object.entries(retry)) {
|
|
1067
|
+
if (url)
|
|
1068
|
+
urlMap[id] = url;
|
|
1069
|
+
}
|
|
1070
|
+
const stillMissing = missing.filter((id) => !urlMap[id]).length;
|
|
1071
|
+
this.logger.log(`Retried ${missing.length} missing ${format} assets; ${stillMissing} still unrendered`);
|
|
1072
|
+
} catch (err) {
|
|
1073
|
+
this.logger.warn(`Asset retry (${format}) failed: ${err.message}`);
|
|
1074
|
+
}
|
|
1075
|
+
}
|
|
1076
|
+
async embed(nodes, urlMap, mime, fileKey, format, scale) {
|
|
1077
|
+
let count = 0;
|
|
1078
|
+
for (let i = 0; i < nodes.length; i += this.fetchConcurrency) {
|
|
1079
|
+
const chunk = nodes.slice(i, i + this.fetchConcurrency);
|
|
1080
|
+
await Promise.all(chunk.map(async (node) => {
|
|
1081
|
+
const url = urlMap[node.id];
|
|
1082
|
+
if (!url)
|
|
1083
|
+
return;
|
|
1084
|
+
try {
|
|
1085
|
+
node.assetSrc = await this.toDataUri(url, mime);
|
|
1086
|
+
await this.cache.set(this.assetKey(fileKey, node.id, format, scale), node.assetSrc);
|
|
1087
|
+
count++;
|
|
1088
|
+
} catch (err) {
|
|
1089
|
+
this.logger.warn(`Asset ${node.id} embed failed: ${err.message}`);
|
|
1090
|
+
}
|
|
1091
|
+
}));
|
|
1092
|
+
}
|
|
1093
|
+
return count;
|
|
1094
|
+
}
|
|
1095
|
+
async toDataUri(url, fallbackMime) {
|
|
1096
|
+
const res = await fetch(url);
|
|
1097
|
+
if (!res.ok)
|
|
1098
|
+
throw new Error(`fetch ${res.status}`);
|
|
1099
|
+
const mime = res.headers.get("content-type")?.split(";")[0]?.trim() || fallbackMime;
|
|
1100
|
+
const buf = Buffer.from(await res.arrayBuffer());
|
|
1101
|
+
return `data:${mime};base64,${buf.toString("base64")}`;
|
|
1102
|
+
}
|
|
1103
|
+
};
|
|
1104
|
+
exports.AssetResolver = AssetResolver;
|
|
1105
|
+
}
|
|
1106
|
+
});
|
|
1107
|
+
|
|
1108
|
+
// ../packages/figma-core/dist/llm/ollama-client.js
|
|
1109
|
+
var require_ollama_client = __commonJS({
|
|
1110
|
+
"../packages/figma-core/dist/llm/ollama-client.js"(exports) {
|
|
1111
|
+
"use strict";
|
|
1112
|
+
var __importDefault = exports && exports.__importDefault || function(mod) {
|
|
1113
|
+
return mod && mod.__esModule ? mod : { "default": mod };
|
|
1114
|
+
};
|
|
1115
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
1116
|
+
exports.OllamaClient = void 0;
|
|
1117
|
+
exports.ollamaConfigFromEnv = ollamaConfigFromEnv;
|
|
1118
|
+
var openai_1 = __importDefault(__require("openai"));
|
|
1119
|
+
var adapters_1 = require_adapters();
|
|
1120
|
+
function ollamaConfigFromEnv(logger) {
|
|
1121
|
+
return {
|
|
1122
|
+
baseUrl: process.env.OLLAMA_CLOUD_URL ?? "https://ollama.com/v1",
|
|
1123
|
+
apiKey: process.env.OLLAMA_API_KEY ?? "ollama",
|
|
1124
|
+
models: (process.env.OLLAMA_CLOUD_MODELS ?? process.env.OLLAMA_CLOUD_MODEL ?? "").split(",").map((m) => m.trim()).filter(Boolean),
|
|
1125
|
+
logger
|
|
1126
|
+
};
|
|
1127
|
+
}
|
|
1128
|
+
function stripJsonCodeFence(content) {
|
|
1129
|
+
const trimmed = content.trim();
|
|
1130
|
+
const fenced = trimmed.match(/^```(?:json)?\s*\n?([\s\S]*?)\n?```\s*$/i);
|
|
1131
|
+
return fenced ? fenced[1].trim() : trimmed;
|
|
1132
|
+
}
|
|
1133
|
+
var OllamaClient = class {
|
|
1134
|
+
constructor(config = {}) {
|
|
1135
|
+
const baseUrl = (config.baseUrl ?? "https://ollama.com/v1").replace(/\/+$/, "");
|
|
1136
|
+
const apiKey = config.apiKey ?? "ollama";
|
|
1137
|
+
this.logger = config.logger ?? new adapters_1.NoopLogger();
|
|
1138
|
+
this.client = new openai_1.default({ apiKey, baseURL: baseUrl, timeout: 3e5 });
|
|
1139
|
+
this.models = (config.models ?? []).map((m) => m.trim()).filter(Boolean);
|
|
1140
|
+
this.logger.log(`Ollama: ${baseUrl} | Models: ${this.models.join(", ") || "(none configured)"}`);
|
|
1141
|
+
}
|
|
1142
|
+
get enabled() {
|
|
1143
|
+
return this.models.length > 0;
|
|
1144
|
+
}
|
|
1145
|
+
async chat(messages, jsonSchema) {
|
|
1146
|
+
for (const model of this.models) {
|
|
1147
|
+
try {
|
|
1148
|
+
this.logger.log(`[Chat] model=${model}`);
|
|
1149
|
+
const completion = await this.client.chat.completions.create({
|
|
1150
|
+
model,
|
|
1151
|
+
messages,
|
|
1152
|
+
temperature: 0.7,
|
|
1153
|
+
stream: false,
|
|
1154
|
+
...jsonSchema && {
|
|
1155
|
+
response_format: {
|
|
1156
|
+
type: "json_schema",
|
|
1157
|
+
json_schema: { name: jsonSchema.name, schema: jsonSchema.schema, strict: true }
|
|
1158
|
+
}
|
|
1159
|
+
}
|
|
1160
|
+
});
|
|
1161
|
+
const content = completion.choices[0]?.message?.content ?? "";
|
|
1162
|
+
return jsonSchema ? stripJsonCodeFence(content) : content;
|
|
1163
|
+
} catch (err) {
|
|
1164
|
+
this.logger.warn(`[Chat] model=${model} failed: ${err.message}`);
|
|
1165
|
+
}
|
|
1166
|
+
}
|
|
1167
|
+
throw new Error("All Ollama models failed");
|
|
1168
|
+
}
|
|
1169
|
+
async chatWithTools(messages, tools) {
|
|
1170
|
+
for (const model of this.models) {
|
|
1171
|
+
try {
|
|
1172
|
+
this.logger.log(`[ChatWithTools] model=${model}`);
|
|
1173
|
+
const completion = await this.client.chat.completions.create({
|
|
1174
|
+
model,
|
|
1175
|
+
messages,
|
|
1176
|
+
tools,
|
|
1177
|
+
tool_choice: "auto",
|
|
1178
|
+
stream: false,
|
|
1179
|
+
temperature: 0.3
|
|
1180
|
+
});
|
|
1181
|
+
const msg = completion.choices[0]?.message;
|
|
1182
|
+
if (!msg)
|
|
1183
|
+
continue;
|
|
1184
|
+
const tool_calls = msg.tool_calls?.map((tc) => ({
|
|
1185
|
+
id: tc.id,
|
|
1186
|
+
name: tc.function.name,
|
|
1187
|
+
arguments: (() => {
|
|
1188
|
+
try {
|
|
1189
|
+
return JSON.parse(tc.function.arguments);
|
|
1190
|
+
} catch {
|
|
1191
|
+
return {};
|
|
1192
|
+
}
|
|
1193
|
+
})()
|
|
1194
|
+
})) ?? null;
|
|
1195
|
+
return { content: msg.content ?? null, tool_calls };
|
|
1196
|
+
} catch (err) {
|
|
1197
|
+
this.logger.warn(`[ChatWithTools] model=${model} failed: ${err.message}`);
|
|
1198
|
+
}
|
|
1199
|
+
}
|
|
1200
|
+
throw new Error("All Ollama models failed");
|
|
1201
|
+
}
|
|
1202
|
+
/**
|
|
1203
|
+
* Stream a completion, forwarding each token to `onChunk`, and return the
|
|
1204
|
+
* full accumulated text. Does NOT manage any transport — the caller owns the
|
|
1205
|
+
* response lifecycle (used by /convert/stream in the API).
|
|
1206
|
+
*/
|
|
1207
|
+
async streamCompletion(messages, onChunk) {
|
|
1208
|
+
for (const model of this.models) {
|
|
1209
|
+
try {
|
|
1210
|
+
this.logger.log(`[StreamCompletion] model=${model}`);
|
|
1211
|
+
const stream = await this.client.chat.completions.create({
|
|
1212
|
+
model,
|
|
1213
|
+
messages,
|
|
1214
|
+
temperature: 0.3,
|
|
1215
|
+
stream: true
|
|
1216
|
+
});
|
|
1217
|
+
let full = "";
|
|
1218
|
+
for await (const chunk of stream) {
|
|
1219
|
+
const content = chunk.choices[0]?.delta?.content ?? "";
|
|
1220
|
+
if (content) {
|
|
1221
|
+
full += content;
|
|
1222
|
+
onChunk?.(content);
|
|
1223
|
+
}
|
|
1224
|
+
}
|
|
1225
|
+
return full;
|
|
1226
|
+
} catch (err) {
|
|
1227
|
+
this.logger.warn(`[StreamCompletion] model=${model} failed: ${err.message}`);
|
|
1228
|
+
}
|
|
1229
|
+
}
|
|
1230
|
+
throw new Error("All Ollama models failed");
|
|
1231
|
+
}
|
|
1232
|
+
};
|
|
1233
|
+
exports.OllamaClient = OllamaClient;
|
|
1234
|
+
}
|
|
1235
|
+
});
|
|
1236
|
+
|
|
1237
|
+
// ../packages/figma-core/dist/llm/strip-fence.js
|
|
1238
|
+
var require_strip_fence = __commonJS({
|
|
1239
|
+
"../packages/figma-core/dist/llm/strip-fence.js"(exports) {
|
|
1240
|
+
"use strict";
|
|
1241
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
1242
|
+
exports.stripCodeFence = stripCodeFence;
|
|
1243
|
+
function stripCodeFence(content) {
|
|
1244
|
+
const trimmed = content.trim();
|
|
1245
|
+
const fenced = trimmed.match(/^```(?:[a-zA-Z]+)?\s*\n?([\s\S]*?)\n?```\s*$/);
|
|
1246
|
+
return fenced ? fenced[1].trim() : trimmed;
|
|
1247
|
+
}
|
|
1248
|
+
}
|
|
1249
|
+
});
|
|
1250
|
+
|
|
1251
|
+
// ../packages/figma-core/dist/llm/strip-assets.js
|
|
1252
|
+
var require_strip_assets = __commonJS({
|
|
1253
|
+
"../packages/figma-core/dist/llm/strip-assets.js"(exports) {
|
|
1254
|
+
"use strict";
|
|
1255
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
1256
|
+
exports.stripHeavyAssets = stripHeavyAssets;
|
|
1257
|
+
function stripHeavyAssets(html) {
|
|
1258
|
+
const map = /* @__PURE__ */ new Map();
|
|
1259
|
+
let n = 0;
|
|
1260
|
+
const placeholderFor = (original) => {
|
|
1261
|
+
let ph = map.get(original);
|
|
1262
|
+
if (!ph) {
|
|
1263
|
+
ph = `__F2H_ASSET_${n++}__`;
|
|
1264
|
+
map.set(original, ph);
|
|
1265
|
+
}
|
|
1266
|
+
return ph;
|
|
1267
|
+
};
|
|
1268
|
+
const stripped = html.replace(/(<svg\b[^>]*>)([\s\S]*?)(<\/svg>)/gi, (_m, open, inner, close) => inner.trim() ? `${open}${placeholderFor(inner)}${close}` : `${open}${inner}${close}`).replace(/data:[^"')\s>]+/gi, (uri) => placeholderFor(uri));
|
|
1269
|
+
const restore = (s) => {
|
|
1270
|
+
let out = s;
|
|
1271
|
+
for (const [original, ph] of map)
|
|
1272
|
+
out = out.split(ph).join(original);
|
|
1273
|
+
return out;
|
|
1274
|
+
};
|
|
1275
|
+
return { stripped, restore };
|
|
1276
|
+
}
|
|
1277
|
+
}
|
|
1278
|
+
});
|
|
1279
|
+
|
|
1280
|
+
// ../packages/figma-core/dist/llm/restructure.js
|
|
1281
|
+
var require_restructure = __commonJS({
|
|
1282
|
+
"../packages/figma-core/dist/llm/restructure.js"(exports) {
|
|
1283
|
+
"use strict";
|
|
1284
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
1285
|
+
exports.LlmRestructure = void 0;
|
|
1286
|
+
var strip_fence_1 = require_strip_fence();
|
|
1287
|
+
var strip_assets_1 = require_strip_assets();
|
|
1288
|
+
var LlmRestructure = class {
|
|
1289
|
+
constructor(ollama) {
|
|
1290
|
+
this.ollama = ollama;
|
|
1291
|
+
this.systemPrompt = [
|
|
1292
|
+
"You are an expert front-end engineer. You receive an HTML fragment that was",
|
|
1293
|
+
"generated deterministically from a Figma design. It uses Tailwind utility",
|
|
1294
|
+
"classes with arbitrary values (e.g. w-[327px], bg-[#ff5a63]) plus occasional",
|
|
1295
|
+
'inline style="" leftovers, and a data-figma-name attribute on each element.',
|
|
1296
|
+
"",
|
|
1297
|
+
"Your job: return a cleaner, SEMANTIC, accessible version of the SAME markup.",
|
|
1298
|
+
"",
|
|
1299
|
+
"STRICT RULES \u2014 fidelity must be preserved:",
|
|
1300
|
+
"1. NEVER change, round, add, or remove any numeric value, color, or arbitrary",
|
|
1301
|
+
" Tailwind value (anything inside square brackets) or inline style value.",
|
|
1302
|
+
"2. NEVER change visible text content.",
|
|
1303
|
+
"3. When you merge a redundant wrapper into its child, COMBINE their classes",
|
|
1304
|
+
" onto the surviving element \u2014 do not drop any class.",
|
|
1305
|
+
"",
|
|
1306
|
+
"You MAY:",
|
|
1307
|
+
"- Replace generic <div>/<p> with semantic tags where clearly appropriate",
|
|
1308
|
+
" (header, nav, main, section, footer, ul/li, button, a, h1-h6, figure, img).",
|
|
1309
|
+
"- Collapse redundant single-child wrapper <div>s (merging their classes).",
|
|
1310
|
+
'- Add helpful accessibility attributes (alt="", aria-label, role) using the',
|
|
1311
|
+
" data-figma-name as a hint. You may then drop data-figma-name.",
|
|
1312
|
+
"",
|
|
1313
|
+
"Output ONLY the resulting HTML fragment. No explanation, no markdown fence."
|
|
1314
|
+
].join("\n");
|
|
1315
|
+
}
|
|
1316
|
+
get enabled() {
|
|
1317
|
+
return this.ollama.enabled;
|
|
1318
|
+
}
|
|
1319
|
+
buildMessages(deterministicHtml) {
|
|
1320
|
+
return [
|
|
1321
|
+
{ role: "system", content: this.systemPrompt },
|
|
1322
|
+
{
|
|
1323
|
+
role: "user",
|
|
1324
|
+
content: `Restructure this HTML fragment:
|
|
1325
|
+
|
|
1326
|
+
${deterministicHtml}`
|
|
1327
|
+
}
|
|
1328
|
+
];
|
|
1329
|
+
}
|
|
1330
|
+
/** Non-streaming restructure. Returns the cleaned HTML fragment. */
|
|
1331
|
+
async restructure(deterministicHtml) {
|
|
1332
|
+
this.assertEnabled();
|
|
1333
|
+
const { stripped, restore } = (0, strip_assets_1.stripHeavyAssets)(deterministicHtml);
|
|
1334
|
+
const out = await this.ollama.chat(this.buildMessages(stripped));
|
|
1335
|
+
return restore((0, strip_fence_1.stripCodeFence)(out));
|
|
1336
|
+
}
|
|
1337
|
+
/** Streaming restructure. Forwards tokens to onChunk; returns the full cleaned HTML. */
|
|
1338
|
+
async restructureStream(deterministicHtml, onChunk) {
|
|
1339
|
+
this.assertEnabled();
|
|
1340
|
+
const { stripped, restore } = (0, strip_assets_1.stripHeavyAssets)(deterministicHtml);
|
|
1341
|
+
const full = await this.ollama.streamCompletion(this.buildMessages(stripped), onChunk);
|
|
1342
|
+
return restore((0, strip_fence_1.stripCodeFence)(full));
|
|
1343
|
+
}
|
|
1344
|
+
assertEnabled() {
|
|
1345
|
+
if (!this.enabled) {
|
|
1346
|
+
throw new Error("LLM restructure unavailable: configure OLLAMA_CLOUD_MODELS / OLLAMA_CLOUD_URL.");
|
|
1347
|
+
}
|
|
1348
|
+
}
|
|
1349
|
+
};
|
|
1350
|
+
exports.LlmRestructure = LlmRestructure;
|
|
1351
|
+
}
|
|
1352
|
+
});
|
|
1353
|
+
|
|
1354
|
+
// ../packages/figma-core/dist/llm/refine-agent.js
|
|
1355
|
+
var require_refine_agent = __commonJS({
|
|
1356
|
+
"../packages/figma-core/dist/llm/refine-agent.js"(exports) {
|
|
1357
|
+
"use strict";
|
|
1358
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
1359
|
+
exports.RefineAgent = void 0;
|
|
1360
|
+
var strip_assets_1 = require_strip_assets();
|
|
1361
|
+
var RefineAgent = class {
|
|
1362
|
+
constructor() {
|
|
1363
|
+
this.systemPrompt = [
|
|
1364
|
+
"You are a senior front-end engineer. You receive a STATIC HTML fragment that",
|
|
1365
|
+
"was generated deterministically from a Figma design. It is visually faithful",
|
|
1366
|
+
'but "dumb": almost everything is an absolutely-positioned <div> or <p>, there',
|
|
1367
|
+
"is no interactivity, tags are generic, and each element carries a",
|
|
1368
|
+
"data-figma-name attribute.",
|
|
1369
|
+
"",
|
|
1370
|
+
"GOAL: refine it into clean, SEMANTIC, FUNCTIONAL, accessible HTML that a real",
|
|
1371
|
+
"product would ship \u2014 using your own judgment about what each element is meant",
|
|
1372
|
+
"to be. The rendered result must still LOOK the same.",
|
|
1373
|
+
"",
|
|
1374
|
+
"PRESERVE exactly (never change):",
|
|
1375
|
+
"- Visual layout, position, size, color, typography, spacing \u2014 i.e. every",
|
|
1376
|
+
' Tailwind arbitrary value in [...] and every inline style="" value. Keep the',
|
|
1377
|
+
" positioning classes on the element you convert so it stays in place.",
|
|
1378
|
+
"- The visible text / wording.",
|
|
1379
|
+
"",
|
|
1380
|
+
"IMPROVE using judgment (these are EXAMPLES, not a fixed checklist \u2014 find the",
|
|
1381
|
+
"issues yourself):",
|
|
1382
|
+
"- Use the correct semantic / interactive element for the apparent intent:",
|
|
1383
|
+
' a language label like "EN" (often beside a chevron) -> a <select> with',
|
|
1384
|
+
' <option>s; a label like "Play Demo" beside a play icon -> a <button>;',
|
|
1385
|
+
" navigation labels -> <a> inside a <nav>; the main page title -> <h1> and",
|
|
1386
|
+
" sub-headings -> <h2>-<h6>; repeated items -> <ul>/<li>; the top bar ->",
|
|
1387
|
+
" <header>, the bottom bar -> <footer>; search/email/text fields -> <input>",
|
|
1388
|
+
" with <label>; clickable cards/CTAs -> <a> or <button>.",
|
|
1389
|
+
'- Add reasonable interactivity & accessibility: type="button" on buttons,',
|
|
1390
|
+
' href="#" on links, aria-label/role where useful, alt text for images',
|
|
1391
|
+
" (use data-figma-name as a hint), <option>s inside selects.",
|
|
1392
|
+
"- Collapse redundant single-child wrapper <div>s, MERGING their classes onto",
|
|
1393
|
+
" the surviving element (never drop a class).",
|
|
1394
|
+
"- You may drop data-figma-name once you have used it.",
|
|
1395
|
+
"",
|
|
1396
|
+
"DO NOT: redesign or move things, change colors/sizes/fonts/spacing, change",
|
|
1397
|
+
"wording, invent new sections, or remove visible content.",
|
|
1398
|
+
"",
|
|
1399
|
+
"HOW TO WORK:",
|
|
1400
|
+
"- The current HTML is in the conversation. Make focused changes with",
|
|
1401
|
+
" apply_edit(find, replace) where `find` is an EXACT, unique substring of the",
|
|
1402
|
+
" current HTML and `replace` is the improved markup.",
|
|
1403
|
+
"- Call get_html anytime to re-read the current state (do this if an edit",
|
|
1404
|
+
" reports NOT_FOUND).",
|
|
1405
|
+
"- You can issue SEVERAL apply_edit calls in one turn (they run together) \u2014",
|
|
1406
|
+
" do this to cover more of the page per step.",
|
|
1407
|
+
"- Be THOROUGH: scan the WHOLE document top-to-bottom for elements that should",
|
|
1408
|
+
" be interactive (selects, buttons, links, inputs) or semantic, not just the",
|
|
1409
|
+
" first sections. Only call finish once the whole document has been covered."
|
|
1410
|
+
].join("\n");
|
|
1411
|
+
this.tools = [
|
|
1412
|
+
{
|
|
1413
|
+
type: "function",
|
|
1414
|
+
function: {
|
|
1415
|
+
name: "get_html",
|
|
1416
|
+
description: "Return the current working HTML so you can re-read it after edits.",
|
|
1417
|
+
parameters: { type: "object", properties: {} }
|
|
1418
|
+
}
|
|
1419
|
+
},
|
|
1420
|
+
{
|
|
1421
|
+
type: "function",
|
|
1422
|
+
function: {
|
|
1423
|
+
name: "apply_edit",
|
|
1424
|
+
description: "Replace an EXACT, unique substring of the working HTML with improved markup. Preserves all other content.",
|
|
1425
|
+
parameters: {
|
|
1426
|
+
type: "object",
|
|
1427
|
+
properties: {
|
|
1428
|
+
find: {
|
|
1429
|
+
type: "string",
|
|
1430
|
+
description: "An exact, unique substring currently present in the working HTML."
|
|
1431
|
+
},
|
|
1432
|
+
replace: { type: "string", description: "The replacement HTML." }
|
|
1433
|
+
},
|
|
1434
|
+
required: ["find", "replace"]
|
|
1435
|
+
}
|
|
1436
|
+
}
|
|
1437
|
+
},
|
|
1438
|
+
{
|
|
1439
|
+
type: "function",
|
|
1440
|
+
function: {
|
|
1441
|
+
name: "finish",
|
|
1442
|
+
description: "Call when the HTML has been meaningfully refined.",
|
|
1443
|
+
parameters: {
|
|
1444
|
+
type: "object",
|
|
1445
|
+
properties: { summary: { type: "string", description: "Short summary of changes." } }
|
|
1446
|
+
}
|
|
1447
|
+
}
|
|
1448
|
+
}
|
|
1449
|
+
];
|
|
1450
|
+
}
|
|
1451
|
+
createSession(deterministicHtml) {
|
|
1452
|
+
const { stripped, restore } = (0, strip_assets_1.stripHeavyAssets)(deterministicHtml);
|
|
1453
|
+
const state = { html: stripped };
|
|
1454
|
+
const executeTool = async (name, args) => {
|
|
1455
|
+
switch (name) {
|
|
1456
|
+
case "get_html":
|
|
1457
|
+
return state.html;
|
|
1458
|
+
case "apply_edit": {
|
|
1459
|
+
const find = String(args.find ?? "");
|
|
1460
|
+
const replace = String(args.replace ?? "");
|
|
1461
|
+
if (!find)
|
|
1462
|
+
return 'ERROR: "find" is empty.';
|
|
1463
|
+
const occurrences = state.html.split(find).length - 1;
|
|
1464
|
+
if (occurrences === 0)
|
|
1465
|
+
return "NOT_FOUND: that exact substring is not in the current HTML. Call get_html and copy an exact, unique snippet.";
|
|
1466
|
+
state.html = state.html.split(find).join(replace);
|
|
1467
|
+
return `OK: replaced ${occurrences} occurrence(s).`;
|
|
1468
|
+
}
|
|
1469
|
+
case "finish":
|
|
1470
|
+
return "OK";
|
|
1471
|
+
default:
|
|
1472
|
+
return `Unknown tool: ${name}`;
|
|
1473
|
+
}
|
|
1474
|
+
};
|
|
1475
|
+
const interceptors = { finish: () => void 0 };
|
|
1476
|
+
const getFinalHtml = () => restore(state.html);
|
|
1477
|
+
return { userMessage: `Here is the HTML export to refine:
|
|
1478
|
+
|
|
1479
|
+
${stripped}`, executeTool, interceptors, getFinalHtml };
|
|
1480
|
+
}
|
|
1481
|
+
};
|
|
1482
|
+
exports.RefineAgent = RefineAgent;
|
|
1483
|
+
}
|
|
1484
|
+
});
|
|
1485
|
+
|
|
1486
|
+
// ../packages/figma-core/dist/llm/agent-runner.js
|
|
1487
|
+
var require_agent_runner = __commonJS({
|
|
1488
|
+
"../packages/figma-core/dist/llm/agent-runner.js"(exports) {
|
|
1489
|
+
"use strict";
|
|
1490
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
1491
|
+
exports.runAgent = runAgent;
|
|
1492
|
+
var adapters_1 = require_adapters();
|
|
1493
|
+
async function runAgent(ollama, messages, config, emit, options = {}) {
|
|
1494
|
+
const logger = options.logger ?? new adapters_1.NoopLogger();
|
|
1495
|
+
const isAborted = options.isAborted ?? (() => false);
|
|
1496
|
+
const systemContent = config.extraContext ? `${config.systemPrompt}
|
|
1497
|
+
|
|
1498
|
+
${config.extraContext}` : config.systemPrompt;
|
|
1499
|
+
const chatMessages = [
|
|
1500
|
+
{ role: "system", content: systemContent },
|
|
1501
|
+
...messages.map((m) => ({ role: m.role, content: m.content }))
|
|
1502
|
+
];
|
|
1503
|
+
const maxIterations = config.maxIterations ?? 20;
|
|
1504
|
+
for (let i = 0; i < maxIterations; i++) {
|
|
1505
|
+
if (isAborted())
|
|
1506
|
+
return;
|
|
1507
|
+
let result;
|
|
1508
|
+
try {
|
|
1509
|
+
result = await ollama.chatWithTools(chatMessages, config.tools);
|
|
1510
|
+
} catch (err) {
|
|
1511
|
+
emit({ type: "error", message: err.message });
|
|
1512
|
+
break;
|
|
1513
|
+
}
|
|
1514
|
+
if (isAborted())
|
|
1515
|
+
return;
|
|
1516
|
+
if (result.content)
|
|
1517
|
+
emit({ type: "text", chunk: result.content });
|
|
1518
|
+
if (!result.tool_calls?.length)
|
|
1519
|
+
break;
|
|
1520
|
+
chatMessages.push({
|
|
1521
|
+
role: "assistant",
|
|
1522
|
+
content: result.content ?? null,
|
|
1523
|
+
tool_calls: result.tool_calls.map((tc) => ({
|
|
1524
|
+
id: tc.id,
|
|
1525
|
+
type: "function",
|
|
1526
|
+
function: { name: tc.name, arguments: JSON.stringify(tc.arguments) }
|
|
1527
|
+
}))
|
|
1528
|
+
});
|
|
1529
|
+
const interceptedTc = result.tool_calls.find((tc) => config.interceptors?.[tc.name]);
|
|
1530
|
+
if (interceptedTc) {
|
|
1531
|
+
emit({ type: "tool_call", name: interceptedTc.name, args: interceptedTc.arguments });
|
|
1532
|
+
config.interceptors[interceptedTc.name](interceptedTc.arguments, emit);
|
|
1533
|
+
break;
|
|
1534
|
+
}
|
|
1535
|
+
const toolResults = await Promise.all(result.tool_calls.map(async (tc) => {
|
|
1536
|
+
emit({ type: "tool_call", name: tc.name, args: tc.arguments });
|
|
1537
|
+
let output = "";
|
|
1538
|
+
try {
|
|
1539
|
+
output = await config.executeTool(tc.name, tc.arguments);
|
|
1540
|
+
} catch (err) {
|
|
1541
|
+
output = `Error: ${err.message}`;
|
|
1542
|
+
logger.warn(`Tool ${tc.name} failed: ${err.message}`);
|
|
1543
|
+
}
|
|
1544
|
+
emit({ type: "tool_result", name: tc.name, preview: output.slice(0, 300) });
|
|
1545
|
+
return { id: tc.id, output };
|
|
1546
|
+
}));
|
|
1547
|
+
for (const { id, output } of toolResults) {
|
|
1548
|
+
chatMessages.push({ role: "tool", tool_call_id: id, content: output });
|
|
1549
|
+
}
|
|
1550
|
+
}
|
|
1551
|
+
if (isAborted())
|
|
1552
|
+
return;
|
|
1553
|
+
const finalEmit = config.getFinalEmit?.();
|
|
1554
|
+
if (finalEmit)
|
|
1555
|
+
emit(finalEmit);
|
|
1556
|
+
}
|
|
1557
|
+
}
|
|
1558
|
+
});
|
|
1559
|
+
|
|
1560
|
+
// ../packages/figma-core/dist/convert.js
|
|
1561
|
+
var require_convert = __commonJS({
|
|
1562
|
+
"../packages/figma-core/dist/convert.js"(exports) {
|
|
1563
|
+
"use strict";
|
|
1564
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
1565
|
+
exports.FigmaConverter = void 0;
|
|
1566
|
+
exports.countNodes = countNodes;
|
|
1567
|
+
var url_1 = require_url();
|
|
1568
|
+
var adapters_1 = require_adapters();
|
|
1569
|
+
var FigmaConverter = class {
|
|
1570
|
+
constructor(deps) {
|
|
1571
|
+
this.client = deps.client;
|
|
1572
|
+
this.styleIr = deps.styleIr;
|
|
1573
|
+
this.renderer = deps.renderer;
|
|
1574
|
+
this.assets = deps.assets;
|
|
1575
|
+
this.restructure = deps.restructure;
|
|
1576
|
+
this.logger = deps.logger ?? new adapters_1.NoopLogger();
|
|
1577
|
+
}
|
|
1578
|
+
async convert(input, auth, options = {}) {
|
|
1579
|
+
const target2 = (0, url_1.resolveFigmaTarget)(input);
|
|
1580
|
+
const node = await this.client.fetchNode(target2.fileKey, target2.nodeId, auth);
|
|
1581
|
+
const ir = this.styleIr.convert(node);
|
|
1582
|
+
let assetWarning;
|
|
1583
|
+
if (options.assets !== false) {
|
|
1584
|
+
try {
|
|
1585
|
+
await this.assets.resolve(ir, target2.fileKey, auth, options.assetScale ?? 2);
|
|
1586
|
+
} catch (err) {
|
|
1587
|
+
assetWarning = `Assets not fully resolved: ${err.message}`;
|
|
1588
|
+
this.logger.warn(assetWarning);
|
|
1589
|
+
}
|
|
1590
|
+
}
|
|
1591
|
+
const mode = options.mode ?? "tailwind";
|
|
1592
|
+
let fragment = this.renderer.renderFragment(ir, { mode });
|
|
1593
|
+
let usedLlm = false;
|
|
1594
|
+
if (options.llm && this.restructure?.enabled) {
|
|
1595
|
+
fragment = await this.restructure.restructure(fragment);
|
|
1596
|
+
usedLlm = true;
|
|
1597
|
+
}
|
|
1598
|
+
const fontLinks = this.renderer.fontLinks(ir);
|
|
1599
|
+
const document = this.renderer.wrapDocument(fragment, ir.name, fontLinks);
|
|
1600
|
+
const html = options.document ? document : fragment;
|
|
1601
|
+
return {
|
|
1602
|
+
fileKey: target2.fileKey,
|
|
1603
|
+
nodeId: target2.nodeId ?? null,
|
|
1604
|
+
name: ir.name,
|
|
1605
|
+
mode,
|
|
1606
|
+
llm: usedLlm,
|
|
1607
|
+
nodeCount: countNodes(ir),
|
|
1608
|
+
fragment,
|
|
1609
|
+
document,
|
|
1610
|
+
html,
|
|
1611
|
+
ir,
|
|
1612
|
+
...assetWarning ? { assetWarning } : {}
|
|
1613
|
+
};
|
|
1614
|
+
}
|
|
1615
|
+
/** Deterministic only: returns the Style IR tree (exact CSS), no HTML. */
|
|
1616
|
+
async extractStyles(input, auth) {
|
|
1617
|
+
const target2 = (0, url_1.resolveFigmaTarget)(input);
|
|
1618
|
+
const node = await this.client.fetchNode(target2.fileKey, target2.nodeId, auth);
|
|
1619
|
+
return this.styleIr.convert(node);
|
|
1620
|
+
}
|
|
1621
|
+
};
|
|
1622
|
+
exports.FigmaConverter = FigmaConverter;
|
|
1623
|
+
function countNodes(node) {
|
|
1624
|
+
return 1 + node.children.reduce((sum, c) => sum + countNodes(c), 0);
|
|
1625
|
+
}
|
|
1626
|
+
}
|
|
1627
|
+
});
|
|
1628
|
+
|
|
1629
|
+
// ../packages/figma-core/dist/factory.js
|
|
1630
|
+
var require_factory = __commonJS({
|
|
1631
|
+
"../packages/figma-core/dist/factory.js"(exports) {
|
|
1632
|
+
"use strict";
|
|
1633
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
1634
|
+
exports.createFigmaCore = createFigmaCore2;
|
|
1635
|
+
var adapters_1 = require_adapters();
|
|
1636
|
+
var client_1 = require_client();
|
|
1637
|
+
var style_ir_1 = require_style_ir();
|
|
1638
|
+
var html_renderer_1 = require_html_renderer();
|
|
1639
|
+
var asset_resolver_1 = require_asset_resolver();
|
|
1640
|
+
var ollama_client_1 = require_ollama_client();
|
|
1641
|
+
var restructure_1 = require_restructure();
|
|
1642
|
+
var refine_agent_1 = require_refine_agent();
|
|
1643
|
+
var convert_1 = require_convert();
|
|
1644
|
+
function createFigmaCore2(config = {}) {
|
|
1645
|
+
const logger = config.logger ?? new adapters_1.ConsoleLogger();
|
|
1646
|
+
const cache = config.cache ?? new adapters_1.DiskCache(config.cacheDir, logger);
|
|
1647
|
+
const client = new client_1.FigmaClient({
|
|
1648
|
+
cache,
|
|
1649
|
+
logger,
|
|
1650
|
+
fallbackToken: config.figmaToken ?? process.env.FIGMA_TOKEN
|
|
1651
|
+
});
|
|
1652
|
+
const styleIr = new style_ir_1.StyleIr();
|
|
1653
|
+
const renderer = new html_renderer_1.HtmlRenderer();
|
|
1654
|
+
const assets = new asset_resolver_1.AssetResolver(client, cache, logger);
|
|
1655
|
+
const ollama = new ollama_client_1.OllamaClient(config.ollama ?? (0, ollama_client_1.ollamaConfigFromEnv)(logger));
|
|
1656
|
+
const restructure = new restructure_1.LlmRestructure(ollama);
|
|
1657
|
+
const refineAgent = new refine_agent_1.RefineAgent();
|
|
1658
|
+
const converter = new convert_1.FigmaConverter({ client, styleIr, renderer, assets, restructure, logger });
|
|
1659
|
+
return {
|
|
1660
|
+
converter,
|
|
1661
|
+
client,
|
|
1662
|
+
styleIr,
|
|
1663
|
+
renderer,
|
|
1664
|
+
assets,
|
|
1665
|
+
ollama,
|
|
1666
|
+
restructure,
|
|
1667
|
+
refineAgent,
|
|
1668
|
+
cache,
|
|
1669
|
+
logger,
|
|
1670
|
+
convert: converter.convert.bind(converter),
|
|
1671
|
+
extractStyles: converter.extractStyles.bind(converter)
|
|
1672
|
+
};
|
|
1673
|
+
}
|
|
1674
|
+
}
|
|
1675
|
+
});
|
|
1676
|
+
|
|
1677
|
+
// ../packages/figma-core/dist/index.js
|
|
1678
|
+
var require_dist = __commonJS({
|
|
1679
|
+
"../packages/figma-core/dist/index.js"(exports) {
|
|
1680
|
+
"use strict";
|
|
1681
|
+
var __createBinding = exports && exports.__createBinding || (Object.create ? (function(o, m, k, k2) {
|
|
1682
|
+
if (k2 === void 0) k2 = k;
|
|
1683
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
1684
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
1685
|
+
desc = { enumerable: true, get: function() {
|
|
1686
|
+
return m[k];
|
|
1687
|
+
} };
|
|
1688
|
+
}
|
|
1689
|
+
Object.defineProperty(o, k2, desc);
|
|
1690
|
+
}) : (function(o, m, k, k2) {
|
|
1691
|
+
if (k2 === void 0) k2 = k;
|
|
1692
|
+
o[k2] = m[k];
|
|
1693
|
+
}));
|
|
1694
|
+
var __exportStar = exports && exports.__exportStar || function(m, exports2) {
|
|
1695
|
+
for (var p in m) if (p !== "default" && !Object.prototype.hasOwnProperty.call(exports2, p)) __createBinding(exports2, m, p);
|
|
1696
|
+
};
|
|
1697
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
1698
|
+
exports.createFigmaCore = exports.countNodes = exports.FigmaConverter = exports.stripHeavyAssets = exports.stripCodeFence = exports.runAgent = exports.RefineAgent = exports.LlmRestructure = exports.ollamaConfigFromEnv = exports.OllamaClient = exports.AssetResolver = exports.px = exports.figmaColorToCss = exports.cssToTailwind = exports.HtmlRenderer = exports.StyleIr = exports.resolveFigmaTarget = exports.normalizeNodeId = exports.parseFigmaUrl = exports.FigmaClient = exports.NoopLogger = exports.ConsoleLogger = exports.DiskCache = exports.MemoryCache = exports.FigmaInputError = exports.FigmaApiError = void 0;
|
|
1699
|
+
var errors_1 = require_errors();
|
|
1700
|
+
Object.defineProperty(exports, "FigmaApiError", { enumerable: true, get: function() {
|
|
1701
|
+
return errors_1.FigmaApiError;
|
|
1702
|
+
} });
|
|
1703
|
+
Object.defineProperty(exports, "FigmaInputError", { enumerable: true, get: function() {
|
|
1704
|
+
return errors_1.FigmaInputError;
|
|
1705
|
+
} });
|
|
1706
|
+
var adapters_1 = require_adapters();
|
|
1707
|
+
Object.defineProperty(exports, "MemoryCache", { enumerable: true, get: function() {
|
|
1708
|
+
return adapters_1.MemoryCache;
|
|
1709
|
+
} });
|
|
1710
|
+
Object.defineProperty(exports, "DiskCache", { enumerable: true, get: function() {
|
|
1711
|
+
return adapters_1.DiskCache;
|
|
1712
|
+
} });
|
|
1713
|
+
Object.defineProperty(exports, "ConsoleLogger", { enumerable: true, get: function() {
|
|
1714
|
+
return adapters_1.ConsoleLogger;
|
|
1715
|
+
} });
|
|
1716
|
+
Object.defineProperty(exports, "NoopLogger", { enumerable: true, get: function() {
|
|
1717
|
+
return adapters_1.NoopLogger;
|
|
1718
|
+
} });
|
|
1719
|
+
var client_1 = require_client();
|
|
1720
|
+
Object.defineProperty(exports, "FigmaClient", { enumerable: true, get: function() {
|
|
1721
|
+
return client_1.FigmaClient;
|
|
1722
|
+
} });
|
|
1723
|
+
__exportStar(require_types(), exports);
|
|
1724
|
+
var url_1 = require_url();
|
|
1725
|
+
Object.defineProperty(exports, "parseFigmaUrl", { enumerable: true, get: function() {
|
|
1726
|
+
return url_1.parseFigmaUrl;
|
|
1727
|
+
} });
|
|
1728
|
+
Object.defineProperty(exports, "normalizeNodeId", { enumerable: true, get: function() {
|
|
1729
|
+
return url_1.normalizeNodeId;
|
|
1730
|
+
} });
|
|
1731
|
+
Object.defineProperty(exports, "resolveFigmaTarget", { enumerable: true, get: function() {
|
|
1732
|
+
return url_1.resolveFigmaTarget;
|
|
1733
|
+
} });
|
|
1734
|
+
var style_ir_1 = require_style_ir();
|
|
1735
|
+
Object.defineProperty(exports, "StyleIr", { enumerable: true, get: function() {
|
|
1736
|
+
return style_ir_1.StyleIr;
|
|
1737
|
+
} });
|
|
1738
|
+
var html_renderer_1 = require_html_renderer();
|
|
1739
|
+
Object.defineProperty(exports, "HtmlRenderer", { enumerable: true, get: function() {
|
|
1740
|
+
return html_renderer_1.HtmlRenderer;
|
|
1741
|
+
} });
|
|
1742
|
+
var tailwind_mapper_1 = require_tailwind_mapper();
|
|
1743
|
+
Object.defineProperty(exports, "cssToTailwind", { enumerable: true, get: function() {
|
|
1744
|
+
return tailwind_mapper_1.cssToTailwind;
|
|
1745
|
+
} });
|
|
1746
|
+
var color_1 = require_color();
|
|
1747
|
+
Object.defineProperty(exports, "figmaColorToCss", { enumerable: true, get: function() {
|
|
1748
|
+
return color_1.figmaColorToCss;
|
|
1749
|
+
} });
|
|
1750
|
+
Object.defineProperty(exports, "px", { enumerable: true, get: function() {
|
|
1751
|
+
return color_1.px;
|
|
1752
|
+
} });
|
|
1753
|
+
var asset_resolver_1 = require_asset_resolver();
|
|
1754
|
+
Object.defineProperty(exports, "AssetResolver", { enumerable: true, get: function() {
|
|
1755
|
+
return asset_resolver_1.AssetResolver;
|
|
1756
|
+
} });
|
|
1757
|
+
var ollama_client_1 = require_ollama_client();
|
|
1758
|
+
Object.defineProperty(exports, "OllamaClient", { enumerable: true, get: function() {
|
|
1759
|
+
return ollama_client_1.OllamaClient;
|
|
1760
|
+
} });
|
|
1761
|
+
Object.defineProperty(exports, "ollamaConfigFromEnv", { enumerable: true, get: function() {
|
|
1762
|
+
return ollama_client_1.ollamaConfigFromEnv;
|
|
1763
|
+
} });
|
|
1764
|
+
var restructure_1 = require_restructure();
|
|
1765
|
+
Object.defineProperty(exports, "LlmRestructure", { enumerable: true, get: function() {
|
|
1766
|
+
return restructure_1.LlmRestructure;
|
|
1767
|
+
} });
|
|
1768
|
+
var refine_agent_1 = require_refine_agent();
|
|
1769
|
+
Object.defineProperty(exports, "RefineAgent", { enumerable: true, get: function() {
|
|
1770
|
+
return refine_agent_1.RefineAgent;
|
|
1771
|
+
} });
|
|
1772
|
+
var agent_runner_1 = require_agent_runner();
|
|
1773
|
+
Object.defineProperty(exports, "runAgent", { enumerable: true, get: function() {
|
|
1774
|
+
return agent_runner_1.runAgent;
|
|
1775
|
+
} });
|
|
1776
|
+
var strip_fence_1 = require_strip_fence();
|
|
1777
|
+
Object.defineProperty(exports, "stripCodeFence", { enumerable: true, get: function() {
|
|
1778
|
+
return strip_fence_1.stripCodeFence;
|
|
1779
|
+
} });
|
|
1780
|
+
var strip_assets_1 = require_strip_assets();
|
|
1781
|
+
Object.defineProperty(exports, "stripHeavyAssets", { enumerable: true, get: function() {
|
|
1782
|
+
return strip_assets_1.stripHeavyAssets;
|
|
1783
|
+
} });
|
|
1784
|
+
var convert_1 = require_convert();
|
|
1785
|
+
Object.defineProperty(exports, "FigmaConverter", { enumerable: true, get: function() {
|
|
1786
|
+
return convert_1.FigmaConverter;
|
|
1787
|
+
} });
|
|
1788
|
+
Object.defineProperty(exports, "countNodes", { enumerable: true, get: function() {
|
|
1789
|
+
return convert_1.countNodes;
|
|
1790
|
+
} });
|
|
1791
|
+
var factory_1 = require_factory();
|
|
1792
|
+
Object.defineProperty(exports, "createFigmaCore", { enumerable: true, get: function() {
|
|
1793
|
+
return factory_1.createFigmaCore;
|
|
1794
|
+
} });
|
|
1795
|
+
}
|
|
1796
|
+
});
|
|
1797
|
+
|
|
1798
|
+
// src/server.ts
|
|
1799
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
1800
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
1801
|
+
import { z } from "zod";
|
|
1802
|
+
|
|
1803
|
+
// src/tools.ts
|
|
1804
|
+
var import_core = __toESM(require_dist(), 1);
|
|
1805
|
+
import { promises as fs2 } from "fs";
|
|
1806
|
+
import * as path2 from "path";
|
|
1807
|
+
|
|
1808
|
+
// src/credentials.ts
|
|
1809
|
+
import { promises as fs } from "fs";
|
|
1810
|
+
import * as os from "os";
|
|
1811
|
+
import * as path from "path";
|
|
1812
|
+
var DIR = path.join(os.homedir(), ".figma-mcp");
|
|
1813
|
+
var FILE = path.join(DIR, "credentials.json");
|
|
1814
|
+
function credentialsPath() {
|
|
1815
|
+
return FILE;
|
|
1816
|
+
}
|
|
1817
|
+
async function loadCredentials() {
|
|
1818
|
+
try {
|
|
1819
|
+
const raw = await fs.readFile(FILE, "utf8");
|
|
1820
|
+
return JSON.parse(raw);
|
|
1821
|
+
} catch {
|
|
1822
|
+
return {};
|
|
1823
|
+
}
|
|
1824
|
+
}
|
|
1825
|
+
async function saveCredentials(patch) {
|
|
1826
|
+
const current = await loadCredentials();
|
|
1827
|
+
const next = { ...current, ...patch };
|
|
1828
|
+
await fs.mkdir(DIR, { recursive: true });
|
|
1829
|
+
await fs.writeFile(FILE, JSON.stringify(next, null, 2), { mode: 384 });
|
|
1830
|
+
return next;
|
|
1831
|
+
}
|
|
1832
|
+
async function clearCredentials() {
|
|
1833
|
+
try {
|
|
1834
|
+
await fs.unlink(FILE);
|
|
1835
|
+
} catch {
|
|
1836
|
+
}
|
|
1837
|
+
}
|
|
1838
|
+
|
|
1839
|
+
// src/config.ts
|
|
1840
|
+
async function resolveConfig(overrides = {}) {
|
|
1841
|
+
const creds = await loadCredentials();
|
|
1842
|
+
const mode = overrides.mode ?? process.env.FIGMA_MCP_MODE ?? "auto";
|
|
1843
|
+
const pat = overrides.pat ?? process.env.FIGMA_PAT ?? process.env.FIGMA_TOKEN ?? creds.pat;
|
|
1844
|
+
const apiUrl = overrides.apiUrl ?? process.env.FIGMA_MCP_API ?? creds.apiUrl;
|
|
1845
|
+
const apiToken = overrides.apiToken ?? process.env.FIGMA_MCP_TOKEN ?? creds.apiToken;
|
|
1846
|
+
const outDir = overrides.outDir ?? process.env.FIGMA_MCP_OUT_DIR ?? `${process.cwd()}/figma-output`;
|
|
1847
|
+
let effectiveMode;
|
|
1848
|
+
if (mode === "local") {
|
|
1849
|
+
effectiveMode = "local";
|
|
1850
|
+
} else if (mode === "remote") {
|
|
1851
|
+
effectiveMode = "remote";
|
|
1852
|
+
} else {
|
|
1853
|
+
effectiveMode = pat ? "local" : apiUrl ? "remote" : "local";
|
|
1854
|
+
}
|
|
1855
|
+
return { mode, effectiveMode, pat, apiUrl, apiToken, outDir };
|
|
1856
|
+
}
|
|
1857
|
+
function describeConfig(cfg) {
|
|
1858
|
+
if (cfg.effectiveMode === "local") {
|
|
1859
|
+
return `mode=local (figma-core) \xB7 PAT=${cfg.pat ? "set" : "MISSING"} \xB7 out=${cfg.outDir}`;
|
|
1860
|
+
}
|
|
1861
|
+
return `mode=remote \xB7 api=${cfg.apiUrl ?? "MISSING"} \xB7 token=${cfg.apiToken ? "set" : "none"} \xB7 out=${cfg.outDir}`;
|
|
1862
|
+
}
|
|
1863
|
+
|
|
1864
|
+
// src/api-client.ts
|
|
1865
|
+
function authHeaders(cfg) {
|
|
1866
|
+
const headers = { "Content-Type": "application/json" };
|
|
1867
|
+
if (cfg.pat) headers["X-Figma-Token"] = cfg.pat;
|
|
1868
|
+
if (cfg.apiToken) headers["Authorization"] = `Bearer ${cfg.apiToken}`;
|
|
1869
|
+
return headers;
|
|
1870
|
+
}
|
|
1871
|
+
async function post(cfg, route, body) {
|
|
1872
|
+
if (!cfg.apiUrl) throw new Error("Remote mode requires a backend URL (FIGMA_MCP_API).");
|
|
1873
|
+
const url = `${cfg.apiUrl.replace(/\/+$/, "")}${route}`;
|
|
1874
|
+
const res = await fetch(url, { method: "POST", headers: authHeaders(cfg), body: JSON.stringify(body) });
|
|
1875
|
+
const text = await res.text();
|
|
1876
|
+
if (!res.ok) {
|
|
1877
|
+
let msg = `${res.status} ${res.statusText}`;
|
|
1878
|
+
try {
|
|
1879
|
+
const j = JSON.parse(text);
|
|
1880
|
+
if (j?.message) msg = `${res.status}: ${j.message}`;
|
|
1881
|
+
} catch {
|
|
1882
|
+
if (text) msg = `${res.status}: ${text.slice(0, 300)}`;
|
|
1883
|
+
}
|
|
1884
|
+
throw new Error(`Backend ${url} failed \u2014 ${msg}`);
|
|
1885
|
+
}
|
|
1886
|
+
return text ? JSON.parse(text) : {};
|
|
1887
|
+
}
|
|
1888
|
+
function remoteConvert(cfg, body) {
|
|
1889
|
+
return post(cfg, "/convert", body);
|
|
1890
|
+
}
|
|
1891
|
+
function remoteExtractStyles(cfg, body) {
|
|
1892
|
+
return post(cfg, "/extract-styles", body);
|
|
1893
|
+
}
|
|
1894
|
+
|
|
1895
|
+
// src/tools.ts
|
|
1896
|
+
var INLINE_LIMIT = 1e5;
|
|
1897
|
+
var core;
|
|
1898
|
+
var corePat;
|
|
1899
|
+
function getCore(pat) {
|
|
1900
|
+
if (!core || corePat !== pat) {
|
|
1901
|
+
core = (0, import_core.createFigmaCore)({ figmaToken: pat });
|
|
1902
|
+
corePat = pat;
|
|
1903
|
+
}
|
|
1904
|
+
return core;
|
|
1905
|
+
}
|
|
1906
|
+
function sanitize(s) {
|
|
1907
|
+
return (s || "figma").replace(/[^a-zA-Z0-9._-]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 60) || "figma";
|
|
1908
|
+
}
|
|
1909
|
+
async function writeOut(outDir, fileName, content) {
|
|
1910
|
+
await fs2.mkdir(outDir, { recursive: true });
|
|
1911
|
+
const full = path2.resolve(outDir, fileName);
|
|
1912
|
+
await fs2.writeFile(full, content, "utf8");
|
|
1913
|
+
return full;
|
|
1914
|
+
}
|
|
1915
|
+
function target(args) {
|
|
1916
|
+
return { figmaUrl: args.figmaUrl, fileKey: args.fileKey, nodeId: args.nodeId };
|
|
1917
|
+
}
|
|
1918
|
+
async function convertFigmaToHtml(args, overrides = {}) {
|
|
1919
|
+
const cfg = await resolveConfig(overrides);
|
|
1920
|
+
let result;
|
|
1921
|
+
if (cfg.effectiveMode === "local") {
|
|
1922
|
+
if (!cfg.pat) throw new Error("Local mode needs a Figma PAT. Run `figma-coder-mcp set-token <PAT>` or set FIGMA_PAT.");
|
|
1923
|
+
const c = getCore(cfg.pat);
|
|
1924
|
+
const r = await c.convert(target(args), { token: cfg.pat, bearer: false }, {
|
|
1925
|
+
mode: args.mode,
|
|
1926
|
+
document: args.document ?? true,
|
|
1927
|
+
assets: args.assets,
|
|
1928
|
+
assetScale: args.assetScale,
|
|
1929
|
+
llm: args.llm
|
|
1930
|
+
});
|
|
1931
|
+
result = { name: r.name, nodeCount: r.nodeCount, mode: r.mode, llm: r.llm, html: r.html };
|
|
1932
|
+
} else {
|
|
1933
|
+
const r = await remoteConvert(cfg, {
|
|
1934
|
+
...target(args),
|
|
1935
|
+
mode: args.mode,
|
|
1936
|
+
document: args.document ?? true,
|
|
1937
|
+
assets: args.assets,
|
|
1938
|
+
assetScale: args.assetScale,
|
|
1939
|
+
llm: args.llm
|
|
1940
|
+
});
|
|
1941
|
+
result = { name: r.name, nodeCount: r.nodeCount, mode: r.mode, llm: r.llm, html: r.html, previewUrl: r.previewUrl };
|
|
1942
|
+
}
|
|
1943
|
+
const fileName = args.outFile ?? `${sanitize(result.name)}.html`;
|
|
1944
|
+
const filePath = await writeOut(cfg.outDir, fileName, result.html);
|
|
1945
|
+
const bytes = Buffer.byteLength(result.html, "utf8");
|
|
1946
|
+
return [
|
|
1947
|
+
`Converted "${result.name}" (${describeConfig(cfg)}).`,
|
|
1948
|
+
`- nodes: ${result.nodeCount}`,
|
|
1949
|
+
`- mode: ${result.mode}${result.llm ? " + LLM restructure" : ""}`,
|
|
1950
|
+
`- output: ${filePath} (${(bytes / 1024).toFixed(1)} KB)`,
|
|
1951
|
+
result.previewUrl ? `- preview: ${cfg.apiUrl}${result.previewUrl}` : void 0,
|
|
1952
|
+
"",
|
|
1953
|
+
"The full HTML document was written to the file above. Open or read it to use the markup."
|
|
1954
|
+
].filter(Boolean).join("\n");
|
|
1955
|
+
}
|
|
1956
|
+
async function getFigmaData(args, overrides = {}) {
|
|
1957
|
+
const cfg = await resolveConfig(overrides);
|
|
1958
|
+
let ir;
|
|
1959
|
+
if (cfg.effectiveMode === "local") {
|
|
1960
|
+
if (!cfg.pat) throw new Error("Local mode needs a Figma PAT. Run `figma-coder-mcp set-token <PAT>` or set FIGMA_PAT.");
|
|
1961
|
+
const c = getCore(cfg.pat);
|
|
1962
|
+
ir = await c.extractStyles(target(args), { token: cfg.pat, bearer: false });
|
|
1963
|
+
} else {
|
|
1964
|
+
ir = await remoteExtractStyles(cfg, target(args));
|
|
1965
|
+
}
|
|
1966
|
+
const json = JSON.stringify(ir, null, 2);
|
|
1967
|
+
const nodeCount = countIr(ir);
|
|
1968
|
+
if (json.length <= INLINE_LIMIT) {
|
|
1969
|
+
return [`Style IR for "${ir.name}" (${nodeCount} nodes, ${describeConfig(cfg)}):`, "", json].join("\n");
|
|
1970
|
+
}
|
|
1971
|
+
const filePath = await writeOut(cfg.outDir, `${sanitize(ir.name)}.ir.json`, json);
|
|
1972
|
+
return [
|
|
1973
|
+
`Style IR for "${ir.name}" is large (${nodeCount} nodes, ${(json.length / 1024).toFixed(1)} KB) \u2014 written to a file.`,
|
|
1974
|
+
`- file: ${filePath}`,
|
|
1975
|
+
`- root: <${ir.tag}> figmaType=${ir.figmaType}, css keys=${Object.keys(ir.css || {}).length}`,
|
|
1976
|
+
"",
|
|
1977
|
+
"Top-level children:",
|
|
1978
|
+
...(ir.children || []).map(
|
|
1979
|
+
(c, i) => ` ${i}. "${c.name}" <${c.tag}> ${c.figmaType} (${(c.children || []).length} children)`
|
|
1980
|
+
),
|
|
1981
|
+
"",
|
|
1982
|
+
"Read the file for the full tree (each node has exact `css`)."
|
|
1983
|
+
].join("\n");
|
|
1984
|
+
}
|
|
1985
|
+
function countIr(node) {
|
|
1986
|
+
if (!node) return 0;
|
|
1987
|
+
return 1 + (node.children || []).reduce((s, c) => s + countIr(c), 0);
|
|
1988
|
+
}
|
|
1989
|
+
|
|
1990
|
+
// src/server.ts
|
|
1991
|
+
var targetShape = {
|
|
1992
|
+
figmaUrl: z.string().optional().describe("Full Figma URL (file/design link, may include ?node-id=)."),
|
|
1993
|
+
fileKey: z.string().optional().describe("Figma file key (alternative to figmaUrl)."),
|
|
1994
|
+
nodeId: z.string().optional().describe('Node id in URL form "1-23" or REST form "1:23". Omitted with fileKey = whole file.')
|
|
1995
|
+
};
|
|
1996
|
+
function safe(handler) {
|
|
1997
|
+
return async (args) => {
|
|
1998
|
+
try {
|
|
1999
|
+
const text = await handler(args);
|
|
2000
|
+
return { content: [{ type: "text", text }] };
|
|
2001
|
+
} catch (err) {
|
|
2002
|
+
return { content: [{ type: "text", text: `Error: ${err?.message ?? String(err)}` }], isError: true };
|
|
2003
|
+
}
|
|
2004
|
+
};
|
|
2005
|
+
}
|
|
2006
|
+
function buildServer() {
|
|
2007
|
+
const server = new McpServer({ name: "figma-coder-mcp", version: "0.1.0" });
|
|
2008
|
+
server.registerTool(
|
|
2009
|
+
"get_figma_data",
|
|
2010
|
+
{
|
|
2011
|
+
title: "Get Figma design data",
|
|
2012
|
+
description: "Fetch the deterministic Style IR (exact CSS per node) for a Figma file or node, so an agent can reason about the design and generate code itself. Small trees are returned inline; large trees are written to a JSON file and summarised.",
|
|
2013
|
+
inputSchema: targetShape
|
|
2014
|
+
},
|
|
2015
|
+
safe((a) => getFigmaData(a))
|
|
2016
|
+
);
|
|
2017
|
+
server.registerTool(
|
|
2018
|
+
"convert_figma_to_html",
|
|
2019
|
+
{
|
|
2020
|
+
title: "Convert Figma to HTML + Tailwind",
|
|
2021
|
+
description: "Convert a Figma file/node into finished, self-contained HTML + Tailwind (deterministic Style IR, assets inlined, optional LLM restructure). The HTML is written to a file; a compact summary + path is returned to keep context small.",
|
|
2022
|
+
inputSchema: {
|
|
2023
|
+
...targetShape,
|
|
2024
|
+
mode: z.enum(["tailwind", "inline"]).optional().describe("Output mode. Default 'tailwind'."),
|
|
2025
|
+
document: z.boolean().optional().describe("Emit a full HTML document (default true) vs a fragment."),
|
|
2026
|
+
assets: z.boolean().optional().describe("Export & inline vectors/images as data URIs. Default true."),
|
|
2027
|
+
assetScale: z.number().min(1).max(4).optional().describe("Raster export scale (1-4). Default 2."),
|
|
2028
|
+
llm: z.boolean().optional().describe("Run the LLM restructure pass (needs OLLAMA_* config). Default false."),
|
|
2029
|
+
outFile: z.string().optional().describe("Override the output file name (within the output dir).")
|
|
2030
|
+
}
|
|
2031
|
+
},
|
|
2032
|
+
safe((a) => convertFigmaToHtml(a))
|
|
2033
|
+
);
|
|
2034
|
+
return server;
|
|
2035
|
+
}
|
|
2036
|
+
async function serveStdio() {
|
|
2037
|
+
const server = buildServer();
|
|
2038
|
+
const transport = new StdioServerTransport();
|
|
2039
|
+
await server.connect(transport);
|
|
2040
|
+
console.error("[figma-coder-mcp] stdio server ready");
|
|
2041
|
+
}
|
|
2042
|
+
|
|
2043
|
+
// src/bin.ts
|
|
2044
|
+
function parseArgs(argv) {
|
|
2045
|
+
const positionals = [];
|
|
2046
|
+
const flags = {};
|
|
2047
|
+
for (let i = 0; i < argv.length; i++) {
|
|
2048
|
+
const a = argv[i];
|
|
2049
|
+
if (a.startsWith("--")) {
|
|
2050
|
+
const key = a.slice(2);
|
|
2051
|
+
const next = argv[i + 1];
|
|
2052
|
+
if (next === void 0 || next.startsWith("--")) {
|
|
2053
|
+
flags[key] = true;
|
|
2054
|
+
} else {
|
|
2055
|
+
flags[key] = next;
|
|
2056
|
+
i++;
|
|
2057
|
+
}
|
|
2058
|
+
} else {
|
|
2059
|
+
positionals.push(a);
|
|
2060
|
+
}
|
|
2061
|
+
}
|
|
2062
|
+
return [positionals, flags];
|
|
2063
|
+
}
|
|
2064
|
+
function targetFrom(value, flags) {
|
|
2065
|
+
const nodeId = typeof flags.node === "string" ? flags.node : void 0;
|
|
2066
|
+
if (!value) throw new Error("Provide a Figma URL or fileKey.");
|
|
2067
|
+
if (/figma\.com\//.test(value)) return { figmaUrl: value, nodeId };
|
|
2068
|
+
return { fileKey: value, nodeId };
|
|
2069
|
+
}
|
|
2070
|
+
var HELP = `figma-coder-mcp \u2014 Figma -> HTML/Tailwind for AI agents (MCP server + CLI)
|
|
2071
|
+
|
|
2072
|
+
Usage:
|
|
2073
|
+
figma-coder-mcp [serve] Start the MCP server over stdio (default)
|
|
2074
|
+
figma-coder-mcp convert <url|key> [..] One-off convert to HTML (writes a file)
|
|
2075
|
+
figma-coder-mcp data <url|key> [..] One-off: print/save the Style IR
|
|
2076
|
+
figma-coder-mcp set-token <PAT> Store a Figma personal access token (local mode)
|
|
2077
|
+
figma-coder-mcp set-api <URL> Store the backend converter URL (remote mode)
|
|
2078
|
+
figma-coder-mcp status Show how requests will be served (no secrets)
|
|
2079
|
+
figma-coder-mcp logout Remove stored credentials
|
|
2080
|
+
figma-coder-mcp help Show this help
|
|
2081
|
+
|
|
2082
|
+
Common flags (convert/data):
|
|
2083
|
+
--node <id> Node id ("1-23" or "1:23")
|
|
2084
|
+
--mode <m> tailwind | inline (convert)
|
|
2085
|
+
--no-assets Skip asset inlining (convert)
|
|
2086
|
+
--llm Run LLM restructure (convert; needs OLLAMA_* env)
|
|
2087
|
+
--out <file> Output file name (convert)
|
|
2088
|
+
|
|
2089
|
+
Config (env or stored): FIGMA_PAT, FIGMA_MCP_API, FIGMA_MCP_TOKEN, FIGMA_MCP_MODE,
|
|
2090
|
+
FIGMA_MCP_OUT_DIR. Auto mode prefers local (PAT) so it works even if the backend is down.`;
|
|
2091
|
+
async function main() {
|
|
2092
|
+
const [positionals, flags] = parseArgs(process.argv.slice(2));
|
|
2093
|
+
const cmd = positionals[0] ?? "serve";
|
|
2094
|
+
switch (cmd) {
|
|
2095
|
+
case "serve":
|
|
2096
|
+
await serveStdio();
|
|
2097
|
+
return;
|
|
2098
|
+
// keep process alive on the transport
|
|
2099
|
+
case "convert": {
|
|
2100
|
+
const text = await convertFigmaToHtml({
|
|
2101
|
+
...targetFrom(positionals[1], flags),
|
|
2102
|
+
mode: flags.mode === "inline" ? "inline" : flags.mode === "tailwind" ? "tailwind" : void 0,
|
|
2103
|
+
assets: flags["no-assets"] ? false : void 0,
|
|
2104
|
+
llm: flags.llm === true,
|
|
2105
|
+
outFile: typeof flags.out === "string" ? flags.out : void 0
|
|
2106
|
+
});
|
|
2107
|
+
console.log(text);
|
|
2108
|
+
return;
|
|
2109
|
+
}
|
|
2110
|
+
case "data": {
|
|
2111
|
+
const text = await getFigmaData(targetFrom(positionals[1], flags));
|
|
2112
|
+
console.log(text);
|
|
2113
|
+
return;
|
|
2114
|
+
}
|
|
2115
|
+
case "set-token": {
|
|
2116
|
+
const pat = positionals[1];
|
|
2117
|
+
if (!pat) throw new Error("Usage: figma-coder-mcp set-token <PAT>");
|
|
2118
|
+
await saveCredentials({ pat });
|
|
2119
|
+
console.log(`Stored Figma PAT in ${credentialsPath()}`);
|
|
2120
|
+
return;
|
|
2121
|
+
}
|
|
2122
|
+
case "set-api": {
|
|
2123
|
+
const url = positionals[1];
|
|
2124
|
+
if (!url) throw new Error("Usage: figma-coder-mcp set-api <URL>");
|
|
2125
|
+
await saveCredentials({ apiUrl: url });
|
|
2126
|
+
console.log(`Stored backend URL (${url}) in ${credentialsPath()}`);
|
|
2127
|
+
return;
|
|
2128
|
+
}
|
|
2129
|
+
case "status": {
|
|
2130
|
+
const cfg = await resolveConfig();
|
|
2131
|
+
console.log(describeConfig(cfg));
|
|
2132
|
+
console.log(`credentials: ${credentialsPath()}`);
|
|
2133
|
+
return;
|
|
2134
|
+
}
|
|
2135
|
+
case "logout":
|
|
2136
|
+
await clearCredentials();
|
|
2137
|
+
console.log("Cleared stored credentials.");
|
|
2138
|
+
return;
|
|
2139
|
+
case "help":
|
|
2140
|
+
case "--help":
|
|
2141
|
+
case "-h":
|
|
2142
|
+
console.log(HELP);
|
|
2143
|
+
return;
|
|
2144
|
+
default:
|
|
2145
|
+
console.error(`Unknown command: ${cmd}
|
|
2146
|
+
`);
|
|
2147
|
+
console.error(HELP);
|
|
2148
|
+
process.exitCode = 1;
|
|
2149
|
+
}
|
|
2150
|
+
}
|
|
2151
|
+
main().catch((err) => {
|
|
2152
|
+
console.error(`figma-coder-mcp: ${err?.message ?? err}`);
|
|
2153
|
+
process.exitCode = 1;
|
|
2154
|
+
});
|