@design-embed/plugin-figma-html 0.1.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.
Files changed (41) hide show
  1. package/LICENSE +9 -0
  2. package/README.md +5 -0
  3. package/dist/compilers/compilerUtils.js +182 -0
  4. package/dist/compilers/htmlCompiler.js +35 -0
  5. package/dist/compilers/index.js +17 -0
  6. package/dist/compilers/reactCompiler.js +58 -0
  7. package/dist/compilers/vanjsCompiler.js +55 -0
  8. package/dist/external/figmaApi.js +74 -0
  9. package/dist/external/imageDownloader.js +82 -0
  10. package/dist/index.js +3 -0
  11. package/dist/plugin.js +56 -0
  12. package/dist/types.js +1 -0
  13. package/node_modules/@design-embed/config/README.md +5 -0
  14. package/node_modules/@design-embed/config/dist/index.js +283 -0
  15. package/node_modules/@design-embed/config/package.json +19 -0
  16. package/node_modules/@design-embed/config/src/index.ts +518 -0
  17. package/node_modules/@design-embed/core/README.md +5 -0
  18. package/node_modules/@design-embed/core/dist/diagnostics/diagnostic.js +3 -0
  19. package/node_modules/@design-embed/core/dist/diagnostics/jsonDiagnostic.js +35 -0
  20. package/node_modules/@design-embed/core/dist/index.js +351 -0
  21. package/node_modules/@design-embed/core/dist/pipeline/checkMode.js +29 -0
  22. package/node_modules/@design-embed/core/dist/plugins/pluginApi.js +1 -0
  23. package/node_modules/@design-embed/core/dist/plugins/pluginRegistry.js +25 -0
  24. package/node_modules/@design-embed/core/package.json +19 -0
  25. package/node_modules/@design-embed/core/src/diagnostics/diagnostic.ts +18 -0
  26. package/node_modules/@design-embed/core/src/diagnostics/jsonDiagnostic.ts +51 -0
  27. package/node_modules/@design-embed/core/src/index.ts +591 -0
  28. package/node_modules/@design-embed/core/src/pipeline/checkMode.ts +46 -0
  29. package/node_modules/@design-embed/core/src/plugins/pluginApi.ts +78 -0
  30. package/node_modules/@design-embed/core/src/plugins/pluginRegistry.ts +37 -0
  31. package/package.json +30 -0
  32. package/src/compilers/compilerUtils.ts +211 -0
  33. package/src/compilers/htmlCompiler.ts +41 -0
  34. package/src/compilers/index.ts +24 -0
  35. package/src/compilers/reactCompiler.ts +79 -0
  36. package/src/compilers/vanjsCompiler.ts +67 -0
  37. package/src/external/figmaApi.ts +142 -0
  38. package/src/external/imageDownloader.ts +136 -0
  39. package/src/index.ts +26 -0
  40. package/src/plugin.ts +76 -0
  41. package/src/types.ts +85 -0
@@ -0,0 +1,37 @@
1
+ import type { SourcePlugin, TransformerPlugin } from "./pluginApi.ts";
2
+
3
+ export class PluginRegistry {
4
+ #sourcePlugins = new Map<string, SourcePlugin>();
5
+ #transformers: TransformerPlugin[] = [];
6
+
7
+ registerSource(plugin: SourcePlugin): void {
8
+ this.#sourcePlugins.set(plugin.name, plugin);
9
+ }
10
+
11
+ getSource(name: string): SourcePlugin | undefined {
12
+ return this.#sourcePlugins.get(name);
13
+ }
14
+
15
+ listSources(): SourcePlugin[] {
16
+ return [...this.#sourcePlugins.values()].sort((left, right) =>
17
+ left.name.localeCompare(right.name),
18
+ );
19
+ }
20
+
21
+ registerTransformer(plugin: TransformerPlugin): void {
22
+ this.#transformers.push(plugin);
23
+ }
24
+
25
+ listTransformers(): TransformerPlugin[] {
26
+ return sortTransformers(this.#transformers);
27
+ }
28
+ }
29
+
30
+ export function sortTransformers(
31
+ transformers: TransformerPlugin[],
32
+ ): TransformerPlugin[] {
33
+ return [...transformers].sort((left, right) => {
34
+ const orderDelta = (left.order ?? 0) - (right.order ?? 0);
35
+ return orderDelta || left.name.localeCompare(right.name);
36
+ });
37
+ }
package/package.json ADDED
@@ -0,0 +1,30 @@
1
+ {
2
+ "name": "@design-embed/plugin-figma-html",
3
+ "version": "0.1.0",
4
+ "type": "module",
5
+ "private": false,
6
+ "publishConfig": {
7
+ "access": "public"
8
+ },
9
+ "exports": {
10
+ ".": {
11
+ "types": "./src/index.ts",
12
+ "development": "./src/index.ts",
13
+ "default": "./dist/index.js"
14
+ }
15
+ },
16
+ "files": [
17
+ "dist",
18
+ "src",
19
+ "!src/**/*.test.ts",
20
+ "README.md"
21
+ ],
22
+ "dependencies": {
23
+ "@design-embed/config": "0.1.0",
24
+ "@design-embed/core": "0.1.0"
25
+ },
26
+ "bundledDependencies": [
27
+ "@design-embed/config",
28
+ "@design-embed/core"
29
+ ]
30
+ }
@@ -0,0 +1,211 @@
1
+ import type { FigmaNode } from "../types.ts";
2
+
3
+ export function toComponentName(
4
+ name: string | undefined,
5
+ fallback = "FigmaExport",
6
+ ): string {
7
+ const cleaned = (name || "").replace(/[^a-zA-Z0-9가-힣]/g, "");
8
+ if (!cleaned) return fallback;
9
+ return /^[0-9]/.test(cleaned) ? `${fallback}${cleaned}` : cleaned;
10
+ }
11
+
12
+ export function escapeHtml(value: string | undefined): string {
13
+ return (value || "")
14
+ .replace(/&/g, "&amp;")
15
+ .replace(/</g, "&lt;")
16
+ .replace(/>/g, "&gt;")
17
+ .replace(/"/g, "&quot;")
18
+ .replace(/'/g, "&#39;");
19
+ }
20
+
21
+ export function escapeJsString(value: string | undefined): string {
22
+ return JSON.stringify(value || "");
23
+ }
24
+
25
+ export function getNodeStyles(
26
+ node: FigmaNode,
27
+ parent?: FigmaNode,
28
+ ): Record<string, string | number> {
29
+ const styles: Record<string, string | number> = {};
30
+ const bounds = node.absoluteBoundingBox;
31
+ const parentBounds = parent?.absoluteBoundingBox;
32
+ const parentUsesLayout = Boolean(
33
+ parent?.layoutMode && parent.layoutMode !== "NONE",
34
+ );
35
+ const isAbsoluteChild = Boolean(
36
+ parent && (!parentUsesLayout || node.layoutPositioning === "ABSOLUTE"),
37
+ );
38
+
39
+ if (isAbsoluteChild && bounds && parentBounds) {
40
+ styles.position = "absolute";
41
+ styles.left = `${Math.round((bounds.x || 0) - (parentBounds.x || 0))}px`;
42
+ styles.top = `${Math.round((bounds.y || 0) - (parentBounds.y || 0))}px`;
43
+ } else if (node.children?.length || node.layoutMode === "NONE") {
44
+ styles.position = "relative";
45
+ }
46
+
47
+ if (bounds && node.layoutSizingHorizontal !== "HUG") {
48
+ styles.width = `${Math.round(bounds.width || 0)}px`;
49
+ }
50
+ if (bounds && node.layoutSizingVertical !== "HUG") {
51
+ styles.height = `${Math.round(bounds.height || 0)}px`;
52
+ }
53
+
54
+ if (node.layoutMode === "HORIZONTAL" || node.layoutMode === "VERTICAL") {
55
+ styles.display = "flex";
56
+ styles.flexDirection = node.layoutMode === "HORIZONTAL" ? "row" : "column";
57
+ styles.boxSizing = "border-box";
58
+ if (node.layoutWrap === "WRAP") styles.flexWrap = "wrap";
59
+ if (node.itemSpacing !== undefined)
60
+ styles.gap = `${Math.round(node.itemSpacing)}px`;
61
+ if (node.counterAxisSpacing !== undefined && node.layoutWrap === "WRAP") {
62
+ styles.rowGap = `${Math.round(node.counterAxisSpacing)}px`;
63
+ }
64
+ styles.justifyContent = mapPrimaryAxisAlignment(node.primaryAxisAlignItems);
65
+ styles.alignItems = mapCounterAxisAlignment(node.counterAxisAlignItems);
66
+ } else if (node.layoutMode === "GRID") {
67
+ styles.display = "grid";
68
+ styles.boxSizing = "border-box";
69
+ if (node.gridColumnsSizing)
70
+ styles.gridTemplateColumns = node.gridColumnsSizing;
71
+ if (node.gridRowsSizing) styles.gridTemplateRows = node.gridRowsSizing;
72
+ if (node.gridColumnGap !== undefined)
73
+ styles.columnGap = `${Math.round(node.gridColumnGap)}px`;
74
+ if (node.gridRowGap !== undefined)
75
+ styles.rowGap = `${Math.round(node.gridRowGap)}px`;
76
+ }
77
+
78
+ if (node.layoutSizingHorizontal === "FILL" || node.layoutGrow === 1) {
79
+ styles.flex = 1;
80
+ styles.width = "100%";
81
+ }
82
+ if (node.layoutSizingVertical === "FILL") {
83
+ styles.height = "100%";
84
+ }
85
+ if (node.layoutAlign === "STRETCH") {
86
+ styles.alignSelf = "stretch";
87
+ }
88
+ if (node.gridColumnSpan && node.gridColumnSpan > 1) {
89
+ styles.gridColumn = `span ${node.gridColumnSpan}`;
90
+ }
91
+ if (node.gridRowSpan && node.gridRowSpan > 1) {
92
+ styles.gridRow = `span ${node.gridRowSpan}`;
93
+ }
94
+
95
+ if (node.paddingTop !== undefined) styles.paddingTop = `${node.paddingTop}px`;
96
+ if (node.paddingBottom !== undefined)
97
+ styles.paddingBottom = `${node.paddingBottom}px`;
98
+ if (node.paddingLeft !== undefined)
99
+ styles.paddingLeft = `${node.paddingLeft}px`;
100
+ if (node.paddingRight !== undefined)
101
+ styles.paddingRight = `${node.paddingRight}px`;
102
+ if (node.clipsContent) styles.overflow = "hidden";
103
+ if (node.opacity !== undefined && node.opacity !== 1)
104
+ styles.opacity = node.opacity;
105
+
106
+ const fill = node.fills?.find((item) => item.type === "SOLID" && item.color);
107
+ if (fill?.color) {
108
+ if (node.type === "TEXT") {
109
+ styles.color = toRgba(fill.color, fill.opacity ?? 1);
110
+ } else {
111
+ styles.backgroundColor = toRgba(fill.color, fill.opacity ?? 1);
112
+ }
113
+ }
114
+
115
+ const imageFill = node.fills?.find(
116
+ (item) => item.type === "IMAGE" && (item.imageLocalPath || item.imageUrl),
117
+ );
118
+ const imageSource = imageFill?.imageLocalPath || imageFill?.imageUrl;
119
+ if (imageSource) {
120
+ styles.backgroundImage = `url("${imageSource}")`;
121
+ styles.backgroundRepeat = "no-repeat";
122
+ styles.backgroundPosition = "center";
123
+ styles.backgroundSize = mapImageScaleMode(imageFill.scaleMode);
124
+ }
125
+
126
+ const stroke = node.strokes?.find(
127
+ (item) => item.type === "SOLID" && item.color,
128
+ );
129
+ if (stroke?.color && node.strokeWeight) {
130
+ styles.border = `${Math.round(node.strokeWeight)}px solid ${toRgba(stroke.color, stroke.opacity ?? 1)}`;
131
+ styles.boxSizing = "border-box";
132
+ }
133
+
134
+ if (node.rectangleCornerRadii?.length === 4) {
135
+ styles.borderRadius = node.rectangleCornerRadii
136
+ .map((radius) => `${Math.round(radius)}px`)
137
+ .join(" ");
138
+ } else if (node.cornerRadius) {
139
+ styles.borderRadius = `${Math.round(node.cornerRadius)}px`;
140
+ }
141
+
142
+ if (node.type === "TEXT") {
143
+ const textStyle = node.style || {};
144
+ if (textStyle.fontSize) styles.fontSize = `${textStyle.fontSize}px`;
145
+ if (textStyle.fontWeight) styles.fontWeight = textStyle.fontWeight;
146
+ if (textStyle.fontFamily)
147
+ styles.fontFamily = `"${textStyle.fontFamily}", sans-serif`;
148
+ if (textStyle.lineHeightPx)
149
+ styles.lineHeight = `${Math.round(textStyle.lineHeightPx)}px`;
150
+ }
151
+
152
+ return styles;
153
+ }
154
+
155
+ export function toReactStyle(styles: Record<string, string | number>): string {
156
+ return JSON.stringify(styles).replace(/"([^"]+)":/g, "$1:");
157
+ }
158
+
159
+ export function toCssText(styles: Record<string, string | number>): string {
160
+ return Object.entries(styles)
161
+ .map(([key, value]) => `${toKebabCase(key)}: ${value};`)
162
+ .join(" ");
163
+ }
164
+
165
+ function toKebabCase(value: string): string {
166
+ return value.replace(/[A-Z]/g, (match) => `-${match.toLowerCase()}`);
167
+ }
168
+
169
+ function mapPrimaryAxisAlignment(value: string | undefined): string {
170
+ switch (value) {
171
+ case "CENTER":
172
+ return "center";
173
+ case "MAX":
174
+ return "flex-end";
175
+ case "SPACE_BETWEEN":
176
+ return "space-between";
177
+ default:
178
+ return "flex-start";
179
+ }
180
+ }
181
+
182
+ function mapCounterAxisAlignment(value: string | undefined): string {
183
+ switch (value) {
184
+ case "CENTER":
185
+ return "center";
186
+ case "MAX":
187
+ return "flex-end";
188
+ case "BASELINE":
189
+ return "baseline";
190
+ default:
191
+ return "flex-start";
192
+ }
193
+ }
194
+
195
+ function mapImageScaleMode(value: string | undefined): string {
196
+ switch (value) {
197
+ case "FIT":
198
+ return "contain";
199
+ case "TILE":
200
+ return "auto";
201
+ default:
202
+ return "cover";
203
+ }
204
+ }
205
+
206
+ function toRgba(
207
+ color: { r: number; g: number; b: number },
208
+ opacity: number,
209
+ ): string {
210
+ return `rgba(${Math.round(color.r * 255)}, ${Math.round(color.g * 255)}, ${Math.round(color.b * 255)}, ${opacity})`;
211
+ }
@@ -0,0 +1,41 @@
1
+ import type { FigmaCompiler, FigmaNode } from "../types.ts";
2
+ import { escapeHtml, getNodeStyles, toCssText } from "./compilerUtils.ts";
3
+
4
+ export const compileHtml: FigmaCompiler = (node) => [
5
+ {
6
+ path: "index.html",
7
+ contents: `<!doctype html>
8
+ <html lang="en">
9
+ <head>
10
+ <meta charset="UTF-8" />
11
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
12
+ <title>${escapeHtml(node.name || "Figma Export")}</title>
13
+ </head>
14
+ <body>
15
+ ${walkHtml(node, undefined, 2).trimEnd()}
16
+ </body>
17
+ </html>
18
+ `,
19
+ },
20
+ ];
21
+
22
+ function walkHtml(node: FigmaNode, parent?: FigmaNode, depth = 0): string {
23
+ if (!node || node.visible === false) return "";
24
+
25
+ const indent = " ".repeat(depth);
26
+ const childIndent = " ".repeat(depth + 1);
27
+ const name = escapeHtml(node.name || "LayoutBox");
28
+ const style = escapeHtml(toCssText(getNodeStyles(node, parent)));
29
+
30
+ if (node.type === "TEXT") {
31
+ return `${indent}<span style="${style}" data-layer="${name}">
32
+ ${childIndent}${escapeHtml(node.characters)}
33
+ ${indent}</span>\n`;
34
+ }
35
+
36
+ const children =
37
+ node.children?.map((child) => walkHtml(child, node, depth + 1)).join("") ||
38
+ "";
39
+ return `${indent}<div style="${style}" data-layer="${name}">
40
+ ${children}${indent}</div>\n`;
41
+ }
@@ -0,0 +1,24 @@
1
+ import type { CompilerMode, FigmaCompiler } from "../types.ts";
2
+ import { compileHtml } from "./htmlCompiler.ts";
3
+ import { compileReact } from "./reactCompiler.ts";
4
+ import { compileVanjs } from "./vanjsCompiler.ts";
5
+
6
+ export { compileHtml } from "./htmlCompiler.ts";
7
+ export { compileReact } from "./reactCompiler.ts";
8
+ export { compileVanjs } from "./vanjsCompiler.ts";
9
+
10
+ export const compilers: Record<CompilerMode, FigmaCompiler> = {
11
+ react: compileReact,
12
+ html: compileHtml,
13
+ vanjs: compileVanjs,
14
+ };
15
+
16
+ export function isCompilerMode(
17
+ value: string | undefined,
18
+ ): value is CompilerMode {
19
+ return Boolean(value && value in compilers);
20
+ }
21
+
22
+ export function getCompiler(mode: CompilerMode): FigmaCompiler {
23
+ return compilers[mode];
24
+ }
@@ -0,0 +1,79 @@
1
+ import type { FigmaCompiler, FigmaNode } from "../types.ts";
2
+ import {
3
+ escapeHtml,
4
+ escapeJsString,
5
+ getNodeStyles,
6
+ toComponentName,
7
+ toReactStyle,
8
+ } from "./compilerUtils.ts";
9
+
10
+ export const compileReact: FigmaCompiler = (node) => {
11
+ const componentName = toComponentName(node.name);
12
+
13
+ return [
14
+ {
15
+ path: "FigmaComponent.tsx",
16
+ contents: `import React from 'react';
17
+
18
+ /**
19
+ * Auto-generated UI component from Figma frame: "${escapeHtml(node.name)}"
20
+ */
21
+ export const ${componentName}: React.FC = () => {
22
+ return (
23
+ ${walkReact(node).trimEnd()}
24
+ );
25
+ };
26
+
27
+ export default ${componentName};
28
+ `,
29
+ },
30
+ ];
31
+ };
32
+
33
+ function walkReact(node: FigmaNode, depth = 2): string {
34
+ if (!node || node.visible === false) return "";
35
+
36
+ const indent = " ".repeat(depth);
37
+ const childIndent = " ".repeat(depth + 1);
38
+ const name = escapeHtml(node.name || "LayoutBox");
39
+ const styles = toReactStyle(getNodeStyles(node));
40
+
41
+ if (node.type === "TEXT") {
42
+ return `${indent}<span style={${styles}} data-layer={${escapeJsString(node.name || "LayoutBox")}}>
43
+ ${childIndent}{${escapeJsString(node.characters)}}
44
+ ${indent}</span>\n`;
45
+ }
46
+
47
+ const children =
48
+ node.children
49
+ ?.map((child) => walkReactWithParent(child, node, depth + 1))
50
+ .join("") || "";
51
+ return `${indent}<div style={${styles}} data-layer="${name}">
52
+ ${children}${indent}</div>\n`;
53
+ }
54
+
55
+ function walkReactWithParent(
56
+ node: FigmaNode,
57
+ parent: FigmaNode,
58
+ depth: number,
59
+ ): string {
60
+ if (!node || node.visible === false) return "";
61
+
62
+ const indent = " ".repeat(depth);
63
+ const childIndent = " ".repeat(depth + 1);
64
+ const name = escapeHtml(node.name || "LayoutBox");
65
+ const styles = toReactStyle(getNodeStyles(node, parent));
66
+
67
+ if (node.type === "TEXT") {
68
+ return `${indent}<span style={${styles}} data-layer={${escapeJsString(node.name || "LayoutBox")}}>
69
+ ${childIndent}{${escapeJsString(node.characters)}}
70
+ ${indent}</span>\n`;
71
+ }
72
+
73
+ const children =
74
+ node.children
75
+ ?.map((child) => walkReactWithParent(child, node, depth + 1))
76
+ .join("") || "";
77
+ return `${indent}<div style={${styles}} data-layer="${name}">
78
+ ${children}${indent}</div>\n`;
79
+ }
@@ -0,0 +1,67 @@
1
+ import type { FigmaCompiler, FigmaNode } from "../types.ts";
2
+ import {
3
+ escapeHtml,
4
+ escapeJsString,
5
+ getNodeStyles,
6
+ toCssText,
7
+ } from "./compilerUtils.ts";
8
+
9
+ export const compileVanjs: FigmaCompiler = (node) => [
10
+ {
11
+ path: "index.html",
12
+ contents: `<!doctype html>
13
+ <html lang="en">
14
+ <head>
15
+ <meta charset="UTF-8" />
16
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
17
+ <title>${escapeHtml(node.name || "Figma Export")}</title>
18
+ </head>
19
+ <body>
20
+ <div id="app"></div>
21
+ <script type="module" src="./main.js"></script>
22
+ </body>
23
+ </html>
24
+ `,
25
+ },
26
+ {
27
+ path: "main.js",
28
+ contents: `import van from 'https://cdn.jsdelivr.net/npm/vanjs-core@1.5.5/src/van.min.js';
29
+
30
+ const { div, span } = van.tags;
31
+
32
+ const App = () => (
33
+ ${walkVanjs(node, undefined, 1).trimEnd()}
34
+ );
35
+
36
+ van.add(document.getElementById('app'), App());
37
+ `,
38
+ },
39
+ ];
40
+
41
+ function walkVanjs(node: FigmaNode, parent?: FigmaNode, depth = 0): string {
42
+ if (!node || node.visible === false) return "null";
43
+
44
+ const indent = " ".repeat(depth);
45
+ const childIndent = " ".repeat(depth + 1);
46
+ const tag = node.type === "TEXT" ? "span" : "div";
47
+ const props = `{
48
+ ${childIndent}style: ${escapeJsString(toCssText(getNodeStyles(node, parent)))},
49
+ ${childIndent}"data-layer": ${escapeJsString(node.name || "LayoutBox")}
50
+ ${indent}}`;
51
+
52
+ if (node.type === "TEXT") {
53
+ return `${indent}${tag}(${props}, ${escapeJsString(node.characters)})\n`;
54
+ }
55
+
56
+ const children = (node.children || [])
57
+ .map((child) => walkVanjs(child, node, depth + 1))
58
+ .filter((value) => value.trim() !== "null")
59
+ .join(",");
60
+
61
+ if (!children) {
62
+ return `${indent}${tag}(${props})\n`;
63
+ }
64
+
65
+ return `${indent}${tag}(${props},
66
+ ${children}${indent})\n`;
67
+ }
@@ -0,0 +1,142 @@
1
+ import type { ExtractedParams, FigmaNode } from "../types.ts";
2
+
3
+ interface FigmaFileResponse {
4
+ document?: FigmaNode;
5
+ nodes?: Record<string, { document?: FigmaNode }>;
6
+ }
7
+
8
+ interface FigmaImageFillsResponse {
9
+ images?: Record<string, string | null>;
10
+ }
11
+
12
+ export type FigmaApiResponse = unknown;
13
+
14
+ export type FigmaFetcher = (
15
+ input: string,
16
+ init?: RequestInit,
17
+ ) => Promise<Response>;
18
+
19
+ export interface FigmaClientOptions {
20
+ token: string;
21
+ fetcher?: FigmaFetcher;
22
+ }
23
+
24
+ export function extractParamsFromURL(input: string): ExtractedParams {
25
+ const cleanInput = input.trim();
26
+ const fileKeyPattern = /figma\.com\/(?:file|design)\/([^/]+)/;
27
+ const fileKeyMatch = cleanInput.match(fileKeyPattern);
28
+ const fileKey = fileKeyMatch?.[1] || cleanInput;
29
+
30
+ let nodeId: string | null = null;
31
+ try {
32
+ if (cleanInput.includes("figma.com")) {
33
+ const url = new URL(cleanInput);
34
+ const rawNodeId = url.searchParams.get("node-id");
35
+ if (rawNodeId) {
36
+ nodeId = rawNodeId.replace(/-/g, ":");
37
+ }
38
+ }
39
+ } catch {
40
+ // Treat non-URL input as a raw Figma file key.
41
+ }
42
+
43
+ return { fileKey, nodeId };
44
+ }
45
+
46
+ export async function fetchFigmaNode(
47
+ fileKey: string,
48
+ nodeId: string | null,
49
+ options: FigmaClientOptions,
50
+ ): Promise<FigmaNode> {
51
+ const data = (await fetchFigmaApiResponse(
52
+ fileKey,
53
+ nodeId,
54
+ options,
55
+ )) as FigmaFileResponse;
56
+ const rootNode = nodeId ? data.nodes?.[nodeId]?.document : data.document;
57
+
58
+ if (!rootNode) {
59
+ throw new Error(`Could not find valid element tree for Node ID: ${nodeId}`);
60
+ }
61
+
62
+ const imageFills = await fetchFigmaImageFills(fileKey, options);
63
+ attachImageFillUrls(rootNode, imageFills);
64
+
65
+ return rootNode;
66
+ }
67
+
68
+ export async function fetchFigmaApiResponse(
69
+ fileKey: string,
70
+ nodeId: string | null,
71
+ options: FigmaClientOptions,
72
+ ): Promise<FigmaApiResponse> {
73
+ const endpoint = buildFigmaNodeEndpoint(fileKey, nodeId);
74
+ const fetcher = options.fetcher ?? fetch;
75
+ const response = await fetcher(endpoint, {
76
+ method: "GET",
77
+ headers: { "X-Figma-Token": options.token },
78
+ });
79
+
80
+ if (!response.ok) {
81
+ throw new Error(
82
+ `Figma API Error: ${response.status} ${response.statusText}`,
83
+ );
84
+ }
85
+
86
+ return response.json();
87
+ }
88
+
89
+ export function buildFigmaNodeEndpoint(
90
+ fileKey: string,
91
+ nodeId: string | null,
92
+ ): string {
93
+ if (!nodeId) {
94
+ return `https://api.figma.com/v1/files/${fileKey}?depth=2`;
95
+ }
96
+
97
+ return `https://api.figma.com/v1/files/${fileKey}/nodes?ids=${encodeURIComponent(nodeId)}`;
98
+ }
99
+
100
+ export async function fetchFigmaImageFills(
101
+ fileKey: string,
102
+ options: FigmaClientOptions,
103
+ ): Promise<Record<string, string>> {
104
+ const endpoint = buildFigmaImageFillsEndpoint(fileKey);
105
+ const fetcher = options.fetcher ?? fetch;
106
+ const response = await fetcher(endpoint, {
107
+ method: "GET",
108
+ headers: { "X-Figma-Token": options.token },
109
+ });
110
+
111
+ if (!response.ok) {
112
+ throw new Error(
113
+ `Figma image fills API Error: ${response.status} ${response.statusText}`,
114
+ );
115
+ }
116
+
117
+ const data = (await response.json()) as FigmaImageFillsResponse;
118
+ return Object.fromEntries(
119
+ Object.entries(data.images || {}).filter(
120
+ (entry): entry is [string, string] => typeof entry[1] === "string",
121
+ ),
122
+ );
123
+ }
124
+
125
+ export function buildFigmaImageFillsEndpoint(fileKey: string): string {
126
+ return `https://api.figma.com/v1/files/${fileKey}/images`;
127
+ }
128
+
129
+ function attachImageFillUrls(
130
+ node: FigmaNode,
131
+ imageFills: Record<string, string>,
132
+ ): void {
133
+ for (const fill of node.fills || []) {
134
+ if (fill.type === "IMAGE" && fill.imageRef) {
135
+ fill.imageUrl = imageFills[fill.imageRef];
136
+ }
137
+ }
138
+
139
+ for (const child of node.children || []) {
140
+ attachImageFillUrls(child, imageFills);
141
+ }
142
+ }