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