@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.
@@ -0,0 +1,407 @@
1
+ // Figma Node JSON Extractor - Figma Plugin Backend (code.js)
2
+ // Exports compact, design-system-aware Node JSON and companion PNG preview for developer handoff.
3
+
4
+ figma.showUI(__html__, { width: 440, height: 520, themeColors: true });
5
+
6
+ function sanitizeFileName(name) {
7
+ if (!name || typeof name !== 'string') return 'spec';
8
+ return name
9
+ .toLowerCase()
10
+ .replace(/[^\w\d-_]+/g, '_')
11
+ .replace(/^_+|_+$/g, '') || 'spec';
12
+ }
13
+
14
+ function clamp(val, min, max) {
15
+ return Math.min(Math.max(val, min), max);
16
+ }
17
+
18
+ function formatRgba(color, opacity = 1) {
19
+ if (!color || typeof color !== 'object') return undefined;
20
+ if (typeof color.r !== 'number' || typeof color.g !== 'number' || typeof color.b !== 'number') return undefined;
21
+ const r = clamp(Math.round(color.r * 255), 0, 255);
22
+ const g = clamp(Math.round(color.g * 255), 0, 255);
23
+ const b = clamp(Math.round(color.b * 255), 0, 255);
24
+ const alpha = typeof opacity === 'number' ? opacity : 1;
25
+ const a = clamp(Number(alpha.toFixed(3)), 0, 1);
26
+
27
+ if (a >= 0.999) {
28
+ const toHex = (c) => c.toString(16).padStart(2, '0').toUpperCase();
29
+ return `#${toHex(r)}${toHex(g)}${toHex(b)}`;
30
+ }
31
+ return `rgba(${r}, ${g}, ${b}, ${a})`;
32
+ }
33
+
34
+ function normalizePropKey(rawKey) {
35
+ if (!rawKey || typeof rawKey !== 'string') return '';
36
+ let clean = rawKey.replace(/#.*$/, '').trim();
37
+ return clean
38
+ .replace(/[^\p{L}\p{N}]+(\p{L})/gu, (_, chr) => chr.toUpperCase())
39
+ .replace(/[^\p{L}\p{N}]+/gu, '')
40
+ .replace(/^\p{Lu}/u, (chr) => chr.toLowerCase());
41
+ }
42
+
43
+ function normalizeComponentProps(componentProperties) {
44
+ if (!componentProperties || typeof componentProperties !== 'object') return undefined;
45
+ const props = {};
46
+ for (const [key, propDef] of Object.entries(componentProperties)) {
47
+ const cleanKey = normalizePropKey(key);
48
+ if (!cleanKey) continue;
49
+ if (propDef && typeof propDef === 'object' && 'value' in propDef) {
50
+ if (typeof propDef.value === 'string' || typeof propDef.value === 'boolean' || typeof propDef.value === 'number') {
51
+ props[cleanKey] = propDef.value;
52
+ }
53
+ } else if (typeof propDef === 'string' || typeof propDef === 'boolean' || typeof propDef === 'number') {
54
+ props[cleanKey] = propDef;
55
+ }
56
+ }
57
+ return Object.keys(props).length > 0 ? props : undefined;
58
+ }
59
+
60
+ function simplifyFills(fills) {
61
+ if (!Array.isArray(fills) || fills.length === 0) return undefined;
62
+ const result = [];
63
+ for (const fill of fills) {
64
+ if (!fill || typeof fill !== 'object' || fill.visible === false) continue;
65
+ if (fill.type === 'SOLID' && fill.color) {
66
+ const color = formatRgba(fill.color, fill.opacity);
67
+ if (color) {
68
+ result.push({
69
+ type: 'SOLID',
70
+ color: color,
71
+ });
72
+ }
73
+ } else if (fill.type === 'IMAGE') {
74
+ result.push({ type: 'IMAGE', scaleMode: fill.scaleMode });
75
+ } else if (fill.type && typeof fill.type === 'string' && fill.type.startsWith('GRADIENT_')) {
76
+ result.push({ type: fill.type });
77
+ }
78
+ }
79
+ return result.length > 0 ? result : undefined;
80
+ }
81
+
82
+ function simplifyStrokes(strokes, strokeWeight, strokeAlign) {
83
+ if (!Array.isArray(strokes) || strokes.length === 0) return undefined;
84
+ const result = [];
85
+ for (const stroke of strokes) {
86
+ if (!stroke || typeof stroke !== 'object' || stroke.visible === false) continue;
87
+ if (stroke.type === 'SOLID' && stroke.color) {
88
+ const color = formatRgba(stroke.color, stroke.opacity);
89
+ if (color) {
90
+ result.push({
91
+ type: 'SOLID',
92
+ color: color,
93
+ weight: typeof strokeWeight === 'number' ? strokeWeight : undefined,
94
+ align: typeof strokeAlign === 'string' ? strokeAlign : undefined,
95
+ });
96
+ }
97
+ }
98
+ }
99
+ return result.length > 0 ? result : undefined;
100
+ }
101
+
102
+ // Safely access properties on any Figma node, avoiding exceptions on Groups, Mixed symbols, etc.
103
+ function safeGet(obj, prop, fallback = undefined) {
104
+ if (!obj || typeof obj !== 'object') return fallback;
105
+ try {
106
+ const val = obj[prop];
107
+ if (typeof val === 'symbol') return fallback; // Filter figma.mixed symbols
108
+ return val !== undefined ? val : fallback;
109
+ } catch (_) {
110
+ return fallback;
111
+ }
112
+ }
113
+
114
+ function serializeNode(node, depth = 0) {
115
+ if (!node) return null;
116
+ if (depth > 25) {
117
+ return { name: safeGet(node, 'name', 'Node'), type: safeGet(node, 'type', 'FRAME') };
118
+ }
119
+
120
+ try {
121
+ const nodeType = safeGet(node, 'type', 'FRAME');
122
+ const pruned = {
123
+ name: safeGet(node, 'name', 'Node'),
124
+ type: nodeType,
125
+ };
126
+
127
+ if (safeGet(node, 'visible') === false) pruned.visible = false;
128
+
129
+ // Dimensions & Coordinates
130
+ const width = safeGet(node, 'width');
131
+ const height = safeGet(node, 'height');
132
+ if (typeof width === 'number' && typeof height === 'number') {
133
+ pruned.bounds = {
134
+ width: Math.round(width),
135
+ height: Math.round(height),
136
+ };
137
+ }
138
+
139
+ // Component Instances & Variants
140
+ if (nodeType === 'INSTANCE') {
141
+ try {
142
+ const main = safeGet(node, 'mainComponent');
143
+ if (main) {
144
+ let setName = null;
145
+ try {
146
+ const parent = safeGet(main, 'parent');
147
+ if (parent && safeGet(parent, 'type') === 'COMPONENT_SET') {
148
+ setName = safeGet(parent, 'name');
149
+ }
150
+ } catch (_) {}
151
+
152
+ pruned.component = {
153
+ name: setName || safeGet(main, 'name', pruned.name),
154
+ variant: safeGet(main, 'name', pruned.name),
155
+ };
156
+ }
157
+ } catch (_) {
158
+ pruned.component = {
159
+ name: pruned.name,
160
+ variant: pruned.name,
161
+ };
162
+ }
163
+ try {
164
+ const props = normalizeComponentProps(safeGet(node, 'componentProperties'));
165
+ if (props) pruned.props = props;
166
+ } catch (_) {}
167
+ } else if (nodeType === 'COMPONENT' || nodeType === 'COMPONENT_SET') {
168
+ try {
169
+ const props = normalizeComponentProps(safeGet(node, 'componentProperties'));
170
+ if (props) pruned.props = props;
171
+ } catch (_) {}
172
+ }
173
+
174
+ // Auto-Layout (Flexbox)
175
+ const layoutMode = safeGet(node, 'layoutMode');
176
+ if (layoutMode && layoutMode !== 'NONE') {
177
+ pruned.layoutMode = layoutMode;
178
+ const primaryAlign = safeGet(node, 'primaryAxisAlignItems');
179
+ const counterAlign = safeGet(node, 'counterAxisAlignItems');
180
+ if (primaryAlign) pruned.primaryAxisAlignItems = primaryAlign;
181
+ if (counterAlign) pruned.counterAxisAlignItems = counterAlign;
182
+
183
+ const itemSpacing = safeGet(node, 'itemSpacing');
184
+ if (typeof itemSpacing === 'number' && itemSpacing !== 0) {
185
+ pruned.itemSpacing = itemSpacing;
186
+ }
187
+
188
+ const pTop = safeGet(node, 'paddingTop');
189
+ const pRight = safeGet(node, 'paddingRight');
190
+ const pBottom = safeGet(node, 'paddingBottom');
191
+ const pLeft = safeGet(node, 'paddingLeft');
192
+ if (pTop !== undefined || pRight !== undefined || pBottom !== undefined || pLeft !== undefined) {
193
+ pruned.padding = {
194
+ top: typeof pTop === 'number' ? pTop : 0,
195
+ right: typeof pRight === 'number' ? pRight : 0,
196
+ bottom: typeof pBottom === 'number' ? pBottom : 0,
197
+ left: typeof pLeft === 'number' ? pLeft : 0,
198
+ };
199
+ }
200
+
201
+ const layoutWrap = safeGet(node, 'layoutWrap');
202
+ if (layoutWrap) pruned.layoutWrap = layoutWrap;
203
+ }
204
+
205
+ const layoutGrow = safeGet(node, 'layoutGrow');
206
+ if (typeof layoutGrow === 'number' && layoutGrow !== 0) {
207
+ pruned.layoutGrow = layoutGrow;
208
+ }
209
+
210
+ const layoutAlign = safeGet(node, 'layoutAlign');
211
+ if (layoutAlign && layoutAlign !== 'INHERIT') {
212
+ pruned.layoutAlign = layoutAlign;
213
+ }
214
+
215
+ // Styling: Fills & Strokes
216
+ const fills = simplifyFills(safeGet(node, 'fills'));
217
+ if (fills) pruned.fills = fills;
218
+
219
+ const strokes = simplifyStrokes(
220
+ safeGet(node, 'strokes'),
221
+ safeGet(node, 'strokeWeight'),
222
+ safeGet(node, 'strokeAlign')
223
+ );
224
+ if (strokes) pruned.strokes = strokes;
225
+
226
+ // Corner Radius
227
+ const cornerRadius = safeGet(node, 'cornerRadius');
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
+ }
244
+
245
+ const opacity = safeGet(node, 'opacity');
246
+ if (typeof opacity === 'number' && opacity !== 1) {
247
+ pruned.opacity = Number(opacity.toFixed(2));
248
+ }
249
+
250
+ // Effects (Drop Shadows, Blurs)
251
+ const effects = safeGet(node, 'effects');
252
+ if (Array.isArray(effects) && effects.length > 0) {
253
+ const visibleEffects = effects.filter((e) => e && safeGet(e, 'visible', true) !== false);
254
+ if (visibleEffects.length > 0) {
255
+ pruned.effects = visibleEffects.map((e) => ({
256
+ type: safeGet(e, 'type'),
257
+ radius: safeGet(e, 'radius'),
258
+ color: safeGet(e, 'color') ? formatRgba(safeGet(e, 'color')) : undefined,
259
+ offset: safeGet(e, 'offset'),
260
+ }));
261
+ }
262
+ }
263
+
264
+ // Typography (TEXT)
265
+ if (nodeType === 'TEXT') {
266
+ pruned.characters = safeGet(node, 'characters', '');
267
+ const font = safeGet(node, 'fontName');
268
+ const lineHeight = safeGet(node, 'lineHeight');
269
+ const letterSpacing = safeGet(node, 'letterSpacing');
270
+ const fontSize = safeGet(node, 'fontSize');
271
+
272
+ pruned.typography = {
273
+ fontFamily: font && typeof font === 'object' ? safeGet(font, 'family') : undefined,
274
+ fontStyle: font && typeof font === 'object' ? safeGet(font, 'style') : undefined,
275
+ fontSize: typeof fontSize === 'number' ? fontSize : undefined,
276
+ lineHeightPx:
277
+ lineHeight && typeof lineHeight === 'object' && safeGet(lineHeight, 'unit') === 'PIXELS'
278
+ ? Math.round(safeGet(lineHeight, 'value', 0))
279
+ : undefined,
280
+ letterSpacing:
281
+ letterSpacing && typeof letterSpacing === 'object' ? safeGet(letterSpacing, 'value') : undefined,
282
+ textAlignHorizontal: safeGet(node, 'textAlignHorizontal'),
283
+ textAlignVertical: safeGet(node, 'textAlignVertical'),
284
+ };
285
+ }
286
+
287
+ // Recursively process children
288
+ const children = safeGet(node, 'children');
289
+ if (Array.isArray(children) && children.length > 0) {
290
+ pruned.children = children
291
+ .filter((c) => {
292
+ try {
293
+ return c && safeGet(c, 'visible', true) !== false;
294
+ } catch (_) {
295
+ return true;
296
+ }
297
+ })
298
+ .map((child) => serializeNode(child, depth + 1))
299
+ .filter(Boolean);
300
+ }
301
+
302
+ return pruned;
303
+ } catch (err) {
304
+ console.warn('Fallback serialization for node:', err);
305
+ return {
306
+ name: safeGet(node, 'name', 'Node'),
307
+ type: safeGet(node, 'type', 'FRAME'),
308
+ };
309
+ }
310
+ }
311
+
312
+ function uint8ArrayToBase64(bytes) {
313
+ if (!bytes || bytes.length === 0) return null;
314
+ const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/';
315
+ let base64 = '';
316
+ const len = bytes.length;
317
+ for (let i = 0; i < len; i += 3) {
318
+ const b0 = bytes[i];
319
+ const b1 = i + 1 < len ? bytes[i + 1] : 0;
320
+ const b2 = i + 2 < len ? bytes[i + 2] : 0;
321
+ base64 += chars[b0 >> 2];
322
+ base64 += chars[((b0 & 3) << 4) | (b1 >> 4)];
323
+ base64 += i + 1 < len ? chars[((b1 & 15) << 2) | (b2 >> 6)] : '=';
324
+ base64 += i + 2 < len ? chars[b2 & 63] : '=';
325
+ }
326
+ return base64;
327
+ }
328
+
329
+ let currentExportId = 0;
330
+
331
+ async function exportCurrentSelection() {
332
+ const exportId = ++currentExportId;
333
+ const selection = figma.currentPage.selection;
334
+ if (!selection || selection.length === 0) {
335
+ figma.ui.postMessage({ type: 'NO_SELECTION' });
336
+ return;
337
+ }
338
+
339
+ const node = selection[0];
340
+ const safeName = sanitizeFileName(safeGet(node, 'name', 'spec'));
341
+
342
+ // 1. Serialize AST Node safely
343
+ let ast = null;
344
+ try {
345
+ ast = serializeNode(node);
346
+ } catch (err) {
347
+ console.error('Failed to serialize AST:', err);
348
+ ast = { name: safeGet(node, 'name', 'spec'), type: safeGet(node, 'type', 'FRAME') };
349
+ }
350
+
351
+ // Abort if selection changed while serializing
352
+ if (exportId !== currentExportId) return;
353
+
354
+ // 2. Export Rendered PNG Preview (Scale 2x)
355
+ let imageBytes = null;
356
+ let imageBase64 = null;
357
+ try {
358
+ imageBytes = await node.exportAsync({
359
+ format: 'PNG',
360
+ constraint: { type: 'SCALE', value: 2 },
361
+ });
362
+ imageBase64 = uint8ArrayToBase64(imageBytes);
363
+ } catch (err) {
364
+ console.warn('Preview render skipped:', err);
365
+ }
366
+
367
+ // Abort if selection changed while rendering PNG
368
+ if (exportId !== currentExportId) return;
369
+
370
+ const nodeWidth = safeGet(node, 'width', 0);
371
+ const nodeHeight = safeGet(node, 'height', 0);
372
+
373
+ figma.ui.postMessage({
374
+ type: 'SPEC_EXPORTED',
375
+ name: safeName,
376
+ originalName: safeGet(node, 'name', 'spec'),
377
+ bounds: {
378
+ width: typeof nodeWidth === 'number' ? Math.round(nodeWidth) : 0,
379
+ height: typeof nodeHeight === 'number' ? Math.round(nodeHeight) : 0,
380
+ },
381
+ nodeType: safeGet(node, 'type', 'FRAME'),
382
+ spec: ast,
383
+ imageBytes: imageBytes,
384
+ imageBase64: imageBase64,
385
+ });
386
+ }
387
+
388
+ // Initial export on launch
389
+ exportCurrentSelection();
390
+
391
+ // Update live when user changes selection in Figma
392
+ figma.on('selectionchange', () => {
393
+ try {
394
+ exportCurrentSelection();
395
+ } catch (err) {
396
+ console.error('selectionchange handler error:', err);
397
+ }
398
+ });
399
+
400
+ // Handle UI button requests
401
+ figma.ui.onmessage = async (msg) => {
402
+ if (msg.type === 'REFRESH') {
403
+ exportCurrentSelection();
404
+ } else if (msg.type === 'NOTIFY') {
405
+ figma.notify(msg.message);
406
+ }
407
+ };
@@ -0,0 +1,10 @@
1
+ {
2
+ "name": "Figma Node JSON Extractor",
3
+ "id": "figma-node-json-extractor",
4
+ "api": "1.0.0",
5
+ "main": "code.js",
6
+ "ui": "ui.html",
7
+ "capabilities": [],
8
+ "enableProposedApi": false,
9
+ "editorType": ["figma", "dev"]
10
+ }