@pedroab0/skills 1.0.1 → 1.2.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 +19 -3
- package/bin/cli.js +175 -35
- package/figma-extractor/README.md +14 -6
- package/figma-extractor/SKILL.md +18 -16
- package/figma-extractor/figma-plugin/README.md +10 -5
- package/figma-extractor/figma-plugin/code.js +461 -87
- package/figma-extractor/figma-plugin/ui.html +76 -23
- package/figma-extractor/scripts/fetch_figma.js +465 -61
- package/figma-node-builder/README.md +1 -1
- package/figma-node-builder/SKILL.md +7 -8
- package/figma-node-builder/scripts/inspect_spec.js +15 -2
- package/figma-to-code/README.md +10 -4
- package/figma-to-code/SKILL.md +18 -16
- package/figma-to-code/scripts/fetch_figma.js +448 -53
- package/package.json +12 -3
- package/figma-extractor/demo/desktop_home.json +0 -2434
- 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 +0 -472
- package/figma-extractor/demo/index.html +0 -188
- package/figma-extractor/demo/style.css +0 -360
- package/figma-extractor/figma-plugin/assets/cover-1920.png +0 -0
- package/figma-extractor/figma-plugin/assets/cover.jpg +0 -0
- package/figma-extractor/figma-plugin/assets/cover.png +0 -0
- package/figma-extractor/figma-plugin/assets/icon-128.png +0 -0
- package/figma-extractor/figma-plugin/assets/icon.png +0 -0
- package/figma-extractor/scripts/test_fetch_figma.js +0 -601
- package/figma-node-builder/scripts/test_inspect_spec.js +0 -148
- package/figma-to-code/scripts/test_fetch_figma.js +0 -581
|
@@ -111,35 +111,353 @@ function safeGet(obj, prop, fallback = undefined) {
|
|
|
111
111
|
}
|
|
112
112
|
}
|
|
113
113
|
|
|
114
|
-
|
|
114
|
+
// Safely resolve TextNode properties with automatic fallback to character range (0, 1) when figma.mixed
|
|
115
|
+
function safeGetTextProp(node, prop, rangeMethod, hasChars) {
|
|
116
|
+
const direct = safeGet(node, prop);
|
|
117
|
+
if (direct !== undefined) return direct;
|
|
118
|
+
if (hasChars && rangeMethod && typeof node[rangeMethod] === 'function') {
|
|
119
|
+
try {
|
|
120
|
+
const ranged = node[rangeMethod](0, 1);
|
|
121
|
+
if (typeof ranged !== 'symbol' && ranged !== undefined) {
|
|
122
|
+
return ranged;
|
|
123
|
+
}
|
|
124
|
+
} catch (_) {}
|
|
125
|
+
}
|
|
126
|
+
return undefined;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function resolveFontWeight(explicitWeight, fontStyle) {
|
|
130
|
+
if (typeof explicitWeight === 'number' && explicitWeight > 0) {
|
|
131
|
+
return explicitWeight;
|
|
132
|
+
}
|
|
133
|
+
if (!fontStyle || typeof fontStyle !== 'string') return undefined;
|
|
134
|
+
const s = fontStyle.toLowerCase();
|
|
135
|
+
if (/thin|hairline/.test(s)) return 100;
|
|
136
|
+
if (/extra\s*light|ultra\s*light/.test(s)) return 200;
|
|
137
|
+
if (/light/.test(s)) return 300;
|
|
138
|
+
if (/semi\s*bold|demi\s*bold/.test(s)) return 600;
|
|
139
|
+
if (/extra\s*bold|ultra\s*bold/.test(s)) return 800;
|
|
140
|
+
if (/bold/.test(s)) return 700;
|
|
141
|
+
if (/black|heavy/.test(s)) return 900;
|
|
142
|
+
if (/medium/.test(s)) return 500;
|
|
143
|
+
if (/regular|normal|book|roman/.test(s)) return 400;
|
|
144
|
+
return undefined;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
const VECTOR_PRIMITIVE_TYPES = new Set([
|
|
148
|
+
'VECTOR',
|
|
149
|
+
'BOOLEAN_OPERATION',
|
|
150
|
+
'STAR',
|
|
151
|
+
'LINE',
|
|
152
|
+
'REGULAR_POLYGON',
|
|
153
|
+
'POLYGON',
|
|
154
|
+
]);
|
|
155
|
+
|
|
156
|
+
function cleanLayerName(rawName, fallback = 'Node') {
|
|
157
|
+
if (!rawName || typeof rawName !== 'string') return fallback;
|
|
158
|
+
const trimmed = rawName.trim();
|
|
159
|
+
if (!trimmed) return fallback;
|
|
160
|
+
if (/^data:image\//i.test(trimmed)) return 'Image';
|
|
161
|
+
if (/^(?:<svg\b|<\?xml\b)/i.test(trimmed)) return 'SVG';
|
|
162
|
+
if (/^https?:\/\//i.test(trimmed) && trimmed.length > 60) {
|
|
163
|
+
try {
|
|
164
|
+
const lastSeg = trimmed.split('?')[0].split('/').pop();
|
|
165
|
+
return (lastSeg && lastSeg.length <= 60) ? lastSeg : 'Image';
|
|
166
|
+
} catch (_) {
|
|
167
|
+
return 'Image';
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
const singleLine = trimmed.replace(/\s+/g, ' ');
|
|
171
|
+
return singleLine.length > 80 ? singleLine.slice(0, 80) + '…' : singleLine;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function isRawAssetString(str) {
|
|
175
|
+
if (!str || typeof str !== 'string') return false;
|
|
176
|
+
const t = str.trim();
|
|
177
|
+
return /^data:image\//i.test(t) || /^(?:<svg\b|<\?xml\b)/i.test(t);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function nodeHasImageFill(node) {
|
|
181
|
+
const fills = safeGet(node, 'fills');
|
|
182
|
+
if (!Array.isArray(fills)) return false;
|
|
183
|
+
return fills.some((f) => f && f.visible !== false && f.type === 'IMAGE');
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function getVisibleChildren(node) {
|
|
187
|
+
const children = safeGet(node, 'children');
|
|
188
|
+
if (!Array.isArray(children) || children.length === 0) return [];
|
|
189
|
+
return children.filter((c) => {
|
|
190
|
+
try {
|
|
191
|
+
return c && safeGet(c, 'visible', true) !== false;
|
|
192
|
+
} catch (_) {
|
|
193
|
+
return false;
|
|
194
|
+
}
|
|
195
|
+
});
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
const GRAPHIC_NAME_RE =
|
|
199
|
+
/svg|icon|ícone|icone|logo|marca|brand|illustrat|ilustra|vector|vetor|symbol|simbol|símbolo|glyph|graphic|ornament|pattern|artwork/i;
|
|
200
|
+
|
|
201
|
+
function hasSvgExportSetting(node) {
|
|
202
|
+
const settings = safeGet(node, 'exportSettings');
|
|
203
|
+
if (!Array.isArray(settings) || settings.length === 0) return false;
|
|
204
|
+
return settings.some((s) => s && String(safeGet(s, 'format', '')).toUpperCase() === 'SVG');
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
function subtreeHasNoInstances(node, maxNodes = 400) {
|
|
208
|
+
let visited = 0;
|
|
209
|
+
function walk(curr, isRoot) {
|
|
210
|
+
if (++visited > maxNodes) return false;
|
|
211
|
+
const t = safeGet(curr, 'type', '');
|
|
212
|
+
if (!isRoot && (t === 'INSTANCE' || t === 'COMPONENT' || t === 'COMPONENT_SET')) return false;
|
|
213
|
+
for (const k of getVisibleChildren(curr)) {
|
|
214
|
+
if (!walk(k, false)) return false;
|
|
215
|
+
}
|
|
216
|
+
return true;
|
|
217
|
+
}
|
|
218
|
+
return walk(node, true);
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// Detects whether a container (GROUP, FRAME, INSTANCE, COMPONENT) is purely an SVG / icon / vector illustration
|
|
222
|
+
function analyzeVectorSubtree(node, maxNodes = 600) {
|
|
223
|
+
let visited = 0;
|
|
224
|
+
let vectorPrimitiveCount = 0;
|
|
225
|
+
let shapeCount = 0;
|
|
226
|
+
let plainRectCount = 0;
|
|
227
|
+
let primarySolidColor = null;
|
|
228
|
+
|
|
229
|
+
const rootType = safeGet(node, 'type', '');
|
|
230
|
+
const hasSvgExport = hasSvgExportSetting(node);
|
|
231
|
+
|
|
232
|
+
// An Auto-Layout FRAME with its own styling (e.g., Icon Badge) should keep its wrapper unless marked as SVG export
|
|
233
|
+
if (rootType === 'FRAME' && !hasSvgExport) {
|
|
234
|
+
const rootLm = safeGet(node, 'layoutMode');
|
|
235
|
+
if (rootLm && rootLm !== 'NONE') {
|
|
236
|
+
const ownFills = simplifyFills(safeGet(node, 'fills'));
|
|
237
|
+
const ownStrokes = simplifyStrokes(safeGet(node, 'strokes'), safeGet(node, 'strokeWeight'), safeGet(node, 'strokeAlign'));
|
|
238
|
+
const ownCr = safeGet(node, 'cornerRadius');
|
|
239
|
+
if (ownFills || ownStrokes || (typeof ownCr === 'number' && ownCr > 0)) {
|
|
240
|
+
return { isPure: false, primarySolidColor: null };
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
function check(curr, isRoot) {
|
|
246
|
+
if (++visited > maxNodes) return false;
|
|
247
|
+
const t = safeGet(curr, 'type', '');
|
|
248
|
+
if (t === 'TEXT' || t === 'SLICE' || t === 'STICKY' || t === 'TABLE') return false;
|
|
249
|
+
if (!isRoot && (t === 'INSTANCE' || t === 'COMPONENT' || t === 'COMPONENT_SET')) return false;
|
|
250
|
+
if (nodeHasImageFill(curr)) return false;
|
|
251
|
+
|
|
252
|
+
if (!primarySolidColor) {
|
|
253
|
+
const fills = simplifyFills(safeGet(curr, 'fills'));
|
|
254
|
+
const solidFill = fills && fills.find((f) => f.type === 'SOLID' && f.color);
|
|
255
|
+
if (solidFill) {
|
|
256
|
+
primarySolidColor = solidFill.color;
|
|
257
|
+
} else {
|
|
258
|
+
const strokes = simplifyStrokes(safeGet(curr, 'strokes'), safeGet(curr, 'strokeWeight'), safeGet(curr, 'strokeAlign'));
|
|
259
|
+
if (strokes && strokes[0] && strokes[0].color) {
|
|
260
|
+
primarySolidColor = strokes[0].color;
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
if (VECTOR_PRIMITIVE_TYPES.has(t)) {
|
|
266
|
+
vectorPrimitiveCount++;
|
|
267
|
+
return true;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
if (t === 'ELLIPSE') {
|
|
271
|
+
shapeCount++;
|
|
272
|
+
return true;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
if (t === 'RECTANGLE') {
|
|
276
|
+
const isMask = safeGet(curr, 'isMask') === true;
|
|
277
|
+
const rName = String(safeGet(curr, 'name', ''));
|
|
278
|
+
if (isMask || /clip|mask|vector|path|rect|bound|bg/i.test(rName)) {
|
|
279
|
+
shapeCount++;
|
|
280
|
+
} else {
|
|
281
|
+
plainRectCount++;
|
|
282
|
+
}
|
|
283
|
+
return true;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
if (t === 'GROUP' || t === 'FRAME' || (isRoot && (t === 'INSTANCE' || t === 'COMPONENT'))) {
|
|
287
|
+
if (!isRoot && t === 'FRAME') {
|
|
288
|
+
const lm = safeGet(curr, 'layoutMode');
|
|
289
|
+
if (lm && lm !== 'NONE') return false;
|
|
290
|
+
}
|
|
291
|
+
const kids = getVisibleChildren(curr);
|
|
292
|
+
if (kids.length === 0) return !isRoot;
|
|
293
|
+
for (const k of kids) {
|
|
294
|
+
if (!check(k, false)) return false;
|
|
295
|
+
}
|
|
296
|
+
return true;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
return false;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
const treeOk = check(node, true);
|
|
303
|
+
if (!treeOk) {
|
|
304
|
+
// Explicit SVG export setting on a self-contained graphic/logo (even if it contains logo <text>)
|
|
305
|
+
const w = safeGet(node, 'width', 0);
|
|
306
|
+
const h = safeGet(node, 'height', 0);
|
|
307
|
+
if (
|
|
308
|
+
hasSvgExport &&
|
|
309
|
+
typeof w === 'number' &&
|
|
310
|
+
typeof h === 'number' &&
|
|
311
|
+
w <= 800 &&
|
|
312
|
+
h <= 800 &&
|
|
313
|
+
!nodeHasImageFill(node) &&
|
|
314
|
+
subtreeHasNoInstances(node)
|
|
315
|
+
) {
|
|
316
|
+
return { isPure: true, primarySolidColor };
|
|
317
|
+
}
|
|
318
|
+
return { isPure: false, primarySolidColor: null };
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
const w = safeGet(node, 'width', 9999);
|
|
322
|
+
const h = safeGet(node, 'height', 9999);
|
|
323
|
+
const isSmallGraphic = typeof w === 'number' && typeof h === 'number' && w <= 160 && h <= 160;
|
|
324
|
+
const nodeName = String(safeGet(node, 'name', ''));
|
|
325
|
+
const hasGraphicName = GRAPHIC_NAME_RE.test(nodeName);
|
|
326
|
+
|
|
327
|
+
const hasEnoughPrimitives =
|
|
328
|
+
vectorPrimitiveCount > 0 ||
|
|
329
|
+
(shapeCount + plainRectCount >= 2 && (hasSvgExport || (isSmallGraphic && hasGraphicName)));
|
|
330
|
+
|
|
331
|
+
const rectGuardOk =
|
|
332
|
+
plainRectCount === 0 ||
|
|
333
|
+
vectorPrimitiveCount >= 2 ||
|
|
334
|
+
isSmallGraphic ||
|
|
335
|
+
hasGraphicName ||
|
|
336
|
+
hasSvgExport;
|
|
337
|
+
|
|
338
|
+
const isPure = hasEnoughPrimitives && rectGuardOk;
|
|
339
|
+
return { isPure, primarySolidColor };
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
// Detects masked image groups (e.g., Mask group with photo + mask shape and zero UI text/components)
|
|
343
|
+
function analyzeMaskedImageSubtree(node) {
|
|
344
|
+
const t = safeGet(node, 'type', '');
|
|
345
|
+
if (t !== 'GROUP' && t !== 'FRAME') return null;
|
|
346
|
+
const lm = safeGet(node, 'layoutMode');
|
|
347
|
+
if (lm && lm !== 'NONE') return null;
|
|
348
|
+
|
|
349
|
+
const kids = getVisibleChildren(node);
|
|
350
|
+
if (kids.length === 0 || kids.length > 20) return null;
|
|
351
|
+
|
|
352
|
+
let foundImageScaleMode = null;
|
|
353
|
+
let hasMaskOrClip = false;
|
|
354
|
+
|
|
355
|
+
function scan(curr) {
|
|
356
|
+
const ct = safeGet(curr, 'type', '');
|
|
357
|
+
if (ct === 'TEXT' || ct === 'INSTANCE' || ct === 'COMPONENT') return false;
|
|
358
|
+
if (safeGet(curr, 'isMask') === true) hasMaskOrClip = true;
|
|
359
|
+
|
|
360
|
+
const fills = safeGet(curr, 'fills');
|
|
361
|
+
if (Array.isArray(fills)) {
|
|
362
|
+
for (const f of fills) {
|
|
363
|
+
if (f && f.visible !== false && f.type === 'IMAGE') {
|
|
364
|
+
foundImageScaleMode = f.scaleMode || 'FILL';
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
const sub = getVisibleChildren(curr);
|
|
370
|
+
for (const s of sub) {
|
|
371
|
+
if (!scan(s)) return false;
|
|
372
|
+
}
|
|
373
|
+
return true;
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
if (!scan(node) || !foundImageScaleMode) return null;
|
|
377
|
+
if (!hasMaskOrClip && !/mask|image|img|photo|avatar|banner|thumb/i.test(String(safeGet(node, 'name', '')))) {
|
|
378
|
+
return null;
|
|
379
|
+
}
|
|
380
|
+
return { scaleMode: foundImageScaleMode };
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
function assignCleanCornerRadius(node, pruned) {
|
|
384
|
+
const cornerRadius = safeGet(node, 'cornerRadius');
|
|
385
|
+
if (typeof cornerRadius === 'number' && cornerRadius > 0) {
|
|
386
|
+
pruned.cornerRadius = Math.round(cornerRadius * 100) / 100;
|
|
387
|
+
return;
|
|
388
|
+
}
|
|
389
|
+
const tl = safeGet(node, 'topLeftRadius');
|
|
390
|
+
const tr = safeGet(node, 'topRightRadius');
|
|
391
|
+
const br = safeGet(node, 'bottomRightRadius');
|
|
392
|
+
const bl = safeGet(node, 'bottomLeftRadius');
|
|
393
|
+
const r0 = typeof tl === 'number' && tl > 0 ? Math.round(tl * 100) / 100 : 0;
|
|
394
|
+
const r1 = typeof tr === 'number' && tr > 0 ? Math.round(tr * 100) / 100 : 0;
|
|
395
|
+
const r2 = typeof br === 'number' && br > 0 ? Math.round(br * 100) / 100 : 0;
|
|
396
|
+
const r3 = typeof bl === 'number' && bl > 0 ? Math.round(bl * 100) / 100 : 0;
|
|
397
|
+
if (r0 > 0 || r1 > 0 || r2 > 0 || r3 > 0) {
|
|
398
|
+
if (r0 === r1 && r1 === r2 && r2 === r3) {
|
|
399
|
+
pruned.cornerRadius = r0;
|
|
400
|
+
} else {
|
|
401
|
+
pruned.cornerRadii = [r0, r1, r2, r3];
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
async function serializeNode(node, depth = 0) {
|
|
115
407
|
if (!node) return null;
|
|
116
408
|
if (depth > 25) {
|
|
117
|
-
return { name: safeGet(node, 'name', 'Node'), type: safeGet(node, 'type', 'FRAME') };
|
|
409
|
+
return { name: cleanLayerName(safeGet(node, 'name'), 'Node'), type: safeGet(node, 'type', 'FRAME') };
|
|
118
410
|
}
|
|
119
411
|
|
|
120
412
|
try {
|
|
121
413
|
const nodeType = safeGet(node, 'type', 'FRAME');
|
|
122
|
-
const
|
|
123
|
-
name: safeGet(node, 'name', 'Node'),
|
|
124
|
-
type: nodeType,
|
|
125
|
-
};
|
|
126
|
-
|
|
127
|
-
if (safeGet(node, 'visible') === false) pruned.visible = false;
|
|
414
|
+
const cleanName = cleanLayerName(safeGet(node, 'name'), 'Node');
|
|
128
415
|
|
|
129
416
|
// Dimensions & Coordinates
|
|
130
417
|
const width = safeGet(node, 'width');
|
|
131
418
|
const height = safeGet(node, 'height');
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
width: Math.round(width),
|
|
135
|
-
|
|
136
|
-
|
|
419
|
+
const bounds =
|
|
420
|
+
typeof width === 'number' && typeof height === 'number'
|
|
421
|
+
? { width: Math.round(width), height: Math.round(height) }
|
|
422
|
+
: undefined;
|
|
423
|
+
|
|
424
|
+
// 1. Leaf Vector Primitives (VECTOR, BOOLEAN_OPERATION, STAR, LINE, POLYGON) — never dump sub-paths
|
|
425
|
+
if (VECTOR_PRIMITIVE_TYPES.has(nodeType)) {
|
|
426
|
+
const vecNode = { name: cleanName, type: 'VECTOR' };
|
|
427
|
+
if (bounds) vecNode.bounds = bounds;
|
|
428
|
+
const fills = simplifyFills(safeGet(node, 'fills'));
|
|
429
|
+
const solidFill = fills && fills.find((f) => f.type === 'SOLID' && f.color);
|
|
430
|
+
if (solidFill) {
|
|
431
|
+
vecNode.fills = [{ type: 'SOLID', color: solidFill.color }];
|
|
432
|
+
} else {
|
|
433
|
+
const strokes = simplifyStrokes(safeGet(node, 'strokes'), safeGet(node, 'strokeWeight'), safeGet(node, 'strokeAlign'));
|
|
434
|
+
if (strokes && strokes[0] && strokes[0].color) {
|
|
435
|
+
vecNode.fills = [{ type: 'SOLID', color: strokes[0].color }];
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
return vecNode;
|
|
137
439
|
}
|
|
138
440
|
|
|
139
|
-
|
|
441
|
+
const pruned = {
|
|
442
|
+
name: cleanName,
|
|
443
|
+
type: nodeType,
|
|
444
|
+
};
|
|
445
|
+
|
|
446
|
+
if (safeGet(node, 'visible') === false) pruned.visible = false;
|
|
447
|
+
if (bounds) pruned.bounds = bounds;
|
|
448
|
+
|
|
449
|
+
// Component Instances & Variants (compatible with dynamic-page getMainComponentAsync)
|
|
140
450
|
if (nodeType === 'INSTANCE') {
|
|
141
451
|
try {
|
|
142
|
-
|
|
452
|
+
let main = null;
|
|
453
|
+
if (typeof node.getMainComponentAsync === 'function') {
|
|
454
|
+
try {
|
|
455
|
+
main = await node.getMainComponentAsync();
|
|
456
|
+
} catch (_) {}
|
|
457
|
+
}
|
|
458
|
+
if (!main) {
|
|
459
|
+
main = safeGet(node, 'mainComponent');
|
|
460
|
+
}
|
|
143
461
|
if (main) {
|
|
144
462
|
let setName = null;
|
|
145
463
|
try {
|
|
@@ -150,8 +468,8 @@ function serializeNode(node, depth = 0) {
|
|
|
150
468
|
} catch (_) {}
|
|
151
469
|
|
|
152
470
|
pruned.component = {
|
|
153
|
-
name: setName || safeGet(main, 'name', pruned.name),
|
|
154
|
-
variant: safeGet(main, 'name', pruned.name),
|
|
471
|
+
name: cleanLayerName(setName || safeGet(main, 'name'), pruned.name),
|
|
472
|
+
variant: cleanLayerName(safeGet(main, 'name'), pruned.name),
|
|
155
473
|
};
|
|
156
474
|
}
|
|
157
475
|
} catch (_) {
|
|
@@ -171,35 +489,64 @@ function serializeNode(node, depth = 0) {
|
|
|
171
489
|
} catch (_) {}
|
|
172
490
|
}
|
|
173
491
|
|
|
492
|
+
// 2. Collapse Pure SVG / Icon / Vector Graphic Containers (eliminates hundreds of internal Vector nodes)
|
|
493
|
+
if (depth > 0 || nodeType === 'GROUP' || nodeType === 'INSTANCE') {
|
|
494
|
+
const vecCheck = analyzeVectorSubtree(node);
|
|
495
|
+
if (vecCheck.isPure) {
|
|
496
|
+
if (nodeType !== 'INSTANCE' && nodeType !== 'COMPONENT') {
|
|
497
|
+
pruned.type = 'VECTOR';
|
|
498
|
+
pruned.isSvg = true;
|
|
499
|
+
} else {
|
|
500
|
+
assignCleanCornerRadius(node, pruned);
|
|
501
|
+
}
|
|
502
|
+
const ownFills = simplifyFills(safeGet(node, 'fills'));
|
|
503
|
+
if (ownFills) {
|
|
504
|
+
pruned.fills = ownFills;
|
|
505
|
+
} else if (vecCheck.primarySolidColor) {
|
|
506
|
+
pruned.fills = [{ type: 'SOLID', color: vecCheck.primarySolidColor }];
|
|
507
|
+
}
|
|
508
|
+
return pruned;
|
|
509
|
+
}
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
// 3. Collapse Masked Image Groups (photo + mask shape with zero UI text/components)
|
|
513
|
+
if (depth > 0) {
|
|
514
|
+
const imgGroup = analyzeMaskedImageSubtree(node);
|
|
515
|
+
if (imgGroup) {
|
|
516
|
+
pruned.fills = [{ type: 'IMAGE', scaleMode: imgGroup.scaleMode }];
|
|
517
|
+
assignCleanCornerRadius(node, pruned);
|
|
518
|
+
return pruned;
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
|
|
174
522
|
// Auto-Layout (Flexbox)
|
|
175
523
|
const layoutMode = safeGet(node, 'layoutMode');
|
|
176
524
|
if (layoutMode && layoutMode !== 'NONE') {
|
|
177
525
|
pruned.layoutMode = layoutMode;
|
|
178
526
|
const primaryAlign = safeGet(node, 'primaryAxisAlignItems');
|
|
179
527
|
const counterAlign = safeGet(node, 'counterAxisAlignItems');
|
|
180
|
-
if (primaryAlign) pruned.primaryAxisAlignItems = primaryAlign;
|
|
181
|
-
if (counterAlign) pruned.counterAxisAlignItems = counterAlign;
|
|
528
|
+
if (primaryAlign && primaryAlign !== 'MIN') pruned.primaryAxisAlignItems = primaryAlign;
|
|
529
|
+
if (counterAlign && counterAlign !== 'MIN') pruned.counterAxisAlignItems = counterAlign;
|
|
182
530
|
|
|
183
531
|
const itemSpacing = safeGet(node, 'itemSpacing');
|
|
184
532
|
if (typeof itemSpacing === 'number' && itemSpacing !== 0) {
|
|
185
|
-
pruned.itemSpacing = itemSpacing;
|
|
533
|
+
pruned.itemSpacing = Math.round(itemSpacing * 100) / 100;
|
|
186
534
|
}
|
|
187
535
|
|
|
188
536
|
const pTop = safeGet(node, 'paddingTop');
|
|
189
537
|
const pRight = safeGet(node, 'paddingRight');
|
|
190
538
|
const pBottom = safeGet(node, 'paddingBottom');
|
|
191
539
|
const pLeft = safeGet(node, 'paddingLeft');
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
};
|
|
540
|
+
const top = typeof pTop === 'number' ? pTop : 0;
|
|
541
|
+
const right = typeof pRight === 'number' ? pRight : 0;
|
|
542
|
+
const bottom = typeof pBottom === 'number' ? pBottom : 0;
|
|
543
|
+
const left = typeof pLeft === 'number' ? pLeft : 0;
|
|
544
|
+
if (top > 0 || right > 0 || bottom > 0 || left > 0) {
|
|
545
|
+
pruned.padding = { top, right, bottom, left };
|
|
199
546
|
}
|
|
200
547
|
|
|
201
548
|
const layoutWrap = safeGet(node, 'layoutWrap');
|
|
202
|
-
if (layoutWrap) pruned.layoutWrap = layoutWrap;
|
|
549
|
+
if (layoutWrap && layoutWrap !== 'NO_WRAP') pruned.layoutWrap = layoutWrap;
|
|
203
550
|
}
|
|
204
551
|
|
|
205
552
|
const layoutGrow = safeGet(node, 'layoutGrow');
|
|
@@ -223,27 +570,11 @@ function serializeNode(node, depth = 0) {
|
|
|
223
570
|
);
|
|
224
571
|
if (strokes) pruned.strokes = strokes;
|
|
225
572
|
|
|
226
|
-
// Corner Radius
|
|
227
|
-
|
|
228
|
-
if (typeof cornerRadius === 'number' && cornerRadius > 0) {
|
|
229
|
-
pruned.cornerRadius = cornerRadius;
|
|
230
|
-
} else {
|
|
231
|
-
const tl = safeGet(node, 'topLeftRadius');
|
|
232
|
-
const tr = safeGet(node, 'topRightRadius');
|
|
233
|
-
const br = safeGet(node, 'bottomRightRadius');
|
|
234
|
-
const bl = safeGet(node, 'bottomLeftRadius');
|
|
235
|
-
if (typeof tl === 'number' || typeof tr === 'number' || typeof br === 'number' || typeof bl === 'number') {
|
|
236
|
-
pruned.cornerRadii = [
|
|
237
|
-
typeof tl === 'number' ? tl : 0,
|
|
238
|
-
typeof tr === 'number' ? tr : 0,
|
|
239
|
-
typeof br === 'number' ? br : 0,
|
|
240
|
-
typeof bl === 'number' ? bl : 0,
|
|
241
|
-
];
|
|
242
|
-
}
|
|
243
|
-
}
|
|
573
|
+
// Corner Radius (only when > 0)
|
|
574
|
+
assignCleanCornerRadius(node, pruned);
|
|
244
575
|
|
|
245
576
|
const opacity = safeGet(node, 'opacity');
|
|
246
|
-
if (typeof opacity === 'number' && opacity
|
|
577
|
+
if (typeof opacity === 'number' && opacity < 0.999) {
|
|
247
578
|
pruned.opacity = Number(opacity.toFixed(2));
|
|
248
579
|
}
|
|
249
580
|
|
|
@@ -252,58 +583,101 @@ function serializeNode(node, depth = 0) {
|
|
|
252
583
|
if (Array.isArray(effects) && effects.length > 0) {
|
|
253
584
|
const visibleEffects = effects.filter((e) => e && safeGet(e, 'visible', true) !== false);
|
|
254
585
|
if (visibleEffects.length > 0) {
|
|
255
|
-
pruned.effects = visibleEffects.map((e) =>
|
|
256
|
-
type: safeGet(e, 'type')
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
586
|
+
pruned.effects = visibleEffects.map((e) => {
|
|
587
|
+
const eff = { type: safeGet(e, 'type') };
|
|
588
|
+
const rad = safeGet(e, 'radius');
|
|
589
|
+
if (typeof rad === 'number' && rad > 0) eff.radius = rad;
|
|
590
|
+
const col = safeGet(e, 'color') ? formatRgba(safeGet(e, 'color')) : undefined;
|
|
591
|
+
if (col) eff.color = col;
|
|
592
|
+
const off = safeGet(e, 'offset');
|
|
593
|
+
if (off && (off.x || off.y)) eff.offset = off;
|
|
594
|
+
return eff;
|
|
595
|
+
});
|
|
261
596
|
}
|
|
262
597
|
}
|
|
263
598
|
|
|
264
599
|
// Typography (TEXT)
|
|
265
600
|
if (nodeType === 'TEXT') {
|
|
266
|
-
|
|
267
|
-
const
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
601
|
+
const rawChars = safeGet(node, 'characters', '');
|
|
602
|
+
const hasChars = typeof rawChars === 'string' && rawChars.length > 0;
|
|
603
|
+
pruned.characters = isRawAssetString(rawChars) ? '[raw-asset-omitted]' : rawChars;
|
|
604
|
+
|
|
605
|
+
// Fallback fills when TextNode has mixed character colors (figma.mixed)
|
|
606
|
+
if (!pruned.fills) {
|
|
607
|
+
const rangeFills = simplifyFills(safeGetTextProp(node, 'fills', 'getRangeFills', hasChars));
|
|
608
|
+
if (rangeFills) pruned.fills = rangeFills;
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
const font = safeGetTextProp(node, 'fontName', 'getRangeFontName', hasChars);
|
|
612
|
+
const lineHeight = safeGetTextProp(node, 'lineHeight', 'getRangeLineHeight', hasChars);
|
|
613
|
+
const letterSpacing = safeGetTextProp(node, 'letterSpacing', 'getRangeLetterSpacing', hasChars);
|
|
614
|
+
const fontSize = safeGetTextProp(node, 'fontSize', 'getRangeFontSize', hasChars);
|
|
615
|
+
const rawWeight = safeGetTextProp(node, 'fontWeight', 'getRangeFontWeight', hasChars);
|
|
616
|
+
|
|
617
|
+
const typo = {};
|
|
618
|
+
const family = font && typeof font === 'object' ? safeGet(font, 'family') : undefined;
|
|
619
|
+
const fontStyle = font && typeof font === 'object' ? safeGet(font, 'style') : undefined;
|
|
620
|
+
if (family) typo.fontFamily = family;
|
|
621
|
+
if (fontStyle) typo.fontStyle = fontStyle;
|
|
622
|
+
|
|
623
|
+
const resolvedWeight = resolveFontWeight(rawWeight, fontStyle);
|
|
624
|
+
if (resolvedWeight) typo.fontWeight = resolvedWeight;
|
|
625
|
+
|
|
626
|
+
if (typeof fontSize === 'number') {
|
|
627
|
+
typo.fontSize = Math.round(fontSize * 100) / 100;
|
|
628
|
+
}
|
|
629
|
+
if (lineHeight && typeof lineHeight === 'object') {
|
|
630
|
+
const lhUnit = safeGet(lineHeight, 'unit');
|
|
631
|
+
const lhRawVal = safeGet(lineHeight, 'value', 0);
|
|
632
|
+
if (lhUnit === 'PIXELS' && typeof lhRawVal === 'number') {
|
|
633
|
+
const lhVal = Math.round(lhRawVal);
|
|
634
|
+
if (lhVal > 0) typo.lineHeightPx = lhVal;
|
|
635
|
+
} else if (lhUnit === 'PERCENT' && typeof lhRawVal === 'number' && typeof fontSize === 'number' && fontSize > 0) {
|
|
636
|
+
const lhVal = Math.round((fontSize * lhRawVal) / 100);
|
|
637
|
+
if (lhVal > 0) typo.lineHeightPx = lhVal;
|
|
638
|
+
}
|
|
639
|
+
}
|
|
640
|
+
if (letterSpacing && typeof letterSpacing === 'object') {
|
|
641
|
+
const lsUnit = safeGet(letterSpacing, 'unit');
|
|
642
|
+
const lsRawVal = safeGet(letterSpacing, 'value');
|
|
643
|
+
if (typeof lsRawVal === 'number' && Math.abs(lsRawVal) > 0.01) {
|
|
644
|
+
const lsPx =
|
|
645
|
+
lsUnit === 'PERCENT' && typeof fontSize === 'number' && fontSize > 0
|
|
646
|
+
? (fontSize * lsRawVal) / 100
|
|
647
|
+
: lsRawVal;
|
|
648
|
+
if (Math.abs(lsPx) > 0.01) {
|
|
649
|
+
typo.letterSpacing = Number(lsPx.toFixed(2));
|
|
650
|
+
}
|
|
651
|
+
}
|
|
652
|
+
}
|
|
653
|
+
const hAlign = safeGet(node, 'textAlignHorizontal');
|
|
654
|
+
if (hAlign && hAlign !== 'LEFT') typo.textAlignHorizontal = hAlign;
|
|
655
|
+
const vAlign = safeGet(node, 'textAlignVertical');
|
|
656
|
+
if (vAlign && vAlign !== 'TOP') typo.textAlignVertical = vAlign;
|
|
657
|
+
|
|
658
|
+
if (Object.keys(typo).length > 0) {
|
|
659
|
+
pruned.typography = typo;
|
|
660
|
+
}
|
|
285
661
|
}
|
|
286
662
|
|
|
287
663
|
// Recursively process children
|
|
288
|
-
const
|
|
289
|
-
if (
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
.map((child) => serializeNode(child, depth + 1))
|
|
299
|
-
.filter(Boolean);
|
|
664
|
+
const visibleKids = getVisibleChildren(node);
|
|
665
|
+
if (visibleKids.length > 0) {
|
|
666
|
+
const serializedKids = [];
|
|
667
|
+
for (const child of visibleKids) {
|
|
668
|
+
const serializedChild = await serializeNode(child, depth + 1);
|
|
669
|
+
if (serializedChild) serializedKids.push(serializedChild);
|
|
670
|
+
}
|
|
671
|
+
if (serializedKids.length > 0) {
|
|
672
|
+
pruned.children = serializedKids;
|
|
673
|
+
}
|
|
300
674
|
}
|
|
301
675
|
|
|
302
676
|
return pruned;
|
|
303
677
|
} catch (err) {
|
|
304
678
|
console.warn('Fallback serialization for node:', err);
|
|
305
679
|
return {
|
|
306
|
-
name: safeGet(node, 'name', 'Node'),
|
|
680
|
+
name: cleanLayerName(safeGet(node, 'name'), 'Node'),
|
|
307
681
|
type: safeGet(node, 'type', 'FRAME'),
|
|
308
682
|
};
|
|
309
683
|
}
|
|
@@ -342,7 +716,7 @@ async function exportCurrentSelection() {
|
|
|
342
716
|
// 1. Serialize AST Node safely
|
|
343
717
|
let ast = null;
|
|
344
718
|
try {
|
|
345
|
-
ast = serializeNode(node);
|
|
719
|
+
ast = await serializeNode(node);
|
|
346
720
|
} catch (err) {
|
|
347
721
|
console.error('Failed to serialize AST:', err);
|
|
348
722
|
ast = { name: safeGet(node, 'name', 'spec'), type: safeGet(node, 'type', 'FRAME') };
|
|
@@ -402,6 +776,6 @@ figma.ui.onmessage = async (msg) => {
|
|
|
402
776
|
if (msg.type === 'REFRESH') {
|
|
403
777
|
exportCurrentSelection();
|
|
404
778
|
} else if (msg.type === 'NOTIFY') {
|
|
405
|
-
figma.notify(msg.message);
|
|
779
|
+
figma.notify(msg.message, msg.error ? { error: true } : undefined);
|
|
406
780
|
}
|
|
407
781
|
};
|