@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
package/LICENSE ADDED
@@ -0,0 +1,9 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Jin-Woo Lee
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
6
+
7
+ The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
8
+
9
+ THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,5 @@
1
+ # @design-embed/plugin-figma-html
2
+
3
+ The official Figma source plugin for design-embed.
4
+
5
+ It fetches an explicitly requested Figma node, downloads referenced image fills, and converts the design payload into raw HTML that can be passed into the local compiler. This package is intentionally separate from the core compiler because Figma access may require network calls and credentials, while core compilation stays local and deterministic.
@@ -0,0 +1,182 @@
1
+ export function toComponentName(name, fallback = "FigmaExport") {
2
+ const cleaned = (name || "").replace(/[^a-zA-Z0-9가-힣]/g, "");
3
+ if (!cleaned)
4
+ return fallback;
5
+ return /^[0-9]/.test(cleaned) ? `${fallback}${cleaned}` : cleaned;
6
+ }
7
+ export function escapeHtml(value) {
8
+ return (value || "")
9
+ .replace(/&/g, "&")
10
+ .replace(/</g, "&lt;")
11
+ .replace(/>/g, "&gt;")
12
+ .replace(/"/g, "&quot;")
13
+ .replace(/'/g, "&#39;");
14
+ }
15
+ export function escapeJsString(value) {
16
+ return JSON.stringify(value || "");
17
+ }
18
+ export function getNodeStyles(node, parent) {
19
+ const styles = {};
20
+ const bounds = node.absoluteBoundingBox;
21
+ const parentBounds = parent?.absoluteBoundingBox;
22
+ const parentUsesLayout = Boolean(parent?.layoutMode && parent.layoutMode !== "NONE");
23
+ const isAbsoluteChild = Boolean(parent && (!parentUsesLayout || node.layoutPositioning === "ABSOLUTE"));
24
+ if (isAbsoluteChild && bounds && parentBounds) {
25
+ styles.position = "absolute";
26
+ styles.left = `${Math.round((bounds.x || 0) - (parentBounds.x || 0))}px`;
27
+ styles.top = `${Math.round((bounds.y || 0) - (parentBounds.y || 0))}px`;
28
+ }
29
+ else if (node.children?.length || node.layoutMode === "NONE") {
30
+ styles.position = "relative";
31
+ }
32
+ if (bounds && node.layoutSizingHorizontal !== "HUG") {
33
+ styles.width = `${Math.round(bounds.width || 0)}px`;
34
+ }
35
+ if (bounds && node.layoutSizingVertical !== "HUG") {
36
+ styles.height = `${Math.round(bounds.height || 0)}px`;
37
+ }
38
+ if (node.layoutMode === "HORIZONTAL" || node.layoutMode === "VERTICAL") {
39
+ styles.display = "flex";
40
+ styles.flexDirection = node.layoutMode === "HORIZONTAL" ? "row" : "column";
41
+ styles.boxSizing = "border-box";
42
+ if (node.layoutWrap === "WRAP")
43
+ styles.flexWrap = "wrap";
44
+ if (node.itemSpacing !== undefined)
45
+ styles.gap = `${Math.round(node.itemSpacing)}px`;
46
+ if (node.counterAxisSpacing !== undefined && node.layoutWrap === "WRAP") {
47
+ styles.rowGap = `${Math.round(node.counterAxisSpacing)}px`;
48
+ }
49
+ styles.justifyContent = mapPrimaryAxisAlignment(node.primaryAxisAlignItems);
50
+ styles.alignItems = mapCounterAxisAlignment(node.counterAxisAlignItems);
51
+ }
52
+ else if (node.layoutMode === "GRID") {
53
+ styles.display = "grid";
54
+ styles.boxSizing = "border-box";
55
+ if (node.gridColumnsSizing)
56
+ styles.gridTemplateColumns = node.gridColumnsSizing;
57
+ if (node.gridRowsSizing)
58
+ styles.gridTemplateRows = node.gridRowsSizing;
59
+ if (node.gridColumnGap !== undefined)
60
+ styles.columnGap = `${Math.round(node.gridColumnGap)}px`;
61
+ if (node.gridRowGap !== undefined)
62
+ styles.rowGap = `${Math.round(node.gridRowGap)}px`;
63
+ }
64
+ if (node.layoutSizingHorizontal === "FILL" || node.layoutGrow === 1) {
65
+ styles.flex = 1;
66
+ styles.width = "100%";
67
+ }
68
+ if (node.layoutSizingVertical === "FILL") {
69
+ styles.height = "100%";
70
+ }
71
+ if (node.layoutAlign === "STRETCH") {
72
+ styles.alignSelf = "stretch";
73
+ }
74
+ if (node.gridColumnSpan && node.gridColumnSpan > 1) {
75
+ styles.gridColumn = `span ${node.gridColumnSpan}`;
76
+ }
77
+ if (node.gridRowSpan && node.gridRowSpan > 1) {
78
+ styles.gridRow = `span ${node.gridRowSpan}`;
79
+ }
80
+ if (node.paddingTop !== undefined)
81
+ styles.paddingTop = `${node.paddingTop}px`;
82
+ if (node.paddingBottom !== undefined)
83
+ styles.paddingBottom = `${node.paddingBottom}px`;
84
+ if (node.paddingLeft !== undefined)
85
+ styles.paddingLeft = `${node.paddingLeft}px`;
86
+ if (node.paddingRight !== undefined)
87
+ styles.paddingRight = `${node.paddingRight}px`;
88
+ if (node.clipsContent)
89
+ styles.overflow = "hidden";
90
+ if (node.opacity !== undefined && node.opacity !== 1)
91
+ styles.opacity = node.opacity;
92
+ const fill = node.fills?.find((item) => item.type === "SOLID" && item.color);
93
+ if (fill?.color) {
94
+ if (node.type === "TEXT") {
95
+ styles.color = toRgba(fill.color, fill.opacity ?? 1);
96
+ }
97
+ else {
98
+ styles.backgroundColor = toRgba(fill.color, fill.opacity ?? 1);
99
+ }
100
+ }
101
+ const imageFill = node.fills?.find((item) => item.type === "IMAGE" && (item.imageLocalPath || item.imageUrl));
102
+ const imageSource = imageFill?.imageLocalPath || imageFill?.imageUrl;
103
+ if (imageSource) {
104
+ styles.backgroundImage = `url("${imageSource}")`;
105
+ styles.backgroundRepeat = "no-repeat";
106
+ styles.backgroundPosition = "center";
107
+ styles.backgroundSize = mapImageScaleMode(imageFill.scaleMode);
108
+ }
109
+ const stroke = node.strokes?.find((item) => item.type === "SOLID" && item.color);
110
+ if (stroke?.color && node.strokeWeight) {
111
+ styles.border = `${Math.round(node.strokeWeight)}px solid ${toRgba(stroke.color, stroke.opacity ?? 1)}`;
112
+ styles.boxSizing = "border-box";
113
+ }
114
+ if (node.rectangleCornerRadii?.length === 4) {
115
+ styles.borderRadius = node.rectangleCornerRadii
116
+ .map((radius) => `${Math.round(radius)}px`)
117
+ .join(" ");
118
+ }
119
+ else if (node.cornerRadius) {
120
+ styles.borderRadius = `${Math.round(node.cornerRadius)}px`;
121
+ }
122
+ if (node.type === "TEXT") {
123
+ const textStyle = node.style || {};
124
+ if (textStyle.fontSize)
125
+ styles.fontSize = `${textStyle.fontSize}px`;
126
+ if (textStyle.fontWeight)
127
+ styles.fontWeight = textStyle.fontWeight;
128
+ if (textStyle.fontFamily)
129
+ styles.fontFamily = `"${textStyle.fontFamily}", sans-serif`;
130
+ if (textStyle.lineHeightPx)
131
+ styles.lineHeight = `${Math.round(textStyle.lineHeightPx)}px`;
132
+ }
133
+ return styles;
134
+ }
135
+ export function toReactStyle(styles) {
136
+ return JSON.stringify(styles).replace(/"([^"]+)":/g, "$1:");
137
+ }
138
+ export function toCssText(styles) {
139
+ return Object.entries(styles)
140
+ .map(([key, value]) => `${toKebabCase(key)}: ${value};`)
141
+ .join(" ");
142
+ }
143
+ function toKebabCase(value) {
144
+ return value.replace(/[A-Z]/g, (match) => `-${match.toLowerCase()}`);
145
+ }
146
+ function mapPrimaryAxisAlignment(value) {
147
+ switch (value) {
148
+ case "CENTER":
149
+ return "center";
150
+ case "MAX":
151
+ return "flex-end";
152
+ case "SPACE_BETWEEN":
153
+ return "space-between";
154
+ default:
155
+ return "flex-start";
156
+ }
157
+ }
158
+ function mapCounterAxisAlignment(value) {
159
+ switch (value) {
160
+ case "CENTER":
161
+ return "center";
162
+ case "MAX":
163
+ return "flex-end";
164
+ case "BASELINE":
165
+ return "baseline";
166
+ default:
167
+ return "flex-start";
168
+ }
169
+ }
170
+ function mapImageScaleMode(value) {
171
+ switch (value) {
172
+ case "FIT":
173
+ return "contain";
174
+ case "TILE":
175
+ return "auto";
176
+ default:
177
+ return "cover";
178
+ }
179
+ }
180
+ function toRgba(color, opacity) {
181
+ return `rgba(${Math.round(color.r * 255)}, ${Math.round(color.g * 255)}, ${Math.round(color.b * 255)}, ${opacity})`;
182
+ }
@@ -0,0 +1,35 @@
1
+ import { escapeHtml, getNodeStyles, toCssText } from "./compilerUtils.js";
2
+ export const compileHtml = (node) => [
3
+ {
4
+ path: "index.html",
5
+ contents: `<!doctype html>
6
+ <html lang="en">
7
+ <head>
8
+ <meta charset="UTF-8" />
9
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
10
+ <title>${escapeHtml(node.name || "Figma Export")}</title>
11
+ </head>
12
+ <body>
13
+ ${walkHtml(node, undefined, 2).trimEnd()}
14
+ </body>
15
+ </html>
16
+ `,
17
+ },
18
+ ];
19
+ function walkHtml(node, parent, depth = 0) {
20
+ if (!node || node.visible === false)
21
+ return "";
22
+ const indent = " ".repeat(depth);
23
+ const childIndent = " ".repeat(depth + 1);
24
+ const name = escapeHtml(node.name || "LayoutBox");
25
+ const style = escapeHtml(toCssText(getNodeStyles(node, parent)));
26
+ if (node.type === "TEXT") {
27
+ return `${indent}<span style="${style}" data-layer="${name}">
28
+ ${childIndent}${escapeHtml(node.characters)}
29
+ ${indent}</span>\n`;
30
+ }
31
+ const children = node.children?.map((child) => walkHtml(child, node, depth + 1)).join("") ||
32
+ "";
33
+ return `${indent}<div style="${style}" data-layer="${name}">
34
+ ${children}${indent}</div>\n`;
35
+ }
@@ -0,0 +1,17 @@
1
+ import { compileHtml } from "./htmlCompiler.js";
2
+ import { compileReact } from "./reactCompiler.js";
3
+ import { compileVanjs } from "./vanjsCompiler.js";
4
+ export { compileHtml } from "./htmlCompiler.js";
5
+ export { compileReact } from "./reactCompiler.js";
6
+ export { compileVanjs } from "./vanjsCompiler.js";
7
+ export const compilers = {
8
+ react: compileReact,
9
+ html: compileHtml,
10
+ vanjs: compileVanjs,
11
+ };
12
+ export function isCompilerMode(value) {
13
+ return Boolean(value && value in compilers);
14
+ }
15
+ export function getCompiler(mode) {
16
+ return compilers[mode];
17
+ }
@@ -0,0 +1,58 @@
1
+ import { escapeHtml, escapeJsString, getNodeStyles, toComponentName, toReactStyle, } from "./compilerUtils.js";
2
+ export const compileReact = (node) => {
3
+ const componentName = toComponentName(node.name);
4
+ return [
5
+ {
6
+ path: "FigmaComponent.tsx",
7
+ contents: `import React from 'react';
8
+
9
+ /**
10
+ * Auto-generated UI component from Figma frame: "${escapeHtml(node.name)}"
11
+ */
12
+ export const ${componentName}: React.FC = () => {
13
+ return (
14
+ ${walkReact(node).trimEnd()}
15
+ );
16
+ };
17
+
18
+ export default ${componentName};
19
+ `,
20
+ },
21
+ ];
22
+ };
23
+ function walkReact(node, depth = 2) {
24
+ if (!node || node.visible === false)
25
+ return "";
26
+ const indent = " ".repeat(depth);
27
+ const childIndent = " ".repeat(depth + 1);
28
+ const name = escapeHtml(node.name || "LayoutBox");
29
+ const styles = toReactStyle(getNodeStyles(node));
30
+ if (node.type === "TEXT") {
31
+ return `${indent}<span style={${styles}} data-layer={${escapeJsString(node.name || "LayoutBox")}}>
32
+ ${childIndent}{${escapeJsString(node.characters)}}
33
+ ${indent}</span>\n`;
34
+ }
35
+ const children = node.children
36
+ ?.map((child) => walkReactWithParent(child, node, depth + 1))
37
+ .join("") || "";
38
+ return `${indent}<div style={${styles}} data-layer="${name}">
39
+ ${children}${indent}</div>\n`;
40
+ }
41
+ function walkReactWithParent(node, parent, depth) {
42
+ if (!node || node.visible === false)
43
+ return "";
44
+ const indent = " ".repeat(depth);
45
+ const childIndent = " ".repeat(depth + 1);
46
+ const name = escapeHtml(node.name || "LayoutBox");
47
+ const styles = toReactStyle(getNodeStyles(node, parent));
48
+ if (node.type === "TEXT") {
49
+ return `${indent}<span style={${styles}} data-layer={${escapeJsString(node.name || "LayoutBox")}}>
50
+ ${childIndent}{${escapeJsString(node.characters)}}
51
+ ${indent}</span>\n`;
52
+ }
53
+ const children = node.children
54
+ ?.map((child) => walkReactWithParent(child, node, depth + 1))
55
+ .join("") || "";
56
+ return `${indent}<div style={${styles}} data-layer="${name}">
57
+ ${children}${indent}</div>\n`;
58
+ }
@@ -0,0 +1,55 @@
1
+ import { escapeHtml, escapeJsString, getNodeStyles, toCssText, } from "./compilerUtils.js";
2
+ export const compileVanjs = (node) => [
3
+ {
4
+ path: "index.html",
5
+ contents: `<!doctype html>
6
+ <html lang="en">
7
+ <head>
8
+ <meta charset="UTF-8" />
9
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
10
+ <title>${escapeHtml(node.name || "Figma Export")}</title>
11
+ </head>
12
+ <body>
13
+ <div id="app"></div>
14
+ <script type="module" src="./main.js"></script>
15
+ </body>
16
+ </html>
17
+ `,
18
+ },
19
+ {
20
+ path: "main.js",
21
+ contents: `import van from 'https://cdn.jsdelivr.net/npm/vanjs-core@1.5.5/src/van.min.js';
22
+
23
+ const { div, span } = van.tags;
24
+
25
+ const App = () => (
26
+ ${walkVanjs(node, undefined, 1).trimEnd()}
27
+ );
28
+
29
+ van.add(document.getElementById('app'), App());
30
+ `,
31
+ },
32
+ ];
33
+ function walkVanjs(node, parent, depth = 0) {
34
+ if (!node || node.visible === false)
35
+ return "null";
36
+ const indent = " ".repeat(depth);
37
+ const childIndent = " ".repeat(depth + 1);
38
+ const tag = node.type === "TEXT" ? "span" : "div";
39
+ const props = `{
40
+ ${childIndent}style: ${escapeJsString(toCssText(getNodeStyles(node, parent)))},
41
+ ${childIndent}"data-layer": ${escapeJsString(node.name || "LayoutBox")}
42
+ ${indent}}`;
43
+ if (node.type === "TEXT") {
44
+ return `${indent}${tag}(${props}, ${escapeJsString(node.characters)})\n`;
45
+ }
46
+ const children = (node.children || [])
47
+ .map((child) => walkVanjs(child, node, depth + 1))
48
+ .filter((value) => value.trim() !== "null")
49
+ .join(",");
50
+ if (!children) {
51
+ return `${indent}${tag}(${props})\n`;
52
+ }
53
+ return `${indent}${tag}(${props},
54
+ ${children}${indent})\n`;
55
+ }
@@ -0,0 +1,74 @@
1
+ export function extractParamsFromURL(input) {
2
+ const cleanInput = input.trim();
3
+ const fileKeyPattern = /figma\.com\/(?:file|design)\/([^/]+)/;
4
+ const fileKeyMatch = cleanInput.match(fileKeyPattern);
5
+ const fileKey = fileKeyMatch?.[1] || cleanInput;
6
+ let nodeId = null;
7
+ try {
8
+ if (cleanInput.includes("figma.com")) {
9
+ const url = new URL(cleanInput);
10
+ const rawNodeId = url.searchParams.get("node-id");
11
+ if (rawNodeId) {
12
+ nodeId = rawNodeId.replace(/-/g, ":");
13
+ }
14
+ }
15
+ }
16
+ catch {
17
+ // Treat non-URL input as a raw Figma file key.
18
+ }
19
+ return { fileKey, nodeId };
20
+ }
21
+ export async function fetchFigmaNode(fileKey, nodeId, options) {
22
+ const data = (await fetchFigmaApiResponse(fileKey, nodeId, options));
23
+ const rootNode = nodeId ? data.nodes?.[nodeId]?.document : data.document;
24
+ if (!rootNode) {
25
+ throw new Error(`Could not find valid element tree for Node ID: ${nodeId}`);
26
+ }
27
+ const imageFills = await fetchFigmaImageFills(fileKey, options);
28
+ attachImageFillUrls(rootNode, imageFills);
29
+ return rootNode;
30
+ }
31
+ export async function fetchFigmaApiResponse(fileKey, nodeId, options) {
32
+ const endpoint = buildFigmaNodeEndpoint(fileKey, nodeId);
33
+ const fetcher = options.fetcher ?? fetch;
34
+ const response = await fetcher(endpoint, {
35
+ method: "GET",
36
+ headers: { "X-Figma-Token": options.token },
37
+ });
38
+ if (!response.ok) {
39
+ throw new Error(`Figma API Error: ${response.status} ${response.statusText}`);
40
+ }
41
+ return response.json();
42
+ }
43
+ export function buildFigmaNodeEndpoint(fileKey, nodeId) {
44
+ if (!nodeId) {
45
+ return `https://api.figma.com/v1/files/${fileKey}?depth=2`;
46
+ }
47
+ return `https://api.figma.com/v1/files/${fileKey}/nodes?ids=${encodeURIComponent(nodeId)}`;
48
+ }
49
+ export async function fetchFigmaImageFills(fileKey, options) {
50
+ const endpoint = buildFigmaImageFillsEndpoint(fileKey);
51
+ const fetcher = options.fetcher ?? fetch;
52
+ const response = await fetcher(endpoint, {
53
+ method: "GET",
54
+ headers: { "X-Figma-Token": options.token },
55
+ });
56
+ if (!response.ok) {
57
+ throw new Error(`Figma image fills API Error: ${response.status} ${response.statusText}`);
58
+ }
59
+ const data = (await response.json());
60
+ return Object.fromEntries(Object.entries(data.images || {}).filter((entry) => typeof entry[1] === "string"));
61
+ }
62
+ export function buildFigmaImageFillsEndpoint(fileKey) {
63
+ return `https://api.figma.com/v1/files/${fileKey}/images`;
64
+ }
65
+ function attachImageFillUrls(node, imageFills) {
66
+ for (const fill of node.fills || []) {
67
+ if (fill.type === "IMAGE" && fill.imageRef) {
68
+ fill.imageUrl = imageFills[fill.imageRef];
69
+ }
70
+ }
71
+ for (const child of node.children || []) {
72
+ attachImageFillUrls(child, imageFills);
73
+ }
74
+ }
@@ -0,0 +1,82 @@
1
+ import { mkdirSync, writeFileSync } from "node:fs";
2
+ import { join, posix } from "node:path";
3
+ export async function downloadFigmaImageFills(rootNode, outDir, options = {}) {
4
+ const targets = collectImageFillTargets(rootNode);
5
+ const uniqueTargets = Array.from(new Map(targets.map((target) => [target.imageRef, target])).values());
6
+ if (uniqueTargets.length === 0)
7
+ return [];
8
+ mkdirSync(outDir, { recursive: true });
9
+ const downloadedImages = await Promise.all(uniqueTargets.map((target) => downloadImageFill(target, outDir, options)));
10
+ const publicPathByRef = new Map(downloadedImages.map((image) => [image.imageRef, image.publicPath]));
11
+ for (const target of targets) {
12
+ const fill = target.fills[target.fillIndex];
13
+ if (fill) {
14
+ fill.imageLocalPath = publicPathByRef.get(target.imageRef);
15
+ }
16
+ }
17
+ return downloadedImages;
18
+ }
19
+ function collectImageFillTargets(node) {
20
+ const targets = [];
21
+ node.fills?.forEach((fill, fillIndex, fills) => {
22
+ if (fill.type === "IMAGE" && fill.imageRef && fill.imageUrl) {
23
+ targets.push({
24
+ imageRef: fill.imageRef,
25
+ imageUrl: fill.imageUrl,
26
+ fills,
27
+ fillIndex,
28
+ });
29
+ }
30
+ });
31
+ for (const child of node.children || []) {
32
+ targets.push(...collectImageFillTargets(child));
33
+ }
34
+ return targets;
35
+ }
36
+ async function downloadImageFill(target, outDir, options) {
37
+ const fetcher = options.fetcher ?? fetch;
38
+ const response = await fetcher(target.imageUrl);
39
+ if (!response.ok) {
40
+ throw new Error(`Figma image download failed for ${target.imageRef}: ${response.status} ${response.statusText}`);
41
+ }
42
+ const extension = extensionFromResponse(response, target.imageUrl);
43
+ const filename = `${sanitizeFilename(target.imageRef)}.${extension}`;
44
+ const filePath = join(outDir, filename);
45
+ const publicPath = posix.join(options.publicPath || outDir, filename);
46
+ writeFileSync(filePath, Buffer.from(await response.arrayBuffer()));
47
+ return {
48
+ imageRef: target.imageRef,
49
+ sourceUrl: target.imageUrl,
50
+ filePath,
51
+ publicPath,
52
+ };
53
+ }
54
+ function extensionFromResponse(response, url) {
55
+ const contentType = response.headers.get("content-type")?.split(";")[0];
56
+ switch (contentType) {
57
+ case "image/jpeg":
58
+ return "jpg";
59
+ case "image/png":
60
+ return "png";
61
+ case "image/svg+xml":
62
+ return "svg";
63
+ case "image/webp":
64
+ return "webp";
65
+ case "image/gif":
66
+ return "gif";
67
+ }
68
+ const pathname = safeUrlPathname(url);
69
+ const extension = pathname.match(/\.([a-z0-9]+)$/i)?.[1]?.toLowerCase();
70
+ return extension || "img";
71
+ }
72
+ function safeUrlPathname(url) {
73
+ try {
74
+ return new URL(url).pathname;
75
+ }
76
+ catch {
77
+ return url;
78
+ }
79
+ }
80
+ function sanitizeFilename(value) {
81
+ return value.replace(/[^a-zA-Z0-9_-]/g, "_");
82
+ }
package/dist/index.js ADDED
@@ -0,0 +1,3 @@
1
+ export { compileHtml, compileReact, compileVanjs, getCompiler, isCompilerMode, } from "./compilers/index.js";
2
+ export { extractParamsFromURL, fetchFigmaApiResponse, fetchFigmaNode, } from "./external/figmaApi.js";
3
+ export { FigmaHtmlPlugin } from "./plugin.js";
package/dist/plugin.js ADDED
@@ -0,0 +1,56 @@
1
+ import { join } from "node:path";
2
+ import { compileHtml } from "./compilers/index.js";
3
+ import { extractParamsFromURL, fetchFigmaNode } from "./external/figmaApi.js";
4
+ import { downloadFigmaImageFills } from "./external/imageDownloader.js";
5
+ export class FigmaHtmlPlugin {
6
+ options;
7
+ name = "figma-html";
8
+ constructor(options) {
9
+ this.options = options;
10
+ }
11
+ async run(input) {
12
+ const { url, token: optionsToken, assetsDir = "assets" } = this.options;
13
+ const token = optionsToken ?? process.env.FIGMA_TOKEN;
14
+ if (!token) {
15
+ return {
16
+ diagnostics: [
17
+ {
18
+ code: "FIGMA_TOKEN_REQUIRED",
19
+ message: "figma-html requires a Figma token. Pass token in the FigmaHtmlPlugin constructor or set the FIGMA_TOKEN environment variable.",
20
+ severity: "error",
21
+ },
22
+ ],
23
+ };
24
+ }
25
+ try {
26
+ const { fileKey, nodeId } = extractParamsFromURL(url);
27
+ const rootNode = await fetchFigmaNode(fileKey, nodeId, { token });
28
+ const downloadedImages = await downloadFigmaImageFills(rootNode, join(input.cwd, assetsDir), { publicPath: assetsDir });
29
+ const [htmlFile] = compileHtml(rootNode);
30
+ return {
31
+ html: htmlFile?.contents,
32
+ diagnostics: downloadedImages.length > 0
33
+ ? [
34
+ {
35
+ code: "FIGMA_ASSETS_DOWNLOADED",
36
+ message: `Downloaded ${downloadedImages.length} image asset(s).`,
37
+ severity: "info",
38
+ },
39
+ ]
40
+ : [],
41
+ };
42
+ }
43
+ catch (error) {
44
+ const message = error instanceof Error ? error.message : String(error);
45
+ return {
46
+ diagnostics: [
47
+ {
48
+ code: "FIGMA_HTML_FAILED",
49
+ message,
50
+ severity: "error",
51
+ },
52
+ ],
53
+ };
54
+ }
55
+ }
56
+ }
package/dist/types.js ADDED
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,5 @@
1
+ # @design-embed/config
2
+
3
+ Internal configuration loading and validation for design-embed.
4
+
5
+ It parses the project config format used by the compiler, validates supported output targets, style modes, component mappings, token settings, plugin settings, and local transformer declarations. It also reports config diagnostics in a stable shape so callers can fail early before compilation begins.