figma-metadata-extractor 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/dist/index.cjs ADDED
@@ -0,0 +1,1462 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
3
+ const path = require("path");
4
+ const fs = require("fs");
5
+ const sharp = require("sharp");
6
+ const child_process = require("child_process");
7
+ const util = require("util");
8
+ const remeda = require("remeda");
9
+ const yaml = require("js-yaml");
10
+ async function applyCropTransform(imagePath, cropTransform) {
11
+ const { Logger: Logger2 } = await Promise.resolve().then(() => logger);
12
+ try {
13
+ const scaleX = cropTransform[0]?.[0] ?? 1;
14
+ const skewX = cropTransform[0]?.[1] ?? 0;
15
+ const translateX = cropTransform[0]?.[2] ?? 0;
16
+ const skewY = cropTransform[1]?.[0] ?? 0;
17
+ const scaleY = cropTransform[1]?.[1] ?? 1;
18
+ const translateY = cropTransform[1]?.[2] ?? 0;
19
+ const image = sharp(imagePath);
20
+ const metadata = await image.metadata();
21
+ if (!metadata.width || !metadata.height) {
22
+ throw new Error(`Could not get image dimensions for ${imagePath}`);
23
+ }
24
+ const { width, height } = metadata;
25
+ const cropLeft = Math.max(0, Math.round(translateX * width));
26
+ const cropTop = Math.max(0, Math.round(translateY * height));
27
+ const cropWidth = Math.min(width - cropLeft, Math.round(scaleX * width));
28
+ const cropHeight = Math.min(height - cropTop, Math.round(scaleY * height));
29
+ if (cropWidth <= 0 || cropHeight <= 0) {
30
+ Logger2.log(`Invalid crop dimensions for ${imagePath}, using original image`);
31
+ return imagePath;
32
+ }
33
+ const tempPath = imagePath + ".tmp";
34
+ await image.extract({
35
+ left: cropLeft,
36
+ top: cropTop,
37
+ width: cropWidth,
38
+ height: cropHeight
39
+ }).toFile(tempPath);
40
+ fs.renameSync(tempPath, imagePath);
41
+ Logger2.log(`Cropped image saved (overwritten): ${imagePath}`);
42
+ Logger2.log(
43
+ `Crop region: ${cropLeft}, ${cropTop}, ${cropWidth}x${cropHeight} from ${width}x${height}`
44
+ );
45
+ return imagePath;
46
+ } catch (error) {
47
+ Logger2.error(`Error cropping image ${imagePath}:`, error);
48
+ return imagePath;
49
+ }
50
+ }
51
+ async function getImageDimensions(imagePath) {
52
+ const { Logger: Logger2 } = await Promise.resolve().then(() => logger);
53
+ try {
54
+ const metadata = await sharp(imagePath).metadata();
55
+ if (!metadata.width || !metadata.height) {
56
+ throw new Error(`Could not get image dimensions for ${imagePath}`);
57
+ }
58
+ return {
59
+ width: metadata.width,
60
+ height: metadata.height
61
+ };
62
+ } catch (error) {
63
+ Logger2.error(`Error getting image dimensions for ${imagePath}:`, error);
64
+ return { width: 1e3, height: 1e3 };
65
+ }
66
+ }
67
+ async function downloadAndProcessImage(fileName, localPath, imageUrl, needsCropping = false, cropTransform, requiresImageDimensions = false) {
68
+ const { Logger: Logger2 } = await Promise.resolve().then(() => logger);
69
+ const processingLog = [];
70
+ const { downloadFigmaImage: downloadFigmaImage2 } = await Promise.resolve().then(() => common);
71
+ const originalPath = await downloadFigmaImage2(fileName, localPath, imageUrl);
72
+ Logger2.log(`Downloaded original image: ${originalPath}`);
73
+ const originalDimensions = await getImageDimensions(originalPath);
74
+ Logger2.log(`Original dimensions: ${originalDimensions.width}x${originalDimensions.height}`);
75
+ let finalPath = originalPath;
76
+ let wasCropped = false;
77
+ let cropRegion;
78
+ if (needsCropping && cropTransform) {
79
+ Logger2.log("Applying crop transform...");
80
+ const scaleX = cropTransform[0]?.[0] ?? 1;
81
+ const scaleY = cropTransform[1]?.[1] ?? 1;
82
+ const translateX = cropTransform[0]?.[2] ?? 0;
83
+ const translateY = cropTransform[1]?.[2] ?? 0;
84
+ const cropLeft = Math.max(0, Math.round(translateX * originalDimensions.width));
85
+ const cropTop = Math.max(0, Math.round(translateY * originalDimensions.height));
86
+ const cropWidth = Math.min(
87
+ originalDimensions.width - cropLeft,
88
+ Math.round(scaleX * originalDimensions.width)
89
+ );
90
+ const cropHeight = Math.min(
91
+ originalDimensions.height - cropTop,
92
+ Math.round(scaleY * originalDimensions.height)
93
+ );
94
+ if (cropWidth > 0 && cropHeight > 0) {
95
+ cropRegion = { left: cropLeft, top: cropTop, width: cropWidth, height: cropHeight };
96
+ finalPath = await applyCropTransform(originalPath, cropTransform);
97
+ wasCropped = true;
98
+ Logger2.log(`Cropped to region: ${cropLeft}, ${cropTop}, ${cropWidth}x${cropHeight}`);
99
+ } else {
100
+ Logger2.log("Invalid crop dimensions, keeping original image");
101
+ }
102
+ }
103
+ const finalDimensions = await getImageDimensions(finalPath);
104
+ Logger2.log(`Final dimensions: ${finalDimensions.width}x${finalDimensions.height}`);
105
+ let cssVariables;
106
+ if (requiresImageDimensions) {
107
+ cssVariables = generateImageCSSVariables(finalDimensions);
108
+ }
109
+ return {
110
+ filePath: finalPath,
111
+ originalDimensions,
112
+ finalDimensions,
113
+ wasCropped,
114
+ cropRegion,
115
+ cssVariables,
116
+ processingLog
117
+ };
118
+ }
119
+ function generateImageCSSVariables({
120
+ width,
121
+ height
122
+ }) {
123
+ return `--original-width: ${width}px; --original-height: ${height}px;`;
124
+ }
125
+ const Logger = {
126
+ isHTTP: false,
127
+ log: (...args) => {
128
+ if (Logger.isHTTP) {
129
+ console.log("[INFO]", ...args);
130
+ } else {
131
+ console.error("[INFO]", ...args);
132
+ }
133
+ },
134
+ error: (...args) => {
135
+ console.error("[ERROR]", ...args);
136
+ }
137
+ };
138
+ function writeLogs(name, value) {
139
+ if (process.env.NODE_ENV !== "development") return;
140
+ try {
141
+ const logsDir = "logs";
142
+ const logPath = `${logsDir}/${name}`;
143
+ fs.accessSync(process.cwd(), fs.constants.W_OK);
144
+ if (!fs.existsSync(logsDir)) {
145
+ fs.mkdirSync(logsDir, { recursive: true });
146
+ }
147
+ fs.writeFileSync(logPath, JSON.stringify(value, null, 2));
148
+ Logger.log(`Debug log written to: ${logPath}`);
149
+ } catch (error) {
150
+ const errorMessage = error instanceof Error ? error.message : String(error);
151
+ Logger.log(`Failed to write logs to ${name}: ${errorMessage}`);
152
+ }
153
+ }
154
+ const logger = /* @__PURE__ */ Object.freeze(/* @__PURE__ */ Object.defineProperty({
155
+ __proto__: null,
156
+ Logger,
157
+ writeLogs
158
+ }, Symbol.toStringTag, { value: "Module" }));
159
+ const execFileAsync = util.promisify(child_process.execFile);
160
+ async function fetchWithRetry(url, options = {}) {
161
+ try {
162
+ const response = await fetch(url, options);
163
+ if (!response.ok) {
164
+ throw new Error(`Fetch failed with status ${response.status}: ${response.statusText}`);
165
+ }
166
+ return await response.json();
167
+ } catch (fetchError) {
168
+ Logger.log(
169
+ `[fetchWithRetry] Initial fetch failed for ${url}: ${fetchError.message}. Likely a corporate proxy or SSL issue. Attempting curl fallback.`
170
+ );
171
+ const curlHeaders = formatHeadersForCurl(options.headers);
172
+ const curlArgs = ["-s", "-S", "--fail-with-body", "-L", ...curlHeaders, url];
173
+ try {
174
+ Logger.log(`[fetchWithRetry] Executing curl with args: ${JSON.stringify(curlArgs)}`);
175
+ const { stdout, stderr } = await execFileAsync("curl", curlArgs);
176
+ if (stderr) {
177
+ if (!stdout || stderr.toLowerCase().includes("error") || stderr.toLowerCase().includes("fail")) {
178
+ throw new Error(`Curl command failed with stderr: ${stderr}`);
179
+ }
180
+ Logger.log(
181
+ `[fetchWithRetry] Curl command for ${url} produced stderr (but might be informational): ${stderr}`
182
+ );
183
+ }
184
+ if (!stdout) {
185
+ throw new Error("Curl command returned empty stdout.");
186
+ }
187
+ const result = JSON.parse(stdout);
188
+ if (result.status && result.status !== 200) {
189
+ throw new Error(`Curl command failed: ${result}`);
190
+ }
191
+ return result;
192
+ } catch (curlError) {
193
+ Logger.error(`[fetchWithRetry] Curl fallback also failed for ${url}: ${curlError.message}`);
194
+ throw fetchError;
195
+ }
196
+ }
197
+ }
198
+ function formatHeadersForCurl(headers) {
199
+ if (!headers) {
200
+ return [];
201
+ }
202
+ const headerArgs = [];
203
+ for (const [key, value] of Object.entries(headers)) {
204
+ headerArgs.push("-H", `${key}: ${value}`);
205
+ }
206
+ return headerArgs;
207
+ }
208
+ class FigmaService {
209
+ apiKey;
210
+ oauthToken;
211
+ useOAuth;
212
+ baseUrl = "https://api.figma.com/v1";
213
+ constructor({ figmaApiKey, figmaOAuthToken, useOAuth }) {
214
+ this.apiKey = figmaApiKey || "";
215
+ this.oauthToken = figmaOAuthToken || "";
216
+ this.useOAuth = !!useOAuth && !!this.oauthToken;
217
+ }
218
+ getAuthHeaders() {
219
+ if (this.useOAuth) {
220
+ Logger.log("Using OAuth Bearer token for authentication");
221
+ return { Authorization: `Bearer ${this.oauthToken}` };
222
+ } else {
223
+ Logger.log("Using Personal Access Token for authentication");
224
+ return { "X-Figma-Token": this.apiKey };
225
+ }
226
+ }
227
+ /**
228
+ * Filters out null values from Figma image responses. This ensures we only work with valid image URLs.
229
+ */
230
+ filterValidImages(images) {
231
+ if (!images) return {};
232
+ return Object.fromEntries(Object.entries(images).filter(([, value]) => !!value));
233
+ }
234
+ async request(endpoint) {
235
+ try {
236
+ Logger.log(`Calling ${this.baseUrl}${endpoint}`);
237
+ const headers = this.getAuthHeaders();
238
+ return await fetchWithRetry(`${this.baseUrl}${endpoint}`, {
239
+ headers
240
+ });
241
+ } catch (error) {
242
+ const errorMessage = error instanceof Error ? error.message : String(error);
243
+ throw new Error(
244
+ `Failed to make request to Figma API endpoint '${endpoint}': ${errorMessage}`
245
+ );
246
+ }
247
+ }
248
+ /**
249
+ * Builds URL query parameters for SVG image requests.
250
+ */
251
+ buildSvgQueryParams(svgIds, svgOptions) {
252
+ const params = new URLSearchParams({
253
+ ids: svgIds.join(","),
254
+ format: "svg",
255
+ svg_outline_text: String(svgOptions.outlineText),
256
+ svg_include_id: String(svgOptions.includeId),
257
+ svg_simplify_stroke: String(svgOptions.simplifyStroke)
258
+ });
259
+ return params.toString();
260
+ }
261
+ /**
262
+ * Gets download URLs for image fills without downloading them.
263
+ *
264
+ * @returns Map of imageRef to download URL
265
+ */
266
+ async getImageFillUrls(fileKey) {
267
+ const endpoint = `/files/${fileKey}/images`;
268
+ const response = await this.request(endpoint);
269
+ return response.meta.images || {};
270
+ }
271
+ /**
272
+ * Gets download URLs for rendered nodes without downloading them.
273
+ *
274
+ * @returns Map of node ID to download URL
275
+ */
276
+ async getNodeRenderUrls(fileKey, nodeIds, format, options = {}) {
277
+ if (nodeIds.length === 0) return {};
278
+ if (format === "png") {
279
+ const scale = options.pngScale || 2;
280
+ const endpoint = `/images/${fileKey}?ids=${nodeIds.join(",")}&format=png&scale=${scale}`;
281
+ const response = await this.request(endpoint);
282
+ return this.filterValidImages(response.images);
283
+ } else {
284
+ const svgOptions = options.svgOptions || {
285
+ outlineText: true,
286
+ includeId: false,
287
+ simplifyStroke: true
288
+ };
289
+ const params = this.buildSvgQueryParams(nodeIds, svgOptions);
290
+ const endpoint = `/images/${fileKey}?${params}`;
291
+ const response = await this.request(endpoint);
292
+ return this.filterValidImages(response.images);
293
+ }
294
+ }
295
+ /**
296
+ * Download images method with post-processing support for cropping and returning image dimensions.
297
+ *
298
+ * Supports:
299
+ * - Image fills vs rendered nodes (based on imageRef vs nodeId)
300
+ * - PNG vs SVG format (based on filename extension)
301
+ * - Image cropping based on transform matrices
302
+ * - CSS variable generation for image dimensions
303
+ *
304
+ * @returns Array of local file paths for successfully downloaded images
305
+ */
306
+ async downloadImages(fileKey, localPath, items, options = {}) {
307
+ if (items.length === 0) return [];
308
+ const sanitizedPath = path.normalize(localPath).replace(/^(\.\.(\/|\\|$))+/, "");
309
+ const resolvedPath = path.resolve(sanitizedPath);
310
+ if (!resolvedPath.startsWith(path.resolve(process.cwd()))) {
311
+ throw new Error("Invalid path specified. Directory traversal is not allowed.");
312
+ }
313
+ const { pngScale = 2, svgOptions } = options;
314
+ const downloadPromises = [];
315
+ const imageFills = items.filter(
316
+ (item) => !!item.imageRef
317
+ );
318
+ const renderNodes = items.filter(
319
+ (item) => !!item.nodeId
320
+ );
321
+ if (imageFills.length > 0) {
322
+ const fillUrls = await this.getImageFillUrls(fileKey);
323
+ const fillDownloads = imageFills.map(({ imageRef, fileName, needsCropping, cropTransform, requiresImageDimensions }) => {
324
+ const imageUrl = fillUrls[imageRef];
325
+ return imageUrl ? downloadAndProcessImage(
326
+ fileName,
327
+ resolvedPath,
328
+ imageUrl,
329
+ needsCropping,
330
+ cropTransform,
331
+ requiresImageDimensions
332
+ ) : null;
333
+ }).filter((promise) => promise !== null);
334
+ if (fillDownloads.length > 0) {
335
+ downloadPromises.push(Promise.all(fillDownloads));
336
+ }
337
+ }
338
+ if (renderNodes.length > 0) {
339
+ const pngNodes = renderNodes.filter((node) => !node.fileName.toLowerCase().endsWith(".svg"));
340
+ const svgNodes = renderNodes.filter((node) => node.fileName.toLowerCase().endsWith(".svg"));
341
+ if (pngNodes.length > 0) {
342
+ const pngUrls = await this.getNodeRenderUrls(
343
+ fileKey,
344
+ pngNodes.map((n) => n.nodeId),
345
+ "png",
346
+ { pngScale }
347
+ );
348
+ const pngDownloads = pngNodes.map(({ nodeId, fileName, needsCropping, cropTransform, requiresImageDimensions }) => {
349
+ const imageUrl = pngUrls[nodeId];
350
+ return imageUrl ? downloadAndProcessImage(
351
+ fileName,
352
+ resolvedPath,
353
+ imageUrl,
354
+ needsCropping,
355
+ cropTransform,
356
+ requiresImageDimensions
357
+ ) : null;
358
+ }).filter((promise) => promise !== null);
359
+ if (pngDownloads.length > 0) {
360
+ downloadPromises.push(Promise.all(pngDownloads));
361
+ }
362
+ }
363
+ if (svgNodes.length > 0) {
364
+ const svgUrls = await this.getNodeRenderUrls(
365
+ fileKey,
366
+ svgNodes.map((n) => n.nodeId),
367
+ "svg",
368
+ { svgOptions }
369
+ );
370
+ const svgDownloads = svgNodes.map(({ nodeId, fileName, needsCropping, cropTransform, requiresImageDimensions }) => {
371
+ const imageUrl = svgUrls[nodeId];
372
+ return imageUrl ? downloadAndProcessImage(
373
+ fileName,
374
+ resolvedPath,
375
+ imageUrl,
376
+ needsCropping,
377
+ cropTransform,
378
+ requiresImageDimensions
379
+ ) : null;
380
+ }).filter((promise) => promise !== null);
381
+ if (svgDownloads.length > 0) {
382
+ downloadPromises.push(Promise.all(svgDownloads));
383
+ }
384
+ }
385
+ }
386
+ const results = await Promise.all(downloadPromises);
387
+ return results.flat();
388
+ }
389
+ /**
390
+ * Get raw Figma API response for a file (for use with flexible extractors)
391
+ */
392
+ async getRawFile(fileKey, depth) {
393
+ const endpoint = `/files/${fileKey}${depth ? `?depth=${depth}` : ""}`;
394
+ Logger.log(`Retrieving raw Figma file: ${fileKey} (depth: ${depth ?? "default"})`);
395
+ const response = await this.request(endpoint);
396
+ writeLogs("figma-raw.json", response);
397
+ return response;
398
+ }
399
+ /**
400
+ * Get raw Figma API response for specific nodes (for use with flexible extractors)
401
+ */
402
+ async getRawNode(fileKey, nodeId, depth) {
403
+ const endpoint = `/files/${fileKey}/nodes?ids=${nodeId}${depth ? `&depth=${depth}` : ""}`;
404
+ Logger.log(
405
+ `Retrieving raw Figma node: ${nodeId} from ${fileKey} (depth: ${depth ?? "default"})`
406
+ );
407
+ const response = await this.request(endpoint);
408
+ writeLogs("figma-raw.json", response);
409
+ return response;
410
+ }
411
+ }
412
+ async function downloadFigmaImage(fileName, localPath, imageUrl) {
413
+ try {
414
+ if (!fs.existsSync(localPath)) {
415
+ fs.mkdirSync(localPath, { recursive: true });
416
+ }
417
+ const fullPath = path.join(localPath, fileName);
418
+ const response = await fetch(imageUrl, {
419
+ method: "GET"
420
+ });
421
+ if (!response.ok) {
422
+ throw new Error(`Failed to download image: ${response.statusText}`);
423
+ }
424
+ const writer = fs.createWriteStream(fullPath);
425
+ const reader = response.body?.getReader();
426
+ if (!reader) {
427
+ throw new Error("Failed to get response body");
428
+ }
429
+ return new Promise((resolve, reject) => {
430
+ const processStream = async () => {
431
+ try {
432
+ while (true) {
433
+ const { done, value } = await reader.read();
434
+ if (done) {
435
+ writer.end();
436
+ break;
437
+ }
438
+ writer.write(value);
439
+ }
440
+ } catch (err) {
441
+ writer.end();
442
+ fs.unlink(fullPath, () => {
443
+ });
444
+ reject(err);
445
+ }
446
+ };
447
+ writer.on("finish", () => {
448
+ resolve(fullPath);
449
+ });
450
+ writer.on("error", (err) => {
451
+ reader.cancel();
452
+ fs.unlink(fullPath, () => {
453
+ });
454
+ reject(new Error(`Failed to write image: ${err.message}`));
455
+ });
456
+ processStream();
457
+ });
458
+ } catch (error) {
459
+ const errorMessage = error instanceof Error ? error.message : String(error);
460
+ throw new Error(`Error downloading image: ${errorMessage}`);
461
+ }
462
+ }
463
+ function generateVarId(prefix = "var") {
464
+ const chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789";
465
+ let result = "";
466
+ for (let i = 0; i < 6; i++) {
467
+ const randomIndex = Math.floor(Math.random() * chars.length);
468
+ result += chars[randomIndex];
469
+ }
470
+ return `${prefix}_${result}`;
471
+ }
472
+ function generateCSSShorthand(values, {
473
+ ignoreZero = true,
474
+ suffix = "px"
475
+ } = {}) {
476
+ const { top, right, bottom, left } = values;
477
+ if (ignoreZero && top === 0 && right === 0 && bottom === 0 && left === 0) {
478
+ return void 0;
479
+ }
480
+ if (top === right && right === bottom && bottom === left) {
481
+ return `${top}${suffix}`;
482
+ }
483
+ if (right === left) {
484
+ if (top === bottom) {
485
+ return `${top}${suffix} ${right}${suffix}`;
486
+ }
487
+ return `${top}${suffix} ${right}${suffix} ${bottom}${suffix}`;
488
+ }
489
+ return `${top}${suffix} ${right}${suffix} ${bottom}${suffix} ${left}${suffix}`;
490
+ }
491
+ function isVisible(element) {
492
+ return element.visible ?? true;
493
+ }
494
+ function pixelRound(num) {
495
+ if (isNaN(num)) {
496
+ throw new TypeError(`Input must be a valid number`);
497
+ }
498
+ return Number(Number(num).toFixed(2));
499
+ }
500
+ const common = /* @__PURE__ */ Object.freeze(/* @__PURE__ */ Object.defineProperty({
501
+ __proto__: null,
502
+ downloadFigmaImage,
503
+ generateCSSShorthand,
504
+ generateVarId,
505
+ isVisible,
506
+ pixelRound
507
+ }, Symbol.toStringTag, { value: "Module" }));
508
+ function hasValue(key, obj, typeGuard) {
509
+ const isObject = typeof obj === "object" && obj !== null;
510
+ if (!isObject || !(key in obj)) return false;
511
+ const val = obj[key];
512
+ return typeGuard ? typeGuard(val) : val !== void 0;
513
+ }
514
+ function isFrame(val) {
515
+ return typeof val === "object" && !!val && "clipsContent" in val && typeof val.clipsContent === "boolean";
516
+ }
517
+ function isLayout(val) {
518
+ return typeof val === "object" && !!val && "absoluteBoundingBox" in val && typeof val.absoluteBoundingBox === "object" && !!val.absoluteBoundingBox && "x" in val.absoluteBoundingBox && "y" in val.absoluteBoundingBox && "width" in val.absoluteBoundingBox && "height" in val.absoluteBoundingBox;
519
+ }
520
+ function isInAutoLayoutFlow(node, parent) {
521
+ const autoLayoutModes = ["HORIZONTAL", "VERTICAL"];
522
+ return isFrame(parent) && autoLayoutModes.includes(parent.layoutMode ?? "NONE") && isLayout(node) && node.layoutPositioning !== "ABSOLUTE";
523
+ }
524
+ function isStrokeWeights(val) {
525
+ return typeof val === "object" && val !== null && "top" in val && "right" in val && "bottom" in val && "left" in val;
526
+ }
527
+ function isRectangle(key, obj) {
528
+ const recordObj = obj;
529
+ return typeof obj === "object" && !!obj && key in recordObj && typeof recordObj[key] === "object" && !!recordObj[key] && "x" in recordObj[key] && "y" in recordObj[key] && "width" in recordObj[key] && "height" in recordObj[key];
530
+ }
531
+ function isRectangleCornerRadii(val) {
532
+ return Array.isArray(val) && val.length === 4 && val.every((v) => typeof v === "number");
533
+ }
534
+ function extractFromDesign(nodes, extractors, options = {}, globalVars = { styles: {} }) {
535
+ const context = {
536
+ globalVars,
537
+ currentDepth: 0
538
+ };
539
+ const processedNodes = nodes.filter((node) => shouldProcessNode(node, options)).map((node) => processNodeWithExtractors(node, extractors, context, options)).filter((node) => node !== null);
540
+ return {
541
+ nodes: processedNodes,
542
+ globalVars: context.globalVars
543
+ };
544
+ }
545
+ function processNodeWithExtractors(node, extractors, context, options) {
546
+ if (!shouldProcessNode(node, options)) {
547
+ return null;
548
+ }
549
+ const result = {
550
+ id: node.id,
551
+ name: node.name,
552
+ type: node.type === "VECTOR" ? "IMAGE-SVG" : node.type
553
+ };
554
+ for (const extractor of extractors) {
555
+ extractor(node, result, context);
556
+ }
557
+ if (shouldTraverseChildren(node, context, options)) {
558
+ const childContext = {
559
+ ...context,
560
+ currentDepth: context.currentDepth + 1,
561
+ parent: node
562
+ };
563
+ if (hasValue("children", node) && node.children.length > 0) {
564
+ const children = node.children.filter((child) => shouldProcessNode(child, options)).map((child) => processNodeWithExtractors(child, extractors, childContext, options)).filter((child) => child !== null);
565
+ if (children.length > 0) {
566
+ const childrenToInclude = options.afterChildren ? options.afterChildren(node, result, children) : children;
567
+ if (childrenToInclude.length > 0) {
568
+ result.children = childrenToInclude;
569
+ }
570
+ }
571
+ }
572
+ }
573
+ return result;
574
+ }
575
+ function shouldProcessNode(node, options) {
576
+ if (!isVisible(node)) {
577
+ return false;
578
+ }
579
+ if (options.nodeFilter && !options.nodeFilter(node)) {
580
+ return false;
581
+ }
582
+ return true;
583
+ }
584
+ function shouldTraverseChildren(node, context, options) {
585
+ if (options.maxDepth !== void 0 && context.currentDepth >= options.maxDepth) {
586
+ return false;
587
+ }
588
+ return true;
589
+ }
590
+ function simplifyComponents(aggregatedComponents) {
591
+ return Object.fromEntries(
592
+ Object.entries(aggregatedComponents).map(([id, comp]) => [
593
+ id,
594
+ {
595
+ id,
596
+ key: comp.key,
597
+ name: comp.name,
598
+ componentSetId: comp.componentSetId
599
+ }
600
+ ])
601
+ );
602
+ }
603
+ function simplifyComponentSets(aggregatedComponentSets) {
604
+ return Object.fromEntries(
605
+ Object.entries(aggregatedComponentSets).map(([id, set]) => [
606
+ id,
607
+ {
608
+ id,
609
+ key: set.key,
610
+ name: set.name,
611
+ description: set.description
612
+ }
613
+ ])
614
+ );
615
+ }
616
+ function simplifyRawFigmaObject(apiResponse, nodeExtractors, options = {}) {
617
+ const { metadata, rawNodes, components, componentSets, extraStyles } = parseAPIResponse(apiResponse);
618
+ const globalVars = { styles: {}, extraStyles };
619
+ const { nodes: extractedNodes, globalVars: finalGlobalVars } = extractFromDesign(
620
+ rawNodes,
621
+ nodeExtractors,
622
+ options,
623
+ globalVars
624
+ );
625
+ return {
626
+ ...metadata,
627
+ nodes: extractedNodes,
628
+ components: simplifyComponents(components),
629
+ componentSets: simplifyComponentSets(componentSets),
630
+ globalVars: { styles: finalGlobalVars.styles }
631
+ };
632
+ }
633
+ function parseAPIResponse(data) {
634
+ const aggregatedComponents = {};
635
+ const aggregatedComponentSets = {};
636
+ let extraStyles = {};
637
+ let nodesToParse;
638
+ if ("nodes" in data) {
639
+ const nodeResponses = Object.values(data.nodes);
640
+ nodeResponses.forEach((nodeResponse) => {
641
+ if (nodeResponse.components) {
642
+ Object.assign(aggregatedComponents, nodeResponse.components);
643
+ }
644
+ if (nodeResponse.componentSets) {
645
+ Object.assign(aggregatedComponentSets, nodeResponse.componentSets);
646
+ }
647
+ if (nodeResponse.styles) {
648
+ Object.assign(extraStyles, nodeResponse.styles);
649
+ }
650
+ });
651
+ nodesToParse = nodeResponses.map((n) => n.document).filter(isVisible);
652
+ } else {
653
+ Object.assign(aggregatedComponents, data.components);
654
+ Object.assign(aggregatedComponentSets, data.componentSets);
655
+ if (data.styles) {
656
+ extraStyles = data.styles;
657
+ }
658
+ nodesToParse = data.document.children.filter(isVisible);
659
+ }
660
+ const { name } = data;
661
+ return {
662
+ metadata: {
663
+ name
664
+ },
665
+ rawNodes: nodesToParse,
666
+ extraStyles,
667
+ components: aggregatedComponents,
668
+ componentSets: aggregatedComponentSets
669
+ };
670
+ }
671
+ function buildSimplifiedLayout(n, parent) {
672
+ const frameValues = buildSimplifiedFrameValues(n);
673
+ const layoutValues = buildSimplifiedLayoutValues(n, parent, frameValues.mode) || {};
674
+ return { ...frameValues, ...layoutValues };
675
+ }
676
+ function convertAlign(axisAlign, stretch) {
677
+ if (stretch && stretch.mode !== "none") {
678
+ const { children, mode, axis } = stretch;
679
+ const direction = getDirection(axis, mode);
680
+ const shouldStretch = children.length > 0 && children.reduce((shouldStretch2, c) => {
681
+ if (!shouldStretch2) return false;
682
+ if ("layoutPositioning" in c && c.layoutPositioning === "ABSOLUTE") return true;
683
+ if (direction === "horizontal") {
684
+ return "layoutSizingHorizontal" in c && c.layoutSizingHorizontal === "FILL";
685
+ } else if (direction === "vertical") {
686
+ return "layoutSizingVertical" in c && c.layoutSizingVertical === "FILL";
687
+ }
688
+ return false;
689
+ }, true);
690
+ if (shouldStretch) return "stretch";
691
+ }
692
+ switch (axisAlign) {
693
+ case "MIN":
694
+ return void 0;
695
+ case "MAX":
696
+ return "flex-end";
697
+ case "CENTER":
698
+ return "center";
699
+ case "SPACE_BETWEEN":
700
+ return "space-between";
701
+ case "BASELINE":
702
+ return "baseline";
703
+ default:
704
+ return void 0;
705
+ }
706
+ }
707
+ function convertSelfAlign(align) {
708
+ switch (align) {
709
+ case "MIN":
710
+ return void 0;
711
+ case "MAX":
712
+ return "flex-end";
713
+ case "CENTER":
714
+ return "center";
715
+ case "STRETCH":
716
+ return "stretch";
717
+ default:
718
+ return void 0;
719
+ }
720
+ }
721
+ function convertSizing(s) {
722
+ if (s === "FIXED") return "fixed";
723
+ if (s === "FILL") return "fill";
724
+ if (s === "HUG") return "hug";
725
+ return void 0;
726
+ }
727
+ function getDirection(axis, mode) {
728
+ switch (axis) {
729
+ case "primary":
730
+ switch (mode) {
731
+ case "row":
732
+ return "horizontal";
733
+ case "column":
734
+ return "vertical";
735
+ }
736
+ case "counter":
737
+ switch (mode) {
738
+ case "row":
739
+ return "horizontal";
740
+ case "column":
741
+ return "vertical";
742
+ }
743
+ }
744
+ }
745
+ function buildSimplifiedFrameValues(n) {
746
+ if (!isFrame(n)) {
747
+ return { mode: "none" };
748
+ }
749
+ const frameValues = {
750
+ mode: !n.layoutMode || n.layoutMode === "NONE" ? "none" : n.layoutMode === "HORIZONTAL" ? "row" : "column"
751
+ };
752
+ const overflowScroll = [];
753
+ if (n.overflowDirection?.includes("HORIZONTAL")) overflowScroll.push("x");
754
+ if (n.overflowDirection?.includes("VERTICAL")) overflowScroll.push("y");
755
+ if (overflowScroll.length > 0) frameValues.overflowScroll = overflowScroll;
756
+ if (frameValues.mode === "none") {
757
+ return frameValues;
758
+ }
759
+ frameValues.justifyContent = convertAlign(n.primaryAxisAlignItems ?? "MIN", {
760
+ children: n.children,
761
+ axis: "primary",
762
+ mode: frameValues.mode
763
+ });
764
+ frameValues.alignItems = convertAlign(n.counterAxisAlignItems ?? "MIN", {
765
+ children: n.children,
766
+ axis: "counter",
767
+ mode: frameValues.mode
768
+ });
769
+ frameValues.alignSelf = convertSelfAlign(n.layoutAlign);
770
+ frameValues.wrap = n.layoutWrap === "WRAP" ? true : void 0;
771
+ frameValues.gap = n.itemSpacing ? `${n.itemSpacing ?? 0}px` : void 0;
772
+ if (n.paddingTop || n.paddingBottom || n.paddingLeft || n.paddingRight) {
773
+ frameValues.padding = generateCSSShorthand({
774
+ top: n.paddingTop ?? 0,
775
+ right: n.paddingRight ?? 0,
776
+ bottom: n.paddingBottom ?? 0,
777
+ left: n.paddingLeft ?? 0
778
+ });
779
+ }
780
+ return frameValues;
781
+ }
782
+ function buildSimplifiedLayoutValues(n, parent, mode) {
783
+ if (!isLayout(n)) return void 0;
784
+ const layoutValues = { mode };
785
+ layoutValues.sizing = {
786
+ horizontal: convertSizing(n.layoutSizingHorizontal),
787
+ vertical: convertSizing(n.layoutSizingVertical)
788
+ };
789
+ if (
790
+ // If parent is a frame but not an AutoLayout, or if the node is absolute, include positioning-related properties
791
+ isFrame(parent) && !isInAutoLayoutFlow(n, parent)
792
+ ) {
793
+ if (n.layoutPositioning === "ABSOLUTE") {
794
+ layoutValues.position = "absolute";
795
+ }
796
+ if (n.absoluteBoundingBox && parent.absoluteBoundingBox) {
797
+ layoutValues.locationRelativeToParent = {
798
+ x: pixelRound(n.absoluteBoundingBox.x - parent.absoluteBoundingBox.x),
799
+ y: pixelRound(n.absoluteBoundingBox.y - parent.absoluteBoundingBox.y)
800
+ };
801
+ }
802
+ }
803
+ if (isRectangle("absoluteBoundingBox", n)) {
804
+ const dimensions = {};
805
+ if (mode === "row") {
806
+ if (!n.layoutGrow && n.layoutSizingHorizontal == "FIXED")
807
+ dimensions.width = n.absoluteBoundingBox.width;
808
+ if (n.layoutAlign !== "STRETCH" && n.layoutSizingVertical == "FIXED")
809
+ dimensions.height = n.absoluteBoundingBox.height;
810
+ } else if (mode === "column") {
811
+ if (n.layoutAlign !== "STRETCH" && n.layoutSizingHorizontal == "FIXED")
812
+ dimensions.width = n.absoluteBoundingBox.width;
813
+ if (!n.layoutGrow && n.layoutSizingVertical == "FIXED")
814
+ dimensions.height = n.absoluteBoundingBox.height;
815
+ if (n.preserveRatio) {
816
+ dimensions.aspectRatio = n.absoluteBoundingBox?.width / n.absoluteBoundingBox?.height;
817
+ }
818
+ } else {
819
+ if (!n.layoutSizingHorizontal || n.layoutSizingHorizontal === "FIXED") {
820
+ dimensions.width = n.absoluteBoundingBox.width;
821
+ }
822
+ if (!n.layoutSizingVertical || n.layoutSizingVertical === "FIXED") {
823
+ dimensions.height = n.absoluteBoundingBox.height;
824
+ }
825
+ }
826
+ if (Object.keys(dimensions).length > 0) {
827
+ if (dimensions.width) {
828
+ dimensions.width = pixelRound(dimensions.width);
829
+ }
830
+ if (dimensions.height) {
831
+ dimensions.height = pixelRound(dimensions.height);
832
+ }
833
+ layoutValues.dimensions = dimensions;
834
+ }
835
+ }
836
+ return layoutValues;
837
+ }
838
+ function translateScaleMode(scaleMode, hasChildren, scalingFactor) {
839
+ const isBackground = hasChildren;
840
+ switch (scaleMode) {
841
+ case "FILL":
842
+ return {
843
+ css: isBackground ? { backgroundSize: "cover", backgroundRepeat: "no-repeat", isBackground: true } : { objectFit: "cover", isBackground: false },
844
+ processing: { needsCropping: false, requiresImageDimensions: false }
845
+ };
846
+ case "FIT":
847
+ return {
848
+ css: isBackground ? { backgroundSize: "contain", backgroundRepeat: "no-repeat", isBackground: true } : { objectFit: "contain", isBackground: false },
849
+ processing: { needsCropping: false, requiresImageDimensions: false }
850
+ };
851
+ case "TILE":
852
+ return {
853
+ css: {
854
+ backgroundRepeat: "repeat",
855
+ backgroundSize: scalingFactor ? `calc(var(--original-width) * ${scalingFactor}) calc(var(--original-height) * ${scalingFactor})` : "auto",
856
+ isBackground: true
857
+ },
858
+ processing: { needsCropping: false, requiresImageDimensions: true }
859
+ };
860
+ case "STRETCH":
861
+ return {
862
+ css: isBackground ? { backgroundSize: "100% 100%", backgroundRepeat: "no-repeat", isBackground: true } : { objectFit: "fill", isBackground: false },
863
+ processing: { needsCropping: false, requiresImageDimensions: false }
864
+ };
865
+ default:
866
+ return {
867
+ css: {},
868
+ processing: { needsCropping: false, requiresImageDimensions: false }
869
+ };
870
+ }
871
+ }
872
+ function generateTransformHash(transform) {
873
+ const values = transform.flat();
874
+ const hash = values.reduce((acc, val) => {
875
+ const str = val.toString();
876
+ for (let i = 0; i < str.length; i++) {
877
+ acc = (acc << 5) - acc + str.charCodeAt(i) & 4294967295;
878
+ }
879
+ return acc;
880
+ }, 0);
881
+ return Math.abs(hash).toString(16).substring(0, 6);
882
+ }
883
+ function handleImageTransform(imageTransform) {
884
+ const transformHash = generateTransformHash(imageTransform);
885
+ return {
886
+ needsCropping: true,
887
+ requiresImageDimensions: false,
888
+ cropTransform: imageTransform,
889
+ filenameSuffix: `${transformHash}`
890
+ };
891
+ }
892
+ function buildSimplifiedStrokes(n, hasChildren = false) {
893
+ let strokes = { colors: [] };
894
+ if (hasValue("strokes", n) && Array.isArray(n.strokes) && n.strokes.length) {
895
+ strokes.colors = n.strokes.filter(isVisible).map((stroke) => parsePaint(stroke, hasChildren));
896
+ }
897
+ if (hasValue("strokeWeight", n) && typeof n.strokeWeight === "number" && n.strokeWeight > 0) {
898
+ strokes.strokeWeight = `${n.strokeWeight}px`;
899
+ }
900
+ if (hasValue("strokeDashes", n) && Array.isArray(n.strokeDashes) && n.strokeDashes.length) {
901
+ strokes.strokeDashes = n.strokeDashes;
902
+ }
903
+ if (hasValue("individualStrokeWeights", n, isStrokeWeights)) {
904
+ strokes.strokeWeight = generateCSSShorthand(n.individualStrokeWeights);
905
+ }
906
+ return strokes;
907
+ }
908
+ function parsePaint(raw, hasChildren = false) {
909
+ if (raw.type === "IMAGE") {
910
+ const baseImageFill = {
911
+ type: "IMAGE",
912
+ imageRef: raw.imageRef,
913
+ scaleMode: raw.scaleMode,
914
+ scalingFactor: raw.scalingFactor
915
+ };
916
+ const isBackground = hasChildren || baseImageFill.scaleMode === "TILE";
917
+ const { css, processing } = translateScaleMode(
918
+ baseImageFill.scaleMode,
919
+ isBackground,
920
+ raw.scalingFactor
921
+ );
922
+ let finalProcessing = processing;
923
+ if (raw.imageTransform) {
924
+ const transformProcessing = handleImageTransform(raw.imageTransform);
925
+ finalProcessing = {
926
+ ...processing,
927
+ ...transformProcessing,
928
+ // Keep requiresImageDimensions from scale mode (needed for TILE)
929
+ requiresImageDimensions: processing.requiresImageDimensions || transformProcessing.requiresImageDimensions
930
+ };
931
+ }
932
+ return {
933
+ ...baseImageFill,
934
+ ...css,
935
+ imageDownloadArguments: finalProcessing
936
+ };
937
+ } else if (raw.type === "SOLID") {
938
+ const { hex, opacity } = convertColor(raw.color, raw.opacity);
939
+ if (opacity === 1) {
940
+ return hex;
941
+ } else {
942
+ return formatRGBAColor(raw.color, opacity);
943
+ }
944
+ } else if (raw.type === "PATTERN") {
945
+ return parsePatternPaint(raw);
946
+ } else if (["GRADIENT_LINEAR", "GRADIENT_RADIAL", "GRADIENT_ANGULAR", "GRADIENT_DIAMOND"].includes(
947
+ raw.type
948
+ )) {
949
+ return {
950
+ type: raw.type,
951
+ gradient: convertGradientToCss(raw)
952
+ };
953
+ } else {
954
+ throw new Error(`Unknown paint type: ${raw.type}`);
955
+ }
956
+ }
957
+ function parsePatternPaint(raw) {
958
+ let backgroundRepeat = "repeat";
959
+ let horizontal = "left";
960
+ switch (raw.horizontalAlignment) {
961
+ case "START":
962
+ horizontal = "left";
963
+ break;
964
+ case "CENTER":
965
+ horizontal = "center";
966
+ break;
967
+ case "END":
968
+ horizontal = "right";
969
+ break;
970
+ }
971
+ let vertical = "top";
972
+ switch (raw.verticalAlignment) {
973
+ case "START":
974
+ vertical = "top";
975
+ break;
976
+ case "CENTER":
977
+ vertical = "center";
978
+ break;
979
+ case "END":
980
+ vertical = "bottom";
981
+ break;
982
+ }
983
+ return {
984
+ type: raw.type,
985
+ patternSource: {
986
+ type: "IMAGE-PNG",
987
+ nodeId: raw.sourceNodeId
988
+ },
989
+ backgroundRepeat,
990
+ backgroundSize: `${Math.round(raw.scalingFactor * 100)}%`,
991
+ backgroundPosition: `${horizontal} ${vertical}`
992
+ };
993
+ }
994
+ function convertColor(color, opacity = 1) {
995
+ const r = Math.round(color.r * 255);
996
+ const g = Math.round(color.g * 255);
997
+ const b = Math.round(color.b * 255);
998
+ const a = Math.round(opacity * color.a * 100) / 100;
999
+ const hex = "#" + ((1 << 24) + (r << 16) + (g << 8) + b).toString(16).slice(1).toUpperCase();
1000
+ return { hex, opacity: a };
1001
+ }
1002
+ function formatRGBAColor(color, opacity = 1) {
1003
+ const r = Math.round(color.r * 255);
1004
+ const g = Math.round(color.g * 255);
1005
+ const b = Math.round(color.b * 255);
1006
+ const a = Math.round(opacity * color.a * 100) / 100;
1007
+ return `rgba(${r}, ${g}, ${b}, ${a})`;
1008
+ }
1009
+ function mapGradientStops(gradient, elementBounds = { width: 1, height: 1 }) {
1010
+ const handles = gradient.gradientHandlePositions;
1011
+ if (!handles || handles.length < 2) {
1012
+ const stops = gradient.gradientStops.map(({ position, color }) => {
1013
+ const cssColor = formatRGBAColor(color, 1);
1014
+ return `${cssColor} ${Math.round(position * 100)}%`;
1015
+ }).join(", ");
1016
+ return { stops, cssGeometry: "0deg" };
1017
+ }
1018
+ const [handle1, handle2, handle3] = handles;
1019
+ switch (gradient.type) {
1020
+ case "GRADIENT_LINEAR": {
1021
+ return mapLinearGradient(gradient.gradientStops, handle1, handle2);
1022
+ }
1023
+ case "GRADIENT_RADIAL": {
1024
+ return mapRadialGradient(gradient.gradientStops, handle1);
1025
+ }
1026
+ case "GRADIENT_ANGULAR": {
1027
+ return mapAngularGradient(gradient.gradientStops, handle1, handle2);
1028
+ }
1029
+ case "GRADIENT_DIAMOND": {
1030
+ return mapDiamondGradient(gradient.gradientStops, handle1);
1031
+ }
1032
+ default: {
1033
+ const stops = gradient.gradientStops.map(({ position, color }) => {
1034
+ const cssColor = formatRGBAColor(color, 1);
1035
+ return `${cssColor} ${Math.round(position * 100)}%`;
1036
+ }).join(", ");
1037
+ return { stops, cssGeometry: "0deg" };
1038
+ }
1039
+ }
1040
+ }
1041
+ function mapLinearGradient(gradientStops, start, end, elementBounds) {
1042
+ const dx = end.x - start.x;
1043
+ const dy = end.y - start.y;
1044
+ const gradientLength = Math.sqrt(dx * dx + dy * dy);
1045
+ if (gradientLength === 0) {
1046
+ const stops = gradientStops.map(({ position, color }) => {
1047
+ const cssColor = formatRGBAColor(color, 1);
1048
+ return `${cssColor} ${Math.round(position * 100)}%`;
1049
+ }).join(", ");
1050
+ return { stops, cssGeometry: "0deg" };
1051
+ }
1052
+ const angle = Math.atan2(dy, dx) * (180 / Math.PI) + 90;
1053
+ const extendedIntersections = findExtendedLineIntersections(start, end);
1054
+ if (extendedIntersections.length >= 2) {
1055
+ const fullLineStart = Math.min(extendedIntersections[0], extendedIntersections[1]);
1056
+ const fullLineEnd = Math.max(extendedIntersections[0], extendedIntersections[1]);
1057
+ const mappedStops2 = gradientStops.map(({ position, color }) => {
1058
+ const cssColor = formatRGBAColor(color, 1);
1059
+ const figmaLinePosition = position;
1060
+ const tOnExtendedLine = figmaLinePosition * (1 - 0) + 0;
1061
+ const extendedPosition = (tOnExtendedLine - fullLineStart) / (fullLineEnd - fullLineStart);
1062
+ const clampedPosition = Math.max(0, Math.min(1, extendedPosition));
1063
+ return `${cssColor} ${Math.round(clampedPosition * 100)}%`;
1064
+ });
1065
+ return {
1066
+ stops: mappedStops2.join(", "),
1067
+ cssGeometry: `${Math.round(angle)}deg`
1068
+ };
1069
+ }
1070
+ const mappedStops = gradientStops.map(({ position, color }) => {
1071
+ const cssColor = formatRGBAColor(color, 1);
1072
+ return `${cssColor} ${Math.round(position * 100)}%`;
1073
+ });
1074
+ return {
1075
+ stops: mappedStops.join(", "),
1076
+ cssGeometry: `${Math.round(angle)}deg`
1077
+ };
1078
+ }
1079
+ function findExtendedLineIntersections(start, end) {
1080
+ const dx = end.x - start.x;
1081
+ const dy = end.y - start.y;
1082
+ if (Math.abs(dx) < 1e-10 && Math.abs(dy) < 1e-10) {
1083
+ return [];
1084
+ }
1085
+ const intersections = [];
1086
+ if (Math.abs(dy) > 1e-10) {
1087
+ const t = -start.y / dy;
1088
+ const x = start.x + t * dx;
1089
+ if (x >= 0 && x <= 1) {
1090
+ intersections.push(t);
1091
+ }
1092
+ }
1093
+ if (Math.abs(dy) > 1e-10) {
1094
+ const t = (1 - start.y) / dy;
1095
+ const x = start.x + t * dx;
1096
+ if (x >= 0 && x <= 1) {
1097
+ intersections.push(t);
1098
+ }
1099
+ }
1100
+ if (Math.abs(dx) > 1e-10) {
1101
+ const t = -start.x / dx;
1102
+ const y = start.y + t * dy;
1103
+ if (y >= 0 && y <= 1) {
1104
+ intersections.push(t);
1105
+ }
1106
+ }
1107
+ if (Math.abs(dx) > 1e-10) {
1108
+ const t = (1 - start.x) / dx;
1109
+ const y = start.y + t * dy;
1110
+ if (y >= 0 && y <= 1) {
1111
+ intersections.push(t);
1112
+ }
1113
+ }
1114
+ const uniqueIntersections = [
1115
+ ...new Set(intersections.map((t) => Math.round(t * 1e6) / 1e6))
1116
+ ];
1117
+ return uniqueIntersections.sort((a, b) => a - b);
1118
+ }
1119
+ function mapRadialGradient(gradientStops, center, edge, widthHandle, elementBounds) {
1120
+ const centerX = Math.round(center.x * 100);
1121
+ const centerY = Math.round(center.y * 100);
1122
+ const stops = gradientStops.map(({ position, color }) => {
1123
+ const cssColor = formatRGBAColor(color, 1);
1124
+ return `${cssColor} ${Math.round(position * 100)}%`;
1125
+ }).join(", ");
1126
+ return {
1127
+ stops,
1128
+ cssGeometry: `circle at ${centerX}% ${centerY}%`
1129
+ };
1130
+ }
1131
+ function mapAngularGradient(gradientStops, center, angleHandle, widthHandle, elementBounds) {
1132
+ const centerX = Math.round(center.x * 100);
1133
+ const centerY = Math.round(center.y * 100);
1134
+ const angle = Math.atan2(angleHandle.y - center.y, angleHandle.x - center.x) * (180 / Math.PI) + 90;
1135
+ const stops = gradientStops.map(({ position, color }) => {
1136
+ const cssColor = formatRGBAColor(color, 1);
1137
+ return `${cssColor} ${Math.round(position * 100)}%`;
1138
+ }).join(", ");
1139
+ return {
1140
+ stops,
1141
+ cssGeometry: `from ${Math.round(angle)}deg at ${centerX}% ${centerY}%`
1142
+ };
1143
+ }
1144
+ function mapDiamondGradient(gradientStops, center, edge, widthHandle, elementBounds) {
1145
+ const centerX = Math.round(center.x * 100);
1146
+ const centerY = Math.round(center.y * 100);
1147
+ const stops = gradientStops.map(({ position, color }) => {
1148
+ const cssColor = formatRGBAColor(color, 1);
1149
+ return `${cssColor} ${Math.round(position * 100)}%`;
1150
+ }).join(", ");
1151
+ return {
1152
+ stops,
1153
+ cssGeometry: `ellipse at ${centerX}% ${centerY}%`
1154
+ };
1155
+ }
1156
+ function convertGradientToCss(gradient) {
1157
+ const sortedGradient = {
1158
+ ...gradient,
1159
+ gradientStops: [...gradient.gradientStops].sort((a, b) => a.position - b.position)
1160
+ };
1161
+ const { stops, cssGeometry } = mapGradientStops(sortedGradient);
1162
+ switch (gradient.type) {
1163
+ case "GRADIENT_LINEAR": {
1164
+ return `linear-gradient(${cssGeometry}, ${stops})`;
1165
+ }
1166
+ case "GRADIENT_RADIAL": {
1167
+ return `radial-gradient(${cssGeometry}, ${stops})`;
1168
+ }
1169
+ case "GRADIENT_ANGULAR": {
1170
+ return `conic-gradient(${cssGeometry}, ${stops})`;
1171
+ }
1172
+ case "GRADIENT_DIAMOND": {
1173
+ return `radial-gradient(${cssGeometry}, ${stops})`;
1174
+ }
1175
+ default:
1176
+ return `linear-gradient(0deg, ${stops})`;
1177
+ }
1178
+ }
1179
+ function buildSimplifiedEffects(n) {
1180
+ if (!hasValue("effects", n)) return {};
1181
+ const effects = n.effects.filter((e) => e.visible);
1182
+ const dropShadows = effects.filter((e) => e.type === "DROP_SHADOW").map(simplifyDropShadow);
1183
+ const innerShadows = effects.filter((e) => e.type === "INNER_SHADOW").map(simplifyInnerShadow);
1184
+ const boxShadow = [...dropShadows, ...innerShadows].join(", ");
1185
+ const filterBlurValues = effects.filter((e) => e.type === "LAYER_BLUR").map(simplifyBlur).join(" ");
1186
+ const backdropFilterValues = effects.filter((e) => e.type === "BACKGROUND_BLUR").map(simplifyBlur).join(" ");
1187
+ const result = {};
1188
+ if (boxShadow) {
1189
+ if (n.type === "TEXT") {
1190
+ result.textShadow = boxShadow;
1191
+ } else {
1192
+ result.boxShadow = boxShadow;
1193
+ }
1194
+ }
1195
+ if (filterBlurValues) result.filter = filterBlurValues;
1196
+ if (backdropFilterValues) result.backdropFilter = backdropFilterValues;
1197
+ return result;
1198
+ }
1199
+ function simplifyDropShadow(effect) {
1200
+ return `${effect.offset.x}px ${effect.offset.y}px ${effect.radius}px ${effect.spread ?? 0}px ${formatRGBAColor(effect.color)}`;
1201
+ }
1202
+ function simplifyInnerShadow(effect) {
1203
+ return `inset ${effect.offset.x}px ${effect.offset.y}px ${effect.radius}px ${effect.spread ?? 0}px ${formatRGBAColor(effect.color)}`;
1204
+ }
1205
+ function simplifyBlur(effect) {
1206
+ return `blur(${effect.radius}px)`;
1207
+ }
1208
+ function isTextNode(n) {
1209
+ return n.type === "TEXT";
1210
+ }
1211
+ function hasTextStyle(n) {
1212
+ return hasValue("style", n) && Object.keys(n.style).length > 0;
1213
+ }
1214
+ function extractNodeText(n) {
1215
+ if (hasValue("characters", n, remeda.isTruthy)) {
1216
+ return n.characters;
1217
+ }
1218
+ return void 0;
1219
+ }
1220
+ function extractTextStyle(n) {
1221
+ if (hasTextStyle(n)) {
1222
+ const style = n.style;
1223
+ const textStyle = {
1224
+ fontFamily: style.fontFamily,
1225
+ fontWeight: style.fontWeight,
1226
+ fontSize: style.fontSize,
1227
+ lineHeight: "lineHeightPx" in style && style.lineHeightPx && style.fontSize ? `${style.lineHeightPx / style.fontSize}em` : void 0,
1228
+ letterSpacing: style.letterSpacing && style.letterSpacing !== 0 && style.fontSize ? `${style.letterSpacing / style.fontSize * 100}%` : void 0,
1229
+ textCase: style.textCase,
1230
+ textAlignHorizontal: style.textAlignHorizontal,
1231
+ textAlignVertical: style.textAlignVertical
1232
+ };
1233
+ return textStyle;
1234
+ }
1235
+ return void 0;
1236
+ }
1237
+ function findOrCreateVar(globalVars, value, prefix) {
1238
+ const [existingVarId] = Object.entries(globalVars.styles).find(
1239
+ ([_, existingValue]) => JSON.stringify(existingValue) === JSON.stringify(value)
1240
+ ) ?? [];
1241
+ if (existingVarId) {
1242
+ return existingVarId;
1243
+ }
1244
+ const varId = generateVarId(prefix);
1245
+ globalVars.styles[varId] = value;
1246
+ return varId;
1247
+ }
1248
+ const layoutExtractor = (node, result, context) => {
1249
+ const layout = buildSimplifiedLayout(node, context.parent);
1250
+ if (Object.keys(layout).length > 1) {
1251
+ result.layout = findOrCreateVar(context.globalVars, layout, "layout");
1252
+ }
1253
+ };
1254
+ const textExtractor = (node, result, context) => {
1255
+ if (isTextNode(node)) {
1256
+ result.text = extractNodeText(node);
1257
+ }
1258
+ if (hasTextStyle(node)) {
1259
+ const textStyle = extractTextStyle(node);
1260
+ if (textStyle) {
1261
+ const styleName = getStyleName(node, context, ["text", "typography"]);
1262
+ if (styleName) {
1263
+ context.globalVars.styles[styleName] = textStyle;
1264
+ result.textStyle = styleName;
1265
+ } else {
1266
+ result.textStyle = findOrCreateVar(context.globalVars, textStyle, "style");
1267
+ }
1268
+ }
1269
+ }
1270
+ };
1271
+ const visualsExtractor = (node, result, context) => {
1272
+ const hasChildren = hasValue("children", node) && Array.isArray(node.children) && node.children.length > 0;
1273
+ if (hasValue("fills", node) && Array.isArray(node.fills) && node.fills.length) {
1274
+ const fills = node.fills.map((fill) => parsePaint(fill, hasChildren)).reverse();
1275
+ const styleName = getStyleName(node, context, ["fill", "fills"]);
1276
+ if (styleName) {
1277
+ context.globalVars.styles[styleName] = fills;
1278
+ result.fills = styleName;
1279
+ } else {
1280
+ result.fills = findOrCreateVar(context.globalVars, fills, "fill");
1281
+ }
1282
+ }
1283
+ const strokes = buildSimplifiedStrokes(node, hasChildren);
1284
+ if (strokes.colors.length) {
1285
+ const styleName = getStyleName(node, context, ["stroke", "strokes"]);
1286
+ if (styleName) {
1287
+ context.globalVars.styles[styleName] = strokes.colors;
1288
+ result.strokes = styleName;
1289
+ if (strokes.strokeWeight) result.strokeWeight = strokes.strokeWeight;
1290
+ if (strokes.strokeDashes) result.strokeDashes = strokes.strokeDashes;
1291
+ if (strokes.strokeWeights) result.strokeWeights = strokes.strokeWeights;
1292
+ } else {
1293
+ result.strokes = findOrCreateVar(context.globalVars, strokes, "stroke");
1294
+ }
1295
+ }
1296
+ const effects = buildSimplifiedEffects(node);
1297
+ if (Object.keys(effects).length) {
1298
+ const styleName = getStyleName(node, context, ["effect", "effects"]);
1299
+ if (styleName) {
1300
+ context.globalVars.styles[styleName] = effects;
1301
+ result.effects = styleName;
1302
+ } else {
1303
+ result.effects = findOrCreateVar(context.globalVars, effects, "effect");
1304
+ }
1305
+ }
1306
+ if (hasValue("opacity", node) && typeof node.opacity === "number" && node.opacity !== 1) {
1307
+ result.opacity = node.opacity;
1308
+ }
1309
+ if (hasValue("cornerRadius", node) && typeof node.cornerRadius === "number") {
1310
+ result.borderRadius = `${node.cornerRadius}px`;
1311
+ }
1312
+ if (hasValue("rectangleCornerRadii", node, isRectangleCornerRadii)) {
1313
+ result.borderRadius = `${node.rectangleCornerRadii[0]}px ${node.rectangleCornerRadii[1]}px ${node.rectangleCornerRadii[2]}px ${node.rectangleCornerRadii[3]}px`;
1314
+ }
1315
+ };
1316
+ const componentExtractor = (node, result, _context) => {
1317
+ if (node.type === "INSTANCE") {
1318
+ if (hasValue("componentId", node)) {
1319
+ result.componentId = node.componentId;
1320
+ }
1321
+ if (hasValue("componentProperties", node)) {
1322
+ result.componentProperties = Object.entries(node.componentProperties ?? {}).map(
1323
+ ([name, { value, type }]) => ({
1324
+ name,
1325
+ value: value.toString(),
1326
+ type
1327
+ })
1328
+ );
1329
+ }
1330
+ }
1331
+ };
1332
+ function getStyleName(node, context, keys) {
1333
+ if (!hasValue("styles", node)) return void 0;
1334
+ const styleMap = node.styles;
1335
+ for (const key of keys) {
1336
+ const styleId = styleMap[key];
1337
+ if (styleId) {
1338
+ const meta = context.globalVars.extraStyles?.[styleId];
1339
+ if (meta?.name) return meta.name;
1340
+ }
1341
+ }
1342
+ return void 0;
1343
+ }
1344
+ const allExtractors = [layoutExtractor, textExtractor, visualsExtractor, componentExtractor];
1345
+ const layoutAndText = [layoutExtractor, textExtractor];
1346
+ const contentOnly = [textExtractor];
1347
+ const visualsOnly = [visualsExtractor];
1348
+ const layoutOnly = [layoutExtractor];
1349
+ const SVG_ELIGIBLE_TYPES = /* @__PURE__ */ new Set([
1350
+ "IMAGE-SVG",
1351
+ "STAR",
1352
+ "LINE",
1353
+ "ELLIPSE",
1354
+ "REGULAR_POLYGON",
1355
+ "RECTANGLE"
1356
+ ]);
1357
+ function collapseSvgContainers(node, result, children) {
1358
+ const allChildrenAreSvgEligible = children.every(
1359
+ (child) => SVG_ELIGIBLE_TYPES.has(child.type)
1360
+ );
1361
+ if ((node.type === "FRAME" || node.type === "GROUP" || node.type === "INSTANCE") && allChildrenAreSvgEligible) {
1362
+ result.type = "IMAGE-SVG";
1363
+ return [];
1364
+ }
1365
+ return children;
1366
+ }
1367
+ async function getFigmaMetadata(figmaUrl, options = {}) {
1368
+ const { apiKey, oauthToken, useOAuth = false, outputFormat = "object", depth } = options;
1369
+ if (!apiKey && !oauthToken) {
1370
+ throw new Error("Either apiKey or oauthToken is required");
1371
+ }
1372
+ const urlMatch = figmaUrl.match(/figma\.com\/(file|design)\/([a-zA-Z0-9]+)/);
1373
+ if (!urlMatch) {
1374
+ throw new Error("Invalid Figma URL format");
1375
+ }
1376
+ const fileKey = urlMatch[2];
1377
+ const nodeIdMatch = figmaUrl.match(/node-id=([^&]+)/);
1378
+ const nodeId = nodeIdMatch ? nodeIdMatch[1].replace(/-/g, ":") : void 0;
1379
+ const figmaService = new FigmaService({
1380
+ figmaApiKey: apiKey || "",
1381
+ figmaOAuthToken: oauthToken || "",
1382
+ useOAuth: useOAuth && !!oauthToken
1383
+ });
1384
+ try {
1385
+ Logger.log(
1386
+ `Fetching ${depth ? `${depth} layers deep` : "all layers"} of ${nodeId ? `node ${nodeId} from file` : `full file`} ${fileKey}`
1387
+ );
1388
+ let rawApiResponse;
1389
+ if (nodeId) {
1390
+ rawApiResponse = await figmaService.getRawNode(fileKey, nodeId, depth || void 0);
1391
+ } else {
1392
+ rawApiResponse = await figmaService.getRawFile(fileKey, depth || void 0);
1393
+ }
1394
+ const simplifiedDesign = simplifyRawFigmaObject(rawApiResponse, allExtractors, {
1395
+ maxDepth: depth || void 0,
1396
+ afterChildren: collapseSvgContainers
1397
+ });
1398
+ Logger.log(
1399
+ `Successfully extracted data: ${simplifiedDesign.nodes.length} nodes, ${Object.keys(simplifiedDesign.globalVars?.styles || {}).length} styles`
1400
+ );
1401
+ const { nodes, globalVars, ...metadata } = simplifiedDesign;
1402
+ const result = {
1403
+ metadata,
1404
+ nodes,
1405
+ globalVars
1406
+ };
1407
+ if (outputFormat === "json") {
1408
+ return JSON.stringify(result, null, 2);
1409
+ } else if (outputFormat === "yaml") {
1410
+ return yaml.dump(result);
1411
+ } else {
1412
+ return result;
1413
+ }
1414
+ } catch (error) {
1415
+ const message = error instanceof Error ? error.message : String(error);
1416
+ Logger.error(`Error fetching file ${fileKey}:`, message);
1417
+ throw new Error(`Failed to fetch Figma data: ${message}`);
1418
+ }
1419
+ }
1420
+ async function downloadFigmaImages(figmaUrl, nodes, options) {
1421
+ const { apiKey, oauthToken, useOAuth = false, pngScale = 2, localPath } = options;
1422
+ if (!apiKey && !oauthToken) {
1423
+ throw new Error("Either apiKey or oauthToken is required");
1424
+ }
1425
+ const urlMatch = figmaUrl.match(/figma\.com\/(file|design)\/([a-zA-Z0-9]+)/);
1426
+ if (!urlMatch) {
1427
+ throw new Error("Invalid Figma URL format");
1428
+ }
1429
+ const fileKey = urlMatch[2];
1430
+ const figmaService = new FigmaService({
1431
+ figmaApiKey: apiKey || "",
1432
+ figmaOAuthToken: oauthToken || "",
1433
+ useOAuth: useOAuth && !!oauthToken
1434
+ });
1435
+ try {
1436
+ const processedNodes = nodes.map((node) => ({
1437
+ ...node,
1438
+ nodeId: node.nodeId.replace(/-/g, ":")
1439
+ }));
1440
+ const results = await figmaService.downloadImages(fileKey, localPath, processedNodes, {
1441
+ pngScale
1442
+ });
1443
+ return results;
1444
+ } catch (error) {
1445
+ Logger.error(`Error downloading images from ${fileKey}:`, error);
1446
+ throw new Error(`Failed to download images: ${error instanceof Error ? error.message : String(error)}`);
1447
+ }
1448
+ }
1449
+ exports.allExtractors = allExtractors;
1450
+ exports.collapseSvgContainers = collapseSvgContainers;
1451
+ exports.componentExtractor = componentExtractor;
1452
+ exports.contentOnly = contentOnly;
1453
+ exports.downloadFigmaImages = downloadFigmaImages;
1454
+ exports.extractFromDesign = extractFromDesign;
1455
+ exports.getFigmaMetadata = getFigmaMetadata;
1456
+ exports.layoutAndText = layoutAndText;
1457
+ exports.layoutExtractor = layoutExtractor;
1458
+ exports.layoutOnly = layoutOnly;
1459
+ exports.simplifyRawFigmaObject = simplifyRawFigmaObject;
1460
+ exports.textExtractor = textExtractor;
1461
+ exports.visualsExtractor = visualsExtractor;
1462
+ exports.visualsOnly = visualsOnly;