@pedroab0/skills 0.0.0-stage → 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +173 -2
- package/bin/cli.js +748 -0
- package/figma-extractor/README.md +170 -0
- package/figma-extractor/SKILL.md +199 -0
- package/figma-extractor/demo/desktop_home.json +2434 -0
- package/figma-extractor/demo/dt_money_desktop_home.png +0 -0
- package/figma-extractor/demo/dt_money_preview.png +0 -0
- package/figma-extractor/demo/dt_money_selection.json +472 -0
- package/figma-extractor/demo/index.html +188 -0
- package/figma-extractor/demo/style.css +360 -0
- package/figma-extractor/figma-plugin/README.md +70 -0
- package/figma-extractor/figma-plugin/code.js +407 -0
- package/figma-extractor/figma-plugin/manifest.json +10 -0
- package/figma-extractor/figma-plugin/ui.html +470 -0
- package/figma-extractor/scripts/fetch_figma.js +1314 -0
- package/figma-extractor/scripts/test_fetch_figma.js +601 -0
- package/figma-node-builder/README.md +107 -0
- package/figma-node-builder/SKILL.md +113 -0
- package/figma-node-builder/scripts/inspect_spec.js +514 -0
- package/figma-node-builder/scripts/test_inspect_spec.js +148 -0
- package/figma-to-code/README.md +160 -0
- package/figma-to-code/SKILL.md +211 -0
- package/figma-to-code/scripts/fetch_figma.js +1251 -0
- package/figma-to-code/scripts/test_fetch_figma.js +581 -0
- package/package.json +39 -4
|
@@ -0,0 +1,1314 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
const fs = require('fs');
|
|
4
|
+
const path = require('path');
|
|
5
|
+
const https = require('https');
|
|
6
|
+
const http = require('http');
|
|
7
|
+
const os = require('os');
|
|
8
|
+
const { URL } = require('url');
|
|
9
|
+
|
|
10
|
+
// -----------------------------------------------------------------------------
|
|
11
|
+
// Environment and Auth Utilities
|
|
12
|
+
// -----------------------------------------------------------------------------
|
|
13
|
+
|
|
14
|
+
function loadEnv() {
|
|
15
|
+
const home = process.env.HOME || '';
|
|
16
|
+
const candidates = [
|
|
17
|
+
path.join(process.cwd(), '.env'),
|
|
18
|
+
home ? path.join(home, '.env') : null,
|
|
19
|
+
home ? path.join(home, '.zshrc') : null,
|
|
20
|
+
home ? path.join(home, '.bashrc') : null,
|
|
21
|
+
].filter(Boolean);
|
|
22
|
+
|
|
23
|
+
for (const envPath of candidates) {
|
|
24
|
+
if (fs.existsSync(envPath)) {
|
|
25
|
+
try {
|
|
26
|
+
const envContent = fs.readFileSync(envPath, 'utf8');
|
|
27
|
+
const lines = envContent.split('\n');
|
|
28
|
+
for (const line of lines) {
|
|
29
|
+
const parsed = parseEnvLine(line);
|
|
30
|
+
if (parsed && parsed.key) {
|
|
31
|
+
// For dot-env files, load any variable; for shell configs, only load FIGMA_ variables
|
|
32
|
+
const isShellConfig = envPath.endsWith('.zshrc') || envPath.endsWith('.bashrc');
|
|
33
|
+
if (!process.env[parsed.key] && (!isShellConfig || parsed.key.startsWith('FIGMA_'))) {
|
|
34
|
+
process.env[parsed.key] = parsed.val;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
} catch (e) {
|
|
39
|
+
// Ignore errors loading env candidates
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function parseEnvLine(line) {
|
|
46
|
+
if (!line || typeof line !== 'string') return null;
|
|
47
|
+
let trimmed = line.trim();
|
|
48
|
+
if (trimmed.startsWith('export ')) {
|
|
49
|
+
trimmed = trimmed.substring(7).trim();
|
|
50
|
+
}
|
|
51
|
+
if (!trimmed || trimmed.startsWith('#') || !trimmed.includes('=')) {
|
|
52
|
+
return null;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const eqIdx = trimmed.indexOf('=');
|
|
56
|
+
const key = trimmed.slice(0, eqIdx).trim();
|
|
57
|
+
let rest = trimmed.slice(eqIdx + 1).trim();
|
|
58
|
+
|
|
59
|
+
let val = '';
|
|
60
|
+
if (rest.startsWith('"')) {
|
|
61
|
+
const closeIdx = rest.indexOf('"', 1);
|
|
62
|
+
val = closeIdx !== -1 ? rest.slice(1, closeIdx) : rest.slice(1);
|
|
63
|
+
} else if (rest.startsWith("'")) {
|
|
64
|
+
const closeIdx = rest.indexOf("'", 1);
|
|
65
|
+
val = closeIdx !== -1 ? rest.slice(1, closeIdx) : rest.slice(1);
|
|
66
|
+
} else {
|
|
67
|
+
// Unquoted: strip trailing inline comments
|
|
68
|
+
val = rest.replace(/\s+#.*$/, '').trim();
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
return { key, val };
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function getFigmaToken() {
|
|
75
|
+
return process.env.FIGMA_ACCESS_TOKEN || process.env.FIGMA_TOKEN || null;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function getAuthHeaders(token) {
|
|
79
|
+
if (!token) return {};
|
|
80
|
+
const trimmed = token.trim();
|
|
81
|
+
if (trimmed.startsWith('Bearer ')) {
|
|
82
|
+
return { Authorization: trimmed };
|
|
83
|
+
}
|
|
84
|
+
return { 'X-Figma-Token': trimmed };
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// -----------------------------------------------------------------------------
|
|
88
|
+
// Network Utilities
|
|
89
|
+
// -----------------------------------------------------------------------------
|
|
90
|
+
|
|
91
|
+
function makeRequest(urlStr, headers = {}, redirectCount = 0) {
|
|
92
|
+
if (redirectCount > 5) {
|
|
93
|
+
return Promise.reject(new Error('Too many HTTP redirects'));
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const parsedUrl = new URL(urlStr);
|
|
97
|
+
const isHttps = parsedUrl.protocol === 'https:';
|
|
98
|
+
const client = isHttps ? https : http;
|
|
99
|
+
|
|
100
|
+
const options = {
|
|
101
|
+
hostname: parsedUrl.hostname,
|
|
102
|
+
port: parsedUrl.port || (isHttps ? 443 : 80),
|
|
103
|
+
path: parsedUrl.pathname + parsedUrl.search,
|
|
104
|
+
headers: {
|
|
105
|
+
'User-Agent': 'FigmaExtractorAgentSkill/1.0',
|
|
106
|
+
...headers,
|
|
107
|
+
},
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
if (process.env.FIGMA_IGNORE_SSL === 'true' && isHttps) {
|
|
111
|
+
options.rejectUnauthorized = false;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
return new Promise((resolve, reject) => {
|
|
115
|
+
const req = client.get(options, (res) => {
|
|
116
|
+
// Handle HTTP redirects (301, 302, 307, 308)
|
|
117
|
+
if (
|
|
118
|
+
res.statusCode >= 300 &&
|
|
119
|
+
res.statusCode < 400 &&
|
|
120
|
+
res.headers.location
|
|
121
|
+
) {
|
|
122
|
+
res.resume();
|
|
123
|
+
const redirectUrl = new URL(res.headers.location, urlStr).toString();
|
|
124
|
+
return resolve(makeRequest(redirectUrl, headers, redirectCount + 1));
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
let data = '';
|
|
128
|
+
res.setEncoding('utf8');
|
|
129
|
+
res.on('data', (chunk) => {
|
|
130
|
+
data += chunk;
|
|
131
|
+
});
|
|
132
|
+
res.on('end', () => {
|
|
133
|
+
if (res.statusCode >= 200 && res.statusCode < 300) {
|
|
134
|
+
resolve(data);
|
|
135
|
+
} else {
|
|
136
|
+
const err = new Error(
|
|
137
|
+
`Request failed with status code ${res.statusCode}: ${data}`,
|
|
138
|
+
);
|
|
139
|
+
err.statusCode = res.statusCode;
|
|
140
|
+
err.responseBody = data;
|
|
141
|
+
err.headers = res.headers;
|
|
142
|
+
if (res.headers['retry-after']) {
|
|
143
|
+
const parsed = parseInt(res.headers['retry-after'], 10);
|
|
144
|
+
err.retryAfter = !isNaN(parsed) ? parsed : res.headers['retry-after'];
|
|
145
|
+
}
|
|
146
|
+
if (res.headers['x-figma-rate-limit-type']) {
|
|
147
|
+
err.rateLimitType = res.headers['x-figma-rate-limit-type'];
|
|
148
|
+
}
|
|
149
|
+
reject(err);
|
|
150
|
+
}
|
|
151
|
+
});
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
req.on('error', (err) => {
|
|
155
|
+
reject(err);
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
req.setTimeout(30000, () => {
|
|
159
|
+
req.destroy();
|
|
160
|
+
reject(new Error('Request timed out after 30 seconds'));
|
|
161
|
+
});
|
|
162
|
+
});
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function downloadBinaryFile(urlStr, targetPath, redirectCount = 0) {
|
|
166
|
+
if (redirectCount > 5) {
|
|
167
|
+
return Promise.reject(new Error('Too many HTTP redirects'));
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
const parsedUrl = new URL(urlStr);
|
|
171
|
+
const isHttps = parsedUrl.protocol === 'https:';
|
|
172
|
+
const client = isHttps ? https : http;
|
|
173
|
+
|
|
174
|
+
const options = {
|
|
175
|
+
hostname: parsedUrl.hostname,
|
|
176
|
+
port: parsedUrl.port || (isHttps ? 443 : 80),
|
|
177
|
+
path: parsedUrl.pathname + parsedUrl.search,
|
|
178
|
+
headers: {
|
|
179
|
+
'User-Agent': 'FigmaExtractorAgentSkill/1.0',
|
|
180
|
+
},
|
|
181
|
+
};
|
|
182
|
+
|
|
183
|
+
if (process.env.FIGMA_IGNORE_SSL === 'true' && isHttps) {
|
|
184
|
+
options.rejectUnauthorized = false;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
return new Promise((resolve, reject) => {
|
|
188
|
+
const dir = path.dirname(targetPath);
|
|
189
|
+
if (!fs.existsSync(dir)) {
|
|
190
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
let fileStream = null;
|
|
194
|
+
const cleanupPartial = () => {
|
|
195
|
+
if (fileStream) {
|
|
196
|
+
try {
|
|
197
|
+
fileStream.destroy();
|
|
198
|
+
} catch (_) {}
|
|
199
|
+
}
|
|
200
|
+
fs.unlink(targetPath, () => {});
|
|
201
|
+
};
|
|
202
|
+
|
|
203
|
+
const req = client.get(options, (res) => {
|
|
204
|
+
if (
|
|
205
|
+
res.statusCode >= 300 &&
|
|
206
|
+
res.statusCode < 400 &&
|
|
207
|
+
res.headers.location
|
|
208
|
+
) {
|
|
209
|
+
res.resume();
|
|
210
|
+
const redirectUrl = new URL(res.headers.location, urlStr).toString();
|
|
211
|
+
return resolve(downloadBinaryFile(redirectUrl, targetPath, redirectCount + 1));
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
if (res.statusCode < 200 || res.statusCode >= 300) {
|
|
215
|
+
res.resume();
|
|
216
|
+
const err = new Error(`Image download failed with status code ${res.statusCode}`);
|
|
217
|
+
err.statusCode = res.statusCode;
|
|
218
|
+
err.headers = res.headers;
|
|
219
|
+
if (res.headers && res.headers['retry-after']) {
|
|
220
|
+
const parsed = parseInt(res.headers['retry-after'], 10);
|
|
221
|
+
err.retryAfter = !isNaN(parsed) ? parsed : res.headers['retry-after'];
|
|
222
|
+
}
|
|
223
|
+
if (res.headers && res.headers['x-figma-rate-limit-type']) {
|
|
224
|
+
err.rateLimitType = res.headers['x-figma-rate-limit-type'];
|
|
225
|
+
}
|
|
226
|
+
return reject(err);
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
fileStream = fs.createWriteStream(targetPath);
|
|
230
|
+
res.pipe(fileStream);
|
|
231
|
+
|
|
232
|
+
fileStream.on('finish', () => {
|
|
233
|
+
fileStream.close(() => resolve(targetPath));
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
fileStream.on('error', (err) => {
|
|
237
|
+
cleanupPartial();
|
|
238
|
+
reject(err);
|
|
239
|
+
});
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
req.on('error', (err) => {
|
|
243
|
+
cleanupPartial();
|
|
244
|
+
reject(err);
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
req.setTimeout(30000, () => {
|
|
248
|
+
req.destroy();
|
|
249
|
+
cleanupPartial();
|
|
250
|
+
reject(new Error('Image download timed out after 30 seconds'));
|
|
251
|
+
});
|
|
252
|
+
});
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
// -----------------------------------------------------------------------------
|
|
256
|
+
// URL and Node ID Normalization
|
|
257
|
+
// -----------------------------------------------------------------------------
|
|
258
|
+
|
|
259
|
+
function parseFigmaUrl(urlStr) {
|
|
260
|
+
let cleanUrl = (urlStr || '').trim();
|
|
261
|
+
if (!cleanUrl.startsWith('http://') && !cleanUrl.startsWith('https://')) {
|
|
262
|
+
cleanUrl = 'https://' + cleanUrl;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
try {
|
|
266
|
+
const url = new URL(cleanUrl);
|
|
267
|
+
const pathParts = url.pathname.split('/').filter(Boolean);
|
|
268
|
+
let fileKey = null;
|
|
269
|
+
|
|
270
|
+
const keyIdx = pathParts.findIndex((part) =>
|
|
271
|
+
['file', 'design', 'board', 'proto'].includes(part.toLowerCase()),
|
|
272
|
+
);
|
|
273
|
+
|
|
274
|
+
if (keyIdx !== -1 && pathParts[keyIdx + 1]) {
|
|
275
|
+
fileKey = pathParts[keyIdx + 1];
|
|
276
|
+
} else if (pathParts.length > 0 && /^[a-zA-Z0-9_-]{20,}$/.test(pathParts[0])) {
|
|
277
|
+
fileKey = pathParts[0];
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
let rawNodeId = url.searchParams.get('node-id');
|
|
281
|
+
let nodeId = rawNodeId ? normalizeNodeId(rawNodeId) : null;
|
|
282
|
+
|
|
283
|
+
return { fileKey, nodeId };
|
|
284
|
+
} catch (e) {
|
|
285
|
+
return { fileKey: null, nodeId: null };
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
function normalizeNodeId(nodeId) {
|
|
290
|
+
if (!nodeId) return null;
|
|
291
|
+
let decoded = decodeURIComponent(String(nodeId).trim());
|
|
292
|
+
let normalized = decoded.replace(/-/g, ':');
|
|
293
|
+
normalized = normalized.replace(/^:+|:+$/g, '');
|
|
294
|
+
return normalized;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
// -----------------------------------------------------------------------------
|
|
298
|
+
// Color and Token Utilities
|
|
299
|
+
// -----------------------------------------------------------------------------
|
|
300
|
+
|
|
301
|
+
function formatRgba(color, opacity) {
|
|
302
|
+
if (!color) return undefined;
|
|
303
|
+
const r = Math.min(255, Math.max(0, Math.round((color.r ?? 0) * 255)));
|
|
304
|
+
const g = Math.min(255, Math.max(0, Math.round((color.g ?? 0) * 255)));
|
|
305
|
+
const b = Math.min(255, Math.max(0, Math.round((color.b ?? 0) * 255)));
|
|
306
|
+
const colorAlpha = color.a !== undefined ? color.a : 1;
|
|
307
|
+
const fillOpacity = opacity !== undefined ? opacity : 1;
|
|
308
|
+
const a = Math.min(1, Math.max(0, Number((colorAlpha * fillOpacity).toFixed(2))));
|
|
309
|
+
|
|
310
|
+
const hex =
|
|
311
|
+
'#' +
|
|
312
|
+
[r, g, b]
|
|
313
|
+
.map((x) => x.toString(16).padStart(2, '0'))
|
|
314
|
+
.join('')
|
|
315
|
+
.toUpperCase();
|
|
316
|
+
|
|
317
|
+
return a < 1 ? `rgba(${r}, ${g}, ${b}, ${a})` : hex;
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
function resolveVariableToken(varAliasOrId, varMap) {
|
|
321
|
+
if (!varAliasOrId || !varMap) return undefined;
|
|
322
|
+
const varId = typeof varAliasOrId === 'object' ? varAliasOrId.id : varAliasOrId;
|
|
323
|
+
return varMap[varId]?.name || undefined;
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
function simplifyFills(fills, boundVars, varMap) {
|
|
327
|
+
if (!Array.isArray(fills) || fills.length === 0) return undefined;
|
|
328
|
+
const boundFills = Array.isArray(boundVars?.fills) ? boundVars.fills : [];
|
|
329
|
+
const results = [];
|
|
330
|
+
|
|
331
|
+
for (let i = 0; i < fills.length; i++) {
|
|
332
|
+
const fill = fills[i];
|
|
333
|
+
if (fill.visible === false) continue;
|
|
334
|
+
|
|
335
|
+
const res = { type: fill.type };
|
|
336
|
+
if (fill.type === 'SOLID' && fill.color) {
|
|
337
|
+
res.color = formatRgba(fill.color, fill.opacity);
|
|
338
|
+
}
|
|
339
|
+
if (fill.opacity !== undefined && fill.opacity !== 1) {
|
|
340
|
+
res.opacity = fill.opacity;
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
const token = resolveVariableToken(boundFills[i], varMap);
|
|
344
|
+
if (token) res.token = token;
|
|
345
|
+
|
|
346
|
+
results.push(res);
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
return results.length > 0 ? results : undefined;
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
function simplifyStrokes(strokes, strokeWeight, strokeAlign, boundVars, varMap) {
|
|
353
|
+
if (!Array.isArray(strokes) || strokes.length === 0) return undefined;
|
|
354
|
+
const boundStrokes = Array.isArray(boundVars?.strokes) ? boundVars.strokes : [];
|
|
355
|
+
const colors = [];
|
|
356
|
+
|
|
357
|
+
for (let i = 0; i < strokes.length; i++) {
|
|
358
|
+
const s = strokes[i];
|
|
359
|
+
if (s.visible === false) continue;
|
|
360
|
+
const col = s.color ? formatRgba(s.color, s.opacity) : s.type;
|
|
361
|
+
const token = resolveVariableToken(boundStrokes[i], varMap);
|
|
362
|
+
colors.push(token ? { color: col, token } : col);
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
if (colors.length === 0) return undefined;
|
|
366
|
+
|
|
367
|
+
const res = { weight: strokeWeight, align: strokeAlign, colors };
|
|
368
|
+
|
|
369
|
+
const strokeWeightToken = resolveVariableToken(boundVars?.strokeWeight, varMap);
|
|
370
|
+
if (strokeWeightToken) res.strokeWeightToken = strokeWeightToken;
|
|
371
|
+
|
|
372
|
+
return res;
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
// -----------------------------------------------------------------------------
|
|
376
|
+
// Component Property & Instance Normalization
|
|
377
|
+
// -----------------------------------------------------------------------------
|
|
378
|
+
|
|
379
|
+
function normalizePropKey(rawKey) {
|
|
380
|
+
if (!rawKey) return '';
|
|
381
|
+
// Strip Figma property ID hash: "Variant#1234:56" -> "Variant", "Has Icon#402" -> "Has Icon"
|
|
382
|
+
let clean = rawKey.replace(/#.*$/, '').trim();
|
|
383
|
+
// Convert to camelCase while preserving Unicode letters (\p{L}) and digits (\p{N})
|
|
384
|
+
return clean
|
|
385
|
+
.replace(/[^\p{L}\p{N}]+(\p{L})/gu, (_, chr) => chr.toUpperCase())
|
|
386
|
+
.replace(/[^\p{L}\p{N}]+/gu, '')
|
|
387
|
+
.replace(/^\p{Lu}/u, (chr) => chr.toLowerCase());
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
function normalizeComponentProps(componentProperties) {
|
|
391
|
+
if (!componentProperties || typeof componentProperties !== 'object') return undefined;
|
|
392
|
+
const props = {};
|
|
393
|
+
|
|
394
|
+
for (const [key, propDef] of Object.entries(componentProperties)) {
|
|
395
|
+
const cleanKey = normalizePropKey(key);
|
|
396
|
+
if (!cleanKey) continue;
|
|
397
|
+
|
|
398
|
+
if (propDef && typeof propDef === 'object' && 'value' in propDef) {
|
|
399
|
+
props[cleanKey] = propDef.value;
|
|
400
|
+
} else {
|
|
401
|
+
props[cleanKey] = propDef;
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
return Object.keys(props).length > 0 ? props : undefined;
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
// -----------------------------------------------------------------------------
|
|
409
|
+
// AST Pruning & Token Optimization
|
|
410
|
+
// -----------------------------------------------------------------------------
|
|
411
|
+
|
|
412
|
+
function pruneFigmaNode(node, context = {}, options = {}) {
|
|
413
|
+
if (!node || typeof node !== 'object') return node;
|
|
414
|
+
|
|
415
|
+
const { varMap, components, componentSets } = context;
|
|
416
|
+
|
|
417
|
+
const pruned = {
|
|
418
|
+
name: node.name,
|
|
419
|
+
type: node.type,
|
|
420
|
+
};
|
|
421
|
+
|
|
422
|
+
if (options.includeIds && node.id) {
|
|
423
|
+
pruned.id = node.id;
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
if (node.visible === false) pruned.visible = false;
|
|
427
|
+
|
|
428
|
+
const bound = node.boundVariables || {};
|
|
429
|
+
|
|
430
|
+
// Component Instance & Master Resolution
|
|
431
|
+
if (node.type === 'INSTANCE') {
|
|
432
|
+
if (node.componentId && components && components[node.componentId]) {
|
|
433
|
+
const master = components[node.componentId];
|
|
434
|
+
const set = master.componentSetId && componentSets ? componentSets[master.componentSetId] : null;
|
|
435
|
+
pruned.component = {
|
|
436
|
+
name: set ? set.name : master.name,
|
|
437
|
+
variant: master.name,
|
|
438
|
+
};
|
|
439
|
+
if (options.includeIds) {
|
|
440
|
+
pruned.component.id = node.componentId;
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
const props = normalizeComponentProps(node.componentProperties);
|
|
445
|
+
if (props) pruned.props = props;
|
|
446
|
+
|
|
447
|
+
// Shallow instances: skip internal rendering sub-layers if requested
|
|
448
|
+
if (options.shallowInstances) {
|
|
449
|
+
if (node.absoluteBoundingBox) {
|
|
450
|
+
pruned.bounds = {
|
|
451
|
+
width: Math.round(node.absoluteBoundingBox.width),
|
|
452
|
+
height: Math.round(node.absoluteBoundingBox.height),
|
|
453
|
+
};
|
|
454
|
+
}
|
|
455
|
+
return pruned;
|
|
456
|
+
}
|
|
457
|
+
} else if (node.type === 'COMPONENT' || node.type === 'COMPONENT_SET') {
|
|
458
|
+
const props = normalizeComponentProps(node.componentProperties);
|
|
459
|
+
if (props) pruned.props = props;
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
// Layout & Flexbox properties
|
|
463
|
+
if (node.layoutMode && node.layoutMode !== 'NONE') {
|
|
464
|
+
pruned.layoutMode = node.layoutMode;
|
|
465
|
+
if (node.primaryAxisAlignItems) pruned.primaryAxisAlignItems = node.primaryAxisAlignItems;
|
|
466
|
+
if (node.counterAxisAlignItems) pruned.counterAxisAlignItems = node.counterAxisAlignItems;
|
|
467
|
+
|
|
468
|
+
if (node.itemSpacing) {
|
|
469
|
+
pruned.itemSpacing = node.itemSpacing;
|
|
470
|
+
const spacingToken = resolveVariableToken(bound.itemSpacing, varMap);
|
|
471
|
+
if (spacingToken) pruned.itemSpacingToken = spacingToken;
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
if (node.paddingLeft || node.paddingRight || node.paddingTop || node.paddingBottom) {
|
|
475
|
+
pruned.padding = {
|
|
476
|
+
top: node.paddingTop ?? 0,
|
|
477
|
+
right: node.paddingRight ?? 0,
|
|
478
|
+
bottom: node.paddingBottom ?? 0,
|
|
479
|
+
left: node.paddingLeft ?? 0,
|
|
480
|
+
};
|
|
481
|
+
const padToken =
|
|
482
|
+
resolveVariableToken(bound.paddingTop, varMap) ||
|
|
483
|
+
resolveVariableToken(bound.paddingLeft, varMap);
|
|
484
|
+
if (padToken) pruned.paddingToken = padToken;
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
if (node.layoutWrap) pruned.layoutWrap = node.layoutWrap;
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
if (node.layoutGrow) pruned.layoutGrow = node.layoutGrow;
|
|
491
|
+
if (node.layoutAlign) pruned.layoutAlign = node.layoutAlign;
|
|
492
|
+
|
|
493
|
+
// Dimensions & Coordinates
|
|
494
|
+
if (node.absoluteBoundingBox) {
|
|
495
|
+
pruned.bounds = {
|
|
496
|
+
width: Math.round(node.absoluteBoundingBox.width),
|
|
497
|
+
height: Math.round(node.absoluteBoundingBox.height),
|
|
498
|
+
};
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
if (node.minWidth) pruned.minWidth = node.minWidth;
|
|
502
|
+
if (node.maxWidth) pruned.maxWidth = node.maxWidth;
|
|
503
|
+
if (node.minHeight) pruned.minHeight = node.minHeight;
|
|
504
|
+
if (node.maxHeight) pruned.maxHeight = node.maxHeight;
|
|
505
|
+
|
|
506
|
+
// Styling: Fills, Strokes, Borders, Corners
|
|
507
|
+
const fills = simplifyFills(node.fills, bound, varMap);
|
|
508
|
+
if (fills) pruned.fills = fills;
|
|
509
|
+
|
|
510
|
+
const strokes = simplifyStrokes(node.strokes, node.strokeWeight, node.strokeAlign, bound, varMap);
|
|
511
|
+
if (strokes) pruned.strokes = strokes;
|
|
512
|
+
|
|
513
|
+
if (node.cornerRadius) {
|
|
514
|
+
pruned.cornerRadius = node.cornerRadius;
|
|
515
|
+
const radiusToken = resolveVariableToken(bound.cornerRadius, varMap);
|
|
516
|
+
if (radiusToken) pruned.cornerRadiusToken = radiusToken;
|
|
517
|
+
}
|
|
518
|
+
if (node.rectangleCornerRadii) pruned.cornerRadii = node.rectangleCornerRadii;
|
|
519
|
+
if (node.opacity !== undefined && node.opacity !== 1) pruned.opacity = node.opacity;
|
|
520
|
+
|
|
521
|
+
// Effects (Drop Shadows, Layer Blurs)
|
|
522
|
+
if (Array.isArray(node.effects) && node.effects.length > 0) {
|
|
523
|
+
const visibleEffects = node.effects.filter((e) => e.visible !== false);
|
|
524
|
+
if (visibleEffects.length > 0) {
|
|
525
|
+
pruned.effects = visibleEffects.map((e) => ({
|
|
526
|
+
type: e.type,
|
|
527
|
+
radius: e.radius,
|
|
528
|
+
color: e.color ? formatRgba(e.color) : undefined,
|
|
529
|
+
offset: e.offset,
|
|
530
|
+
}));
|
|
531
|
+
}
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
// Typography (for TEXT nodes)
|
|
535
|
+
if (node.type === 'TEXT') {
|
|
536
|
+
pruned.characters = node.characters;
|
|
537
|
+
if (node.style) {
|
|
538
|
+
pruned.typography = {
|
|
539
|
+
fontFamily: node.style.fontFamily,
|
|
540
|
+
fontSize: node.style.fontSize,
|
|
541
|
+
fontWeight: node.style.fontWeight,
|
|
542
|
+
lineHeightPx: node.style.lineHeightPx ? Math.round(node.style.lineHeightPx) : undefined,
|
|
543
|
+
letterSpacing: node.style.letterSpacing,
|
|
544
|
+
textAlignHorizontal: node.style.textAlignHorizontal,
|
|
545
|
+
textAlignVertical: node.style.textAlignVertical,
|
|
546
|
+
};
|
|
547
|
+
}
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
// Recursively process children
|
|
551
|
+
if (Array.isArray(node.children) && node.children.length > 0) {
|
|
552
|
+
pruned.children = node.children
|
|
553
|
+
.filter((c) => c.visible !== false)
|
|
554
|
+
.map((c) => pruneFigmaNode(c, context, options));
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
return pruned;
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
// -----------------------------------------------------------------------------
|
|
561
|
+
// Rate Limit Circuit Breaker & Safety Gate
|
|
562
|
+
// -----------------------------------------------------------------------------
|
|
563
|
+
|
|
564
|
+
const RATE_GATE_WINDOW_MS = 60 * 1000; // 60 seconds sliding window
|
|
565
|
+
const MAX_REQUESTS_PER_WINDOW = 5; // Max 5 calls per minute (well below Figma 10/min threshold)
|
|
566
|
+
const MIN_REQUEST_INTERVAL_MS = 2500; // Minimum 2.5s between consecutive requests
|
|
567
|
+
|
|
568
|
+
function getRateGateFilePath() {
|
|
569
|
+
const home = process.env.HOME || (typeof os.homedir === 'function' ? os.homedir() : '');
|
|
570
|
+
return home ? path.join(home, '.figma_ratelimit.json') : path.join(process.cwd(), '.figma_ratelimit.json');
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
function loadRateGateState() {
|
|
574
|
+
const filePath = getRateGateFilePath();
|
|
575
|
+
if (fs.existsSync(filePath)) {
|
|
576
|
+
try {
|
|
577
|
+
const data = fs.readFileSync(filePath, 'utf8');
|
|
578
|
+
return JSON.parse(data);
|
|
579
|
+
} catch (_) {}
|
|
580
|
+
}
|
|
581
|
+
return { files: {} };
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
function saveRateGateState(state) {
|
|
585
|
+
const filePath = getRateGateFilePath();
|
|
586
|
+
try {
|
|
587
|
+
fs.writeFileSync(filePath, JSON.stringify(state, null, 2), 'utf8');
|
|
588
|
+
} catch (_) {}
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
function resetRateGateState(fileKey) {
|
|
592
|
+
const state = loadRateGateState();
|
|
593
|
+
if (!state.files) state.files = {};
|
|
594
|
+
if (fileKey) {
|
|
595
|
+
delete state.files[fileKey];
|
|
596
|
+
} else {
|
|
597
|
+
state.files = {};
|
|
598
|
+
}
|
|
599
|
+
saveRateGateState(state);
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
function recordRateLimitLockout(fileKey, retryAfterSeconds) {
|
|
603
|
+
if (!fileKey || !retryAfterSeconds) return;
|
|
604
|
+
const state = loadRateGateState();
|
|
605
|
+
if (!state.files) state.files = {};
|
|
606
|
+
const fileState = state.files[fileKey] || { timestamps: [], lockedUntil: 0 };
|
|
607
|
+
fileState.lockedUntil = Date.now() + (retryAfterSeconds * 1000);
|
|
608
|
+
fileState.lastRetryAfter = retryAfterSeconds;
|
|
609
|
+
state.files[fileKey] = fileState;
|
|
610
|
+
saveRateGateState(state);
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
async function checkAndApplyRateGate(fileKey, options = {}) {
|
|
614
|
+
if (!fileKey) return;
|
|
615
|
+
const state = loadRateGateState();
|
|
616
|
+
if (!state.files) state.files = {};
|
|
617
|
+
const fileState = state.files[fileKey] || { timestamps: [], lockedUntil: 0 };
|
|
618
|
+
const now = Date.now();
|
|
619
|
+
|
|
620
|
+
// 1. Circuit Breaker: Is the file actively locked out by a previous 429 response?
|
|
621
|
+
if (fileState.lockedUntil && fileState.lockedUntil > now) {
|
|
622
|
+
const remainingSec = Math.ceil((fileState.lockedUntil - now) / 1000);
|
|
623
|
+
const unlockDate = new Date(fileState.lockedUntil).toLocaleString();
|
|
624
|
+
const hours = (remainingSec / 3600).toFixed(1);
|
|
625
|
+
const err = new Error(
|
|
626
|
+
`FIGMA_CIRCUIT_BREAKER_ACTIVE: File "${fileKey}" is actively locked by Figma rate limiting until ${unlockDate} (~${remainingSec}s / ~${hours}h remaining). Outgoing API requests to this file are blocked to prevent extending the lockout duration. To continue immediately, duplicate this file in Figma Drafts (Right-click -> Duplicate) to obtain a fresh file key.`
|
|
627
|
+
);
|
|
628
|
+
err.isCircuitBreaker = true;
|
|
629
|
+
err.statusCode = 429;
|
|
630
|
+
err.retryAfter = remainingSec;
|
|
631
|
+
err.lockedUntil = fileState.lockedUntil;
|
|
632
|
+
throw err;
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
// 2. Sliding Window: Clean timestamps older than 60 seconds
|
|
636
|
+
fileState.timestamps = (fileState.timestamps || []).filter((t) => now - t < RATE_GATE_WINDOW_MS);
|
|
637
|
+
|
|
638
|
+
// 3. Inter-request pacing: Ensure at least MIN_REQUEST_INTERVAL_MS since last call
|
|
639
|
+
if (fileState.timestamps.length > 0 && !options.noWait) {
|
|
640
|
+
const lastTimestamp = fileState.timestamps[fileState.timestamps.length - 1];
|
|
641
|
+
const elapsed = now - lastTimestamp;
|
|
642
|
+
if (elapsed < MIN_REQUEST_INTERVAL_MS) {
|
|
643
|
+
const waitMs = MIN_REQUEST_INTERVAL_MS - elapsed;
|
|
644
|
+
await new Promise((resolve) => setTimeout(resolve, waitMs));
|
|
645
|
+
}
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
// 4. Sliding window burst limit: Cap at MAX_REQUESTS_PER_WINDOW calls/minute
|
|
649
|
+
if (fileState.timestamps.length >= MAX_REQUESTS_PER_WINDOW && !options.noWait) {
|
|
650
|
+
const oldestTimestamp = fileState.timestamps[0];
|
|
651
|
+
const waitMs = RATE_GATE_WINDOW_MS - (Date.now() - oldestTimestamp) + 200;
|
|
652
|
+
if (waitMs > 0) {
|
|
653
|
+
console.error(
|
|
654
|
+
`[RateLimitGuard] Approaching Figma rate limit: pausing ${(waitMs / 1000).toFixed(1)}s to protect file quota...`
|
|
655
|
+
);
|
|
656
|
+
await new Promise((resolve) => setTimeout(resolve, waitMs));
|
|
657
|
+
}
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
// Record this request timestamp
|
|
661
|
+
const currentNow = Date.now();
|
|
662
|
+
fileState.timestamps = (fileState.timestamps || []).filter((t) => currentNow - t < RATE_GATE_WINDOW_MS);
|
|
663
|
+
fileState.timestamps.push(currentNow);
|
|
664
|
+
state.files[fileKey] = fileState;
|
|
665
|
+
saveRateGateState(state);
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
// -----------------------------------------------------------------------------
|
|
669
|
+
// Figma API Execution
|
|
670
|
+
// -----------------------------------------------------------------------------
|
|
671
|
+
|
|
672
|
+
async function executeFigmaRequest(url, headers, fileKey, options = {}) {
|
|
673
|
+
if (!options.bypassRateGate && fileKey) {
|
|
674
|
+
await checkAndApplyRateGate(fileKey, options);
|
|
675
|
+
}
|
|
676
|
+
return makeRequest(url, headers);
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
async function fetchFigmaVariables(fileKey, headers, options = {}) {
|
|
680
|
+
const state = loadRateGateState();
|
|
681
|
+
if (state.files && state.files[fileKey] && state.files[fileKey].variablesSupported === false) {
|
|
682
|
+
// Proactively skip: this file does not support variables or lacks scope
|
|
683
|
+
return null;
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
const url = `https://api.figma.com/v1/files/${fileKey}/variables/local`;
|
|
687
|
+
try {
|
|
688
|
+
const rawData = await executeFigmaRequest(url, headers, fileKey, options);
|
|
689
|
+
const parsed = JSON.parse(rawData);
|
|
690
|
+
if (parsed.status === 200 && parsed.meta) {
|
|
691
|
+
return parsed.meta;
|
|
692
|
+
}
|
|
693
|
+
return null;
|
|
694
|
+
} catch (e) {
|
|
695
|
+
// Only re-throw rate limit (429) so callers can respect rate-limiting policies
|
|
696
|
+
if (e.statusCode === 429) {
|
|
697
|
+
throw e;
|
|
698
|
+
}
|
|
699
|
+
// Proactively record lack of variable scope so subsequent runs save an API call
|
|
700
|
+
if (e.statusCode === 403 || e.statusCode === 404) {
|
|
701
|
+
const currentState = loadRateGateState();
|
|
702
|
+
if (!currentState.files) currentState.files = {};
|
|
703
|
+
const fileState = currentState.files[fileKey] || { timestamps: [], lockedUntil: 0 };
|
|
704
|
+
fileState.variablesSupported = false;
|
|
705
|
+
currentState.files[fileKey] = fileState;
|
|
706
|
+
saveRateGateState(currentState);
|
|
707
|
+
}
|
|
708
|
+
return null;
|
|
709
|
+
}
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
function buildVariableMap(variablesMeta) {
|
|
713
|
+
const varMap = {};
|
|
714
|
+
const dictionary = {
|
|
715
|
+
colors: {},
|
|
716
|
+
spacing: {},
|
|
717
|
+
radii: {},
|
|
718
|
+
typography: {},
|
|
719
|
+
other: {},
|
|
720
|
+
};
|
|
721
|
+
|
|
722
|
+
if (!variablesMeta || !variablesMeta.variables) {
|
|
723
|
+
return { varMap, dictionary };
|
|
724
|
+
}
|
|
725
|
+
|
|
726
|
+
const variables = variablesMeta.variables;
|
|
727
|
+
const sets = variablesMeta.variableSets || {};
|
|
728
|
+
|
|
729
|
+
for (const [id, variable] of Object.entries(variables)) {
|
|
730
|
+
const name = variable.name;
|
|
731
|
+
const type = variable.resolvedType;
|
|
732
|
+
varMap[id] = { name, resolvedType: type };
|
|
733
|
+
|
|
734
|
+
const set = sets[variable.variableSetId];
|
|
735
|
+
const defaultModeId = set?.defaultModeId;
|
|
736
|
+
const modes = variable.valuesByMode || {};
|
|
737
|
+
const val = (defaultModeId && modes[defaultModeId]) ?? Object.values(modes)[0];
|
|
738
|
+
|
|
739
|
+
let formattedVal = val;
|
|
740
|
+
if (type === 'COLOR' && val && typeof val === 'object') {
|
|
741
|
+
formattedVal = formatRgba(val);
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
// Categorize into dictionary buckets.
|
|
745
|
+
// Priority order: colors > spacing > radii > typography > other
|
|
746
|
+
// Variables with overlapping keywords (e.g. "text/fill/primary") land in the first matching bucket.
|
|
747
|
+
const lowerName = name.toLowerCase();
|
|
748
|
+
if (type === 'COLOR' || lowerName.includes('color') || lowerName.includes('brand') || lowerName.includes('bg') || lowerName.includes('fill')) {
|
|
749
|
+
dictionary.colors[name] = formattedVal;
|
|
750
|
+
} else if (lowerName.includes('spac') || lowerName.includes('gap') || lowerName.includes('padding') || lowerName.includes('margin') || lowerName.includes('offset')) {
|
|
751
|
+
dictionary.spacing[name] = formattedVal;
|
|
752
|
+
} else if (lowerName.includes('radi') || lowerName.includes('corner') || lowerName.includes('round')) {
|
|
753
|
+
dictionary.radii[name] = formattedVal;
|
|
754
|
+
} else if (lowerName.includes('font') || lowerName.includes('text') || lowerName.includes('size') || lowerName.includes('typography')) {
|
|
755
|
+
dictionary.typography[name] = formattedVal;
|
|
756
|
+
} else {
|
|
757
|
+
dictionary.other[name] = formattedVal;
|
|
758
|
+
}
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
return { varMap, dictionary };
|
|
762
|
+
}
|
|
763
|
+
|
|
764
|
+
async function fetchFigmaNodes(fileKey, nodeId, headers, options = {}) {
|
|
765
|
+
let url = `https://api.figma.com/v1/files/${fileKey}`;
|
|
766
|
+
if (nodeId) {
|
|
767
|
+
url += `/nodes?ids=${encodeURIComponent(nodeId)}`;
|
|
768
|
+
if (options.depth) url += `&depth=${options.depth}`;
|
|
769
|
+
} else if (options.depth) {
|
|
770
|
+
url += `?depth=${options.depth}`;
|
|
771
|
+
}
|
|
772
|
+
|
|
773
|
+
const rawData = await executeFigmaRequest(url, headers, fileKey, options);
|
|
774
|
+
const parsed = JSON.parse(rawData);
|
|
775
|
+
|
|
776
|
+
// Fetch variables unless explicitly disabled
|
|
777
|
+
let varMap = {};
|
|
778
|
+
let tokensDictionary = null;
|
|
779
|
+
if (!options.noVariables) {
|
|
780
|
+
const varsMeta = await fetchFigmaVariables(fileKey, headers, options);
|
|
781
|
+
const built = buildVariableMap(varsMeta);
|
|
782
|
+
varMap = built.varMap;
|
|
783
|
+
tokensDictionary = built.dictionary;
|
|
784
|
+
}
|
|
785
|
+
|
|
786
|
+
const context = {
|
|
787
|
+
varMap,
|
|
788
|
+
components: parsed.components || {},
|
|
789
|
+
componentSets: parsed.componentSets || {},
|
|
790
|
+
};
|
|
791
|
+
|
|
792
|
+
if (nodeId && parsed.nodes) {
|
|
793
|
+
const targetNode = parsed.nodes[nodeId];
|
|
794
|
+
if (!targetNode || !targetNode.document) {
|
|
795
|
+
throw new Error(`Node ID "${nodeId}" not found in Figma file "${fileKey}".`);
|
|
796
|
+
}
|
|
797
|
+
|
|
798
|
+
// Merge components if present in target node
|
|
799
|
+
if (targetNode.components) {
|
|
800
|
+
context.components = { ...context.components, ...targetNode.components };
|
|
801
|
+
}
|
|
802
|
+
if (targetNode.componentSets) {
|
|
803
|
+
context.componentSets = { ...context.componentSets, ...targetNode.componentSets };
|
|
804
|
+
}
|
|
805
|
+
|
|
806
|
+
if (options.raw) {
|
|
807
|
+
const rawRes = {
|
|
808
|
+
name: parsed.name,
|
|
809
|
+
lastModified: parsed.lastModified,
|
|
810
|
+
thumbnailUrl: parsed.thumbnailUrl,
|
|
811
|
+
node: targetNode.document,
|
|
812
|
+
components: targetNode.components,
|
|
813
|
+
componentSets: targetNode.componentSets,
|
|
814
|
+
schemaVersion: parsed.schemaVersion,
|
|
815
|
+
};
|
|
816
|
+
if (options.includeTokens && tokensDictionary) {
|
|
817
|
+
rawRes.tokens = tokensDictionary;
|
|
818
|
+
}
|
|
819
|
+
return rawRes;
|
|
820
|
+
}
|
|
821
|
+
|
|
822
|
+
const res = {
|
|
823
|
+
name: parsed.name,
|
|
824
|
+
node: pruneFigmaNode(targetNode.document, context, options),
|
|
825
|
+
};
|
|
826
|
+
if (options.includeTokens && tokensDictionary) {
|
|
827
|
+
res.tokens = tokensDictionary;
|
|
828
|
+
}
|
|
829
|
+
return res;
|
|
830
|
+
}
|
|
831
|
+
|
|
832
|
+
if (options.raw) {
|
|
833
|
+
const rawRes = {
|
|
834
|
+
name: parsed.name,
|
|
835
|
+
lastModified: parsed.lastModified,
|
|
836
|
+
document: parsed.document,
|
|
837
|
+
components: parsed.components,
|
|
838
|
+
componentSets: parsed.componentSets,
|
|
839
|
+
};
|
|
840
|
+
if (options.includeTokens && tokensDictionary) {
|
|
841
|
+
rawRes.tokens = tokensDictionary;
|
|
842
|
+
}
|
|
843
|
+
return rawRes;
|
|
844
|
+
}
|
|
845
|
+
|
|
846
|
+
const res = {
|
|
847
|
+
name: parsed.name,
|
|
848
|
+
document: pruneFigmaNode(parsed.document, context, options),
|
|
849
|
+
};
|
|
850
|
+
if (options.includeTokens && tokensDictionary) {
|
|
851
|
+
res.tokens = tokensDictionary;
|
|
852
|
+
}
|
|
853
|
+
return res;
|
|
854
|
+
}
|
|
855
|
+
|
|
856
|
+
async function fetchFigmaImage(fileKey, nodeId, headers, targetPath, options = {}) {
|
|
857
|
+
const scale = options.scale || 2;
|
|
858
|
+
const format = options.format || 'png';
|
|
859
|
+
const url = `https://api.figma.com/v1/images/${fileKey}?ids=${encodeURIComponent(nodeId)}&scale=${scale}&format=${format}`;
|
|
860
|
+
|
|
861
|
+
const rawData = await executeFigmaRequest(url, headers, fileKey, options);
|
|
862
|
+
const parsed = JSON.parse(rawData);
|
|
863
|
+
|
|
864
|
+
if (parsed.err) {
|
|
865
|
+
throw new Error(`Figma Image API error: ${parsed.err}`);
|
|
866
|
+
}
|
|
867
|
+
|
|
868
|
+
const imageUrl = parsed.images ? parsed.images[nodeId] : null;
|
|
869
|
+
if (!imageUrl) {
|
|
870
|
+
throw new Error(`No rendered image returned for node ID "${nodeId}".`);
|
|
871
|
+
}
|
|
872
|
+
|
|
873
|
+
const resolvedPath = path.resolve(
|
|
874
|
+
targetPath || `./.specs/figma_${fileKey}_${nodeId.replace(/:/g, '_')}.${format}`,
|
|
875
|
+
);
|
|
876
|
+
|
|
877
|
+
await downloadBinaryFile(imageUrl, resolvedPath);
|
|
878
|
+
return {
|
|
879
|
+
path: resolvedPath,
|
|
880
|
+
format,
|
|
881
|
+
scale,
|
|
882
|
+
remoteUrl: imageUrl,
|
|
883
|
+
};
|
|
884
|
+
}
|
|
885
|
+
|
|
886
|
+
// -----------------------------------------------------------------------------
|
|
887
|
+
// CLI Argument Parsing
|
|
888
|
+
// -----------------------------------------------------------------------------
|
|
889
|
+
|
|
890
|
+
function parseArguments(argv) {
|
|
891
|
+
const args = argv.slice(2);
|
|
892
|
+
const result = {
|
|
893
|
+
fileKey: null,
|
|
894
|
+
nodeId: null,
|
|
895
|
+
specName: null,
|
|
896
|
+
specJsonPath: null,
|
|
897
|
+
imagePath: null,
|
|
898
|
+
downloadImage: false,
|
|
899
|
+
raw: false,
|
|
900
|
+
depth: null,
|
|
901
|
+
scale: 2,
|
|
902
|
+
format: 'png',
|
|
903
|
+
tokensOnly: false,
|
|
904
|
+
shallowInstances: false,
|
|
905
|
+
noVariables: false,
|
|
906
|
+
includeTokens: false,
|
|
907
|
+
includeIds: false,
|
|
908
|
+
pretty: false,
|
|
909
|
+
help: false,
|
|
910
|
+
};
|
|
911
|
+
|
|
912
|
+
if (args.length === 0 || args.includes('--help') || args.includes('-h')) {
|
|
913
|
+
result.help = true;
|
|
914
|
+
return result;
|
|
915
|
+
}
|
|
916
|
+
|
|
917
|
+
const positional = [];
|
|
918
|
+
|
|
919
|
+
for (let i = 0; i < args.length; i++) {
|
|
920
|
+
const arg = args[i];
|
|
921
|
+
if (arg === '--raw') {
|
|
922
|
+
result.raw = true;
|
|
923
|
+
} else if (arg === '--spec') {
|
|
924
|
+
if (i + 1 >= args.length) continue;
|
|
925
|
+
result.specName = args[++i];
|
|
926
|
+
} else if (arg === '--image' || arg === '-i' || arg === '--download-image') {
|
|
927
|
+
result.downloadImage = true;
|
|
928
|
+
} else if (arg === '--image-path' || arg === '-o' || arg === '--output') {
|
|
929
|
+
if (i + 1 >= args.length) continue;
|
|
930
|
+
result.imagePath = args[++i];
|
|
931
|
+
result.downloadImage = true;
|
|
932
|
+
} else if (arg === '--depth') {
|
|
933
|
+
if (i + 1 >= args.length) continue;
|
|
934
|
+
result.depth = parseInt(args[++i], 10);
|
|
935
|
+
} else if (arg === '--scale') {
|
|
936
|
+
if (i + 1 >= args.length) continue;
|
|
937
|
+
result.scale = parseFloat(args[++i]);
|
|
938
|
+
} else if (arg === '--format') {
|
|
939
|
+
if (i + 1 >= args.length) continue;
|
|
940
|
+
result.format = args[++i].toLowerCase();
|
|
941
|
+
} else if (arg === '--tokens' || arg === '--variables') {
|
|
942
|
+
result.tokensOnly = true;
|
|
943
|
+
} else if (arg === '--shallow-instances') {
|
|
944
|
+
result.shallowInstances = true;
|
|
945
|
+
} else if (arg === '--no-variables') {
|
|
946
|
+
result.noVariables = true;
|
|
947
|
+
} else if (arg === '--include-tokens') {
|
|
948
|
+
result.includeTokens = true;
|
|
949
|
+
} else if (arg === '--include-ids') {
|
|
950
|
+
result.includeIds = true;
|
|
951
|
+
} else if (arg === '--pretty') {
|
|
952
|
+
result.pretty = true;
|
|
953
|
+
} else if (arg === '--reset-circuit-breaker' || arg === '--reset-rate-gate') {
|
|
954
|
+
result.resetRateGate = true;
|
|
955
|
+
} else if (arg === '--bypass-circuit-breaker' || arg === '--bypass-rate-gate' || arg === '--no-rate-gate') {
|
|
956
|
+
result.bypassRateGate = true;
|
|
957
|
+
} else if (arg === '--force' || arg === '-f' || arg === '--fresh') {
|
|
958
|
+
result.force = true;
|
|
959
|
+
} else if (!arg.startsWith('-')) {
|
|
960
|
+
positional.push(arg);
|
|
961
|
+
}
|
|
962
|
+
}
|
|
963
|
+
|
|
964
|
+
// Handle atomic --spec <name> paths
|
|
965
|
+
if (result.specName) {
|
|
966
|
+
let rawSpec = result.specName.trim();
|
|
967
|
+
const hasDir = rawSpec.includes('/') || rawSpec.includes('\\');
|
|
968
|
+
const specDir = hasDir ? path.dirname(rawSpec) : './.specs';
|
|
969
|
+
let baseName = path.basename(rawSpec);
|
|
970
|
+
if (baseName.endsWith('.json')) baseName = baseName.slice(0, -5);
|
|
971
|
+
else if (baseName.endsWith('.png')) baseName = baseName.slice(0, -4);
|
|
972
|
+
|
|
973
|
+
result.specJsonPath = path.join(specDir, `${baseName}.json`);
|
|
974
|
+
if (!result.imagePath) {
|
|
975
|
+
result.imagePath = path.join(specDir, `${baseName}.png`);
|
|
976
|
+
}
|
|
977
|
+
}
|
|
978
|
+
|
|
979
|
+
if (positional.length === 0 && !result.help) {
|
|
980
|
+
return result;
|
|
981
|
+
}
|
|
982
|
+
|
|
983
|
+
const first = positional[0];
|
|
984
|
+
const isUrl = first.includes('figma.com') || first.startsWith('http://') || first.startsWith('https://');
|
|
985
|
+
|
|
986
|
+
if (isUrl) {
|
|
987
|
+
const parsed = parseFigmaUrl(first);
|
|
988
|
+
result.fileKey = parsed.fileKey;
|
|
989
|
+
result.nodeId = parsed.nodeId || (positional.length >= 2 ? normalizeNodeId(positional[1]) : null);
|
|
990
|
+
} else {
|
|
991
|
+
// Parameters style: <fileKey> [nodeId]
|
|
992
|
+
result.fileKey = positional[0];
|
|
993
|
+
if (positional.length >= 2) {
|
|
994
|
+
result.nodeId = normalizeNodeId(positional[1]);
|
|
995
|
+
}
|
|
996
|
+
}
|
|
997
|
+
|
|
998
|
+
return result;
|
|
999
|
+
}
|
|
1000
|
+
|
|
1001
|
+
function printUsage() {
|
|
1002
|
+
console.log(`
|
|
1003
|
+
Figma Extractor - Design-System-Aware Agent Skill
|
|
1004
|
+
|
|
1005
|
+
Usage:
|
|
1006
|
+
node fetch_figma.js <FigmaURL> [options]
|
|
1007
|
+
node fetch_figma.js <fileKey> [nodeId] [options]
|
|
1008
|
+
|
|
1009
|
+
Options:
|
|
1010
|
+
--spec <name> Atomically save ./.specs/<name>.json (add -i to also download preview PNG)
|
|
1011
|
+
--image, -i Download rendered preview image from Figma (Tier 1 call)
|
|
1012
|
+
--image-path, -o <path> Target path for saved preview image (implies --image)
|
|
1013
|
+
--tokens, --variables Export only the design token dictionary (colors, spacing, radii)
|
|
1014
|
+
--shallow-instances Collapse internal layers of component instances into props
|
|
1015
|
+
--include-tokens Include complete tokens dictionary in node output
|
|
1016
|
+
--include-ids Keep Figma internal node IDs in pruned AST
|
|
1017
|
+
--pretty Pretty-print JSON with 2-space indentation (default is minified)
|
|
1018
|
+
--raw Return full raw Figma API response (unpruned)
|
|
1019
|
+
--depth <number> Traverse node tree down to specific depth
|
|
1020
|
+
--scale <1|2|3|4> Image render scale (default: 2)
|
|
1021
|
+
--format <png|svg|jpg|pdf> Image format (default: png)
|
|
1022
|
+
--no-variables Skip querying /variables/local
|
|
1023
|
+
--help, -h Show this help message
|
|
1024
|
+
|
|
1025
|
+
Examples:
|
|
1026
|
+
# Atomic SDD extraction (AST only - 1 Tier 1 call)
|
|
1027
|
+
node fetch_figma.js "https://www.figma.com/design/.../App?node-id=4023-474" --spec card
|
|
1028
|
+
|
|
1029
|
+
# Atomic SDD extraction with companion preview image (2 Tier 1 calls)
|
|
1030
|
+
node fetch_figma.js "https://www.figma.com/design/.../App?node-id=4023-474" --spec card -i
|
|
1031
|
+
|
|
1032
|
+
# Download preview image to custom path
|
|
1033
|
+
node fetch_figma.js "https://www.figma.com/design/.../App?node-id=4023-474" -i -o ./.specs/preview.png
|
|
1034
|
+
|
|
1035
|
+
# Export tokens only
|
|
1036
|
+
node fetch_figma.js "https://www.figma.com/design/.../App" --tokens
|
|
1037
|
+
`);
|
|
1038
|
+
}
|
|
1039
|
+
|
|
1040
|
+
// -----------------------------------------------------------------------------
|
|
1041
|
+
// Main CLI Function
|
|
1042
|
+
// -----------------------------------------------------------------------------
|
|
1043
|
+
|
|
1044
|
+
async function main() {
|
|
1045
|
+
loadEnv();
|
|
1046
|
+
const config = parseArguments(process.argv);
|
|
1047
|
+
|
|
1048
|
+
if (config.resetRateGate) {
|
|
1049
|
+
resetRateGateState(config.fileKey);
|
|
1050
|
+
console.log(
|
|
1051
|
+
JSON.stringify(
|
|
1052
|
+
{
|
|
1053
|
+
message: `Rate limit safety gate cleared${config.fileKey ? ` for "${config.fileKey}"` : ''}.`,
|
|
1054
|
+
success: true,
|
|
1055
|
+
},
|
|
1056
|
+
null,
|
|
1057
|
+
config.pretty ? 2 : 0,
|
|
1058
|
+
),
|
|
1059
|
+
);
|
|
1060
|
+
return;
|
|
1061
|
+
}
|
|
1062
|
+
|
|
1063
|
+
if (config.help || !config.fileKey) {
|
|
1064
|
+
printUsage();
|
|
1065
|
+
if (!config.help) {
|
|
1066
|
+
console.error(
|
|
1067
|
+
JSON.stringify(
|
|
1068
|
+
{
|
|
1069
|
+
error: 'Missing required Figma URL or fileKey.',
|
|
1070
|
+
},
|
|
1071
|
+
null,
|
|
1072
|
+
config.pretty ? 2 : 0,
|
|
1073
|
+
),
|
|
1074
|
+
);
|
|
1075
|
+
process.exit(1);
|
|
1076
|
+
}
|
|
1077
|
+
process.exit(0);
|
|
1078
|
+
}
|
|
1079
|
+
|
|
1080
|
+
const token = getFigmaToken();
|
|
1081
|
+
if (!token) {
|
|
1082
|
+
console.error(
|
|
1083
|
+
JSON.stringify(
|
|
1084
|
+
{
|
|
1085
|
+
error:
|
|
1086
|
+
'FIGMA_ACCESS_TOKEN is not set. Please set FIGMA_ACCESS_TOKEN in your environment or in the workspace .env file.',
|
|
1087
|
+
isTokenMissing: true,
|
|
1088
|
+
},
|
|
1089
|
+
null,
|
|
1090
|
+
config.pretty ? 2 : 0,
|
|
1091
|
+
),
|
|
1092
|
+
);
|
|
1093
|
+
process.exit(1);
|
|
1094
|
+
}
|
|
1095
|
+
|
|
1096
|
+
const headers = getAuthHeaders(token);
|
|
1097
|
+
|
|
1098
|
+
try {
|
|
1099
|
+
// Proactive Spec Reuse: If spec already exists locally and --force is not passed, reuse with 0 API calls
|
|
1100
|
+
if (config.specJsonPath && fs.existsSync(path.resolve(config.specJsonPath)) && !config.force) {
|
|
1101
|
+
try {
|
|
1102
|
+
const rawContent = fs.readFileSync(path.resolve(config.specJsonPath), 'utf8');
|
|
1103
|
+
const specLabel = path.basename(config.specJsonPath, '.json');
|
|
1104
|
+
const sizeKb = (Buffer.byteLength(rawContent, 'utf8') / 1024).toFixed(1);
|
|
1105
|
+
console.log(`\n✔ Reusing local Figma specification for "${specLabel}" (0 API calls):`);
|
|
1106
|
+
console.log(` • AST Spec: ${config.specJsonPath} (${sizeKb} KB)`);
|
|
1107
|
+
if (config.imagePath && fs.existsSync(path.resolve(config.imagePath))) {
|
|
1108
|
+
console.log(` • Preview: ${config.imagePath}`);
|
|
1109
|
+
}
|
|
1110
|
+
console.log(` (Pass --force or -f to re-fetch freshly from Figma API)\n`);
|
|
1111
|
+
return;
|
|
1112
|
+
} catch (_) {}
|
|
1113
|
+
}
|
|
1114
|
+
|
|
1115
|
+
// Mode: Tokens Only
|
|
1116
|
+
if (config.tokensOnly) {
|
|
1117
|
+
const varsMeta = await fetchFigmaVariables(config.fileKey, headers, {
|
|
1118
|
+
bypassRateGate: config.bypassRateGate,
|
|
1119
|
+
});
|
|
1120
|
+
if (!varsMeta) {
|
|
1121
|
+
console.error(
|
|
1122
|
+
JSON.stringify(
|
|
1123
|
+
{
|
|
1124
|
+
error:
|
|
1125
|
+
'FIGMA_VARIABLES_NOT_ACCESSIBLE: Variables could not be fetched from /variables/local. Ensure your Figma access token has the "file_variables:read" scope and the file belongs to an Organization or Enterprise workspace supporting design tokens.',
|
|
1126
|
+
statusCode: 403,
|
|
1127
|
+
},
|
|
1128
|
+
null,
|
|
1129
|
+
config.pretty ? 2 : 0,
|
|
1130
|
+
),
|
|
1131
|
+
);
|
|
1132
|
+
process.exit(1);
|
|
1133
|
+
}
|
|
1134
|
+
const { dictionary } = buildVariableMap(varsMeta);
|
|
1135
|
+
console.log(
|
|
1136
|
+
JSON.stringify(
|
|
1137
|
+
{
|
|
1138
|
+
tokens: dictionary,
|
|
1139
|
+
},
|
|
1140
|
+
null,
|
|
1141
|
+
config.pretty ? 2 : 0,
|
|
1142
|
+
),
|
|
1143
|
+
);
|
|
1144
|
+
return;
|
|
1145
|
+
}
|
|
1146
|
+
|
|
1147
|
+
// 1. Fetch Node JSON
|
|
1148
|
+
const nodeData = await fetchFigmaNodes(config.fileKey, config.nodeId, headers, {
|
|
1149
|
+
raw: config.raw,
|
|
1150
|
+
depth: config.depth,
|
|
1151
|
+
shallowInstances: config.shallowInstances,
|
|
1152
|
+
noVariables: config.noVariables,
|
|
1153
|
+
includeTokens: config.includeTokens,
|
|
1154
|
+
includeIds: config.includeIds,
|
|
1155
|
+
bypassRateGate: config.bypassRateGate,
|
|
1156
|
+
});
|
|
1157
|
+
|
|
1158
|
+
// 2. Fetch Image Preview if requested
|
|
1159
|
+
let imageResult = null;
|
|
1160
|
+
if (config.downloadImage && config.nodeId) {
|
|
1161
|
+
try {
|
|
1162
|
+
if (config.imagePath && fs.existsSync(path.resolve(config.imagePath))) {
|
|
1163
|
+
imageResult = {
|
|
1164
|
+
path: path.resolve(config.imagePath),
|
|
1165
|
+
source: 'local',
|
|
1166
|
+
};
|
|
1167
|
+
} else {
|
|
1168
|
+
imageResult = await fetchFigmaImage(
|
|
1169
|
+
config.fileKey,
|
|
1170
|
+
config.nodeId,
|
|
1171
|
+
headers,
|
|
1172
|
+
config.imagePath,
|
|
1173
|
+
{ scale: config.scale, format: config.format, bypassRateGate: config.bypassRateGate },
|
|
1174
|
+
);
|
|
1175
|
+
}
|
|
1176
|
+
} catch (imgErr) {
|
|
1177
|
+
imageResult = {
|
|
1178
|
+
error: `Failed to download image preview: ${imgErr.message}`,
|
|
1179
|
+
};
|
|
1180
|
+
}
|
|
1181
|
+
}
|
|
1182
|
+
|
|
1183
|
+
// 3. Assemble structured result (compact & token-optimized)
|
|
1184
|
+
const output = {
|
|
1185
|
+
...nodeData,
|
|
1186
|
+
};
|
|
1187
|
+
|
|
1188
|
+
if (imageResult) {
|
|
1189
|
+
output.image = imageResult;
|
|
1190
|
+
}
|
|
1191
|
+
|
|
1192
|
+
if (config.specJsonPath) {
|
|
1193
|
+
const jsonDir = path.dirname(path.resolve(config.specJsonPath));
|
|
1194
|
+
if (!fs.existsSync(jsonDir)) {
|
|
1195
|
+
fs.mkdirSync(jsonDir, { recursive: true });
|
|
1196
|
+
}
|
|
1197
|
+
const jsonContent = JSON.stringify(output, null, config.pretty ? 2 : 0);
|
|
1198
|
+
fs.writeFileSync(path.resolve(config.specJsonPath), jsonContent, 'utf8');
|
|
1199
|
+
|
|
1200
|
+
const sizeKb = (Buffer.byteLength(jsonContent, 'utf8') / 1024).toFixed(1);
|
|
1201
|
+
const specLabel = path.basename(config.specJsonPath, '.json');
|
|
1202
|
+
console.log(`\n✔ Saved Figma specification for "${specLabel}":`);
|
|
1203
|
+
console.log(` • AST Spec: ${config.specJsonPath} (${sizeKb} KB)`);
|
|
1204
|
+
if (imageResult && !imageResult.error && imageResult.path) {
|
|
1205
|
+
console.log(` • Preview: ${config.imagePath}`);
|
|
1206
|
+
} else if (imageResult && imageResult.error) {
|
|
1207
|
+
console.log(` ⚠ Preview: ${imageResult.error}`);
|
|
1208
|
+
} else {
|
|
1209
|
+
console.log(` • Preview: (skipped to conserve Tier 1 quota; pass -i to download PNG)`);
|
|
1210
|
+
}
|
|
1211
|
+
console.log(``);
|
|
1212
|
+
} else {
|
|
1213
|
+
console.log(JSON.stringify(output, null, config.pretty ? 2 : 0));
|
|
1214
|
+
}
|
|
1215
|
+
} catch (error) {
|
|
1216
|
+
if (error.statusCode === 429 && error.retryAfter && !error.isCircuitBreaker) {
|
|
1217
|
+
recordRateLimitLockout(config.fileKey, error.retryAfter);
|
|
1218
|
+
}
|
|
1219
|
+
const errorPayload = formatApiError(error);
|
|
1220
|
+
console.error(JSON.stringify(errorPayload, null, config.pretty ? 2 : 0));
|
|
1221
|
+
process.exit(1);
|
|
1222
|
+
}
|
|
1223
|
+
}
|
|
1224
|
+
|
|
1225
|
+
function formatApiError(error) {
|
|
1226
|
+
let errorMessage = error.message;
|
|
1227
|
+
let isCircuitBreaker = Boolean(error.isCircuitBreaker);
|
|
1228
|
+
let isTokenExpiredOrInvalid = false;
|
|
1229
|
+
let isSslError = false;
|
|
1230
|
+
let isRateLimited = error.statusCode === 429 || isCircuitBreaker;
|
|
1231
|
+
const retryAfter = error.retryAfter !== undefined ? error.retryAfter : null;
|
|
1232
|
+
const rateLimitType = error.rateLimitType || null;
|
|
1233
|
+
|
|
1234
|
+
if (isCircuitBreaker) {
|
|
1235
|
+
errorMessage = error.message;
|
|
1236
|
+
} else if (error.statusCode === 401 || error.statusCode === 403) {
|
|
1237
|
+
isTokenExpiredOrInvalid = true;
|
|
1238
|
+
errorMessage =
|
|
1239
|
+
'FIGMA_TOKEN_EXPIRED_OR_INVALID: The provided Figma access token is expired, invalid, or unauthorized. Create a new token in Figma Settings -> Personal Access Tokens and export FIGMA_ACCESS_TOKEN or add it to .env.';
|
|
1240
|
+
} else if (error.statusCode === 429) {
|
|
1241
|
+
isRateLimited = true;
|
|
1242
|
+
let retryMsg = ' Please wait 30-60 seconds before making more requests.';
|
|
1243
|
+
if (retryAfter !== null) {
|
|
1244
|
+
if (retryAfter > 3600) {
|
|
1245
|
+
const hours = Math.round(retryAfter / 3600);
|
|
1246
|
+
const days = (retryAfter / 86400).toFixed(1);
|
|
1247
|
+
retryMsg = ` Please retry after ${retryAfter} seconds (~${hours} hours / ${days} days). This file or token may have exceeded the monthly Starter plan quota (up to 20 calls/month). Consider moving the file to a paid team or waiting until the quota resets.`;
|
|
1248
|
+
} else {
|
|
1249
|
+
retryMsg = ` Please retry after ${retryAfter} second${retryAfter === 1 ? '' : 's'}.`;
|
|
1250
|
+
}
|
|
1251
|
+
}
|
|
1252
|
+
const typeMsg = rateLimitType ? ` (Policy: ${rateLimitType})` : '';
|
|
1253
|
+
errorMessage = `FIGMA_RATE_LIMIT_EXCEEDED: Figma API rate limit reached.${typeMsg}${retryMsg}`;
|
|
1254
|
+
} else if (
|
|
1255
|
+
error.code === 'DEPTH_ZERO_SELF_SIGNED_CERT' ||
|
|
1256
|
+
error.code === 'UNABLE_TO_VERIFY_LEAF_SIGNATURE' ||
|
|
1257
|
+
(error.message &&
|
|
1258
|
+
(error.message.toLowerCase().includes('self-signed') ||
|
|
1259
|
+
error.message.toLowerCase().includes('self signed')))
|
|
1260
|
+
) {
|
|
1261
|
+
isSslError = true;
|
|
1262
|
+
errorMessage =
|
|
1263
|
+
'FIGMA_SSL_CERTIFICATE_ERROR: Self-signed certificate error. Set FIGMA_IGNORE_SSL=true in your .env file or environment to bypass.';
|
|
1264
|
+
}
|
|
1265
|
+
|
|
1266
|
+
const payload = {
|
|
1267
|
+
error: errorMessage,
|
|
1268
|
+
statusCode: error.statusCode || null,
|
|
1269
|
+
isCircuitBreaker,
|
|
1270
|
+
isTokenExpiredOrInvalid,
|
|
1271
|
+
isSslError,
|
|
1272
|
+
isRateLimited,
|
|
1273
|
+
};
|
|
1274
|
+
|
|
1275
|
+
if (retryAfter !== null) {
|
|
1276
|
+
payload.retryAfter = retryAfter;
|
|
1277
|
+
}
|
|
1278
|
+
if (rateLimitType) {
|
|
1279
|
+
payload.rateLimitType = rateLimitType;
|
|
1280
|
+
}
|
|
1281
|
+
|
|
1282
|
+
return payload;
|
|
1283
|
+
}
|
|
1284
|
+
|
|
1285
|
+
// Export internal functions for unit testing
|
|
1286
|
+
module.exports = {
|
|
1287
|
+
loadEnv,
|
|
1288
|
+
parseEnvLine,
|
|
1289
|
+
getFigmaToken,
|
|
1290
|
+
getAuthHeaders,
|
|
1291
|
+
parseFigmaUrl,
|
|
1292
|
+
normalizeNodeId,
|
|
1293
|
+
formatRgba,
|
|
1294
|
+
simplifyFills,
|
|
1295
|
+
simplifyStrokes,
|
|
1296
|
+
normalizePropKey,
|
|
1297
|
+
normalizeComponentProps,
|
|
1298
|
+
buildVariableMap,
|
|
1299
|
+
pruneFigmaNode,
|
|
1300
|
+
parseArguments,
|
|
1301
|
+
formatApiError,
|
|
1302
|
+
executeFigmaRequest,
|
|
1303
|
+
fetchFigmaVariables,
|
|
1304
|
+
checkAndApplyRateGate,
|
|
1305
|
+
loadRateGateState,
|
|
1306
|
+
saveRateGateState,
|
|
1307
|
+
resetRateGateState,
|
|
1308
|
+
recordRateLimitLockout,
|
|
1309
|
+
getRateGateFilePath,
|
|
1310
|
+
};
|
|
1311
|
+
|
|
1312
|
+
if (require.main === module) {
|
|
1313
|
+
main();
|
|
1314
|
+
}
|