@design-embed/plugin-figma-html 0.1.0 → 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/LICENSE +1 -1
- package/dist/compilers/compilerUtils.d.mts +11 -0
- package/dist/compilers/compilerUtils.mjs +119 -0
- package/dist/compilers/htmlCompiler.d.mts +6 -0
- package/dist/compilers/htmlCompiler.mjs +31 -0
- package/dist/compilers/index.d.mts +11 -0
- package/dist/compilers/index.mjs +17 -0
- package/dist/compilers/index.test.d.mts +1 -0
- package/dist/compilers/index.test.mjs +45 -0
- package/dist/compilers/reactCompiler.d.mts +6 -0
- package/dist/compilers/reactCompiler.mjs +47 -0
- package/dist/compilers/vanjsCompiler.d.mts +6 -0
- package/dist/compilers/vanjsCompiler.mjs +47 -0
- package/dist/design-embed/src/core/diagnostics/diagnostic.d.mts +16 -0
- package/dist/design-embed/src/core/nodes.d.mts +14 -0
- package/dist/design-embed/src/core/plugins/pluginApi.d.mts +34 -0
- package/dist/external/figmaApi.d.mts +17 -0
- package/dist/external/figmaApi.mjs +55 -0
- package/dist/external/figmaApi.test.d.mts +1 -0
- package/dist/external/figmaApi.test.mjs +101 -0
- package/dist/external/imageDownloader.d.mts +17 -0
- package/dist/external/imageDownloader.mjs +66 -0
- package/dist/external/imageDownloader.test.d.mts +1 -0
- package/dist/external/imageDownloader.test.mjs +42 -0
- package/dist/index.d.mts +8 -0
- package/dist/index.mjs +7 -0
- package/dist/plugin.d.mts +16 -0
- package/dist/plugin.mjs +43 -0
- package/dist/types.d.mts +84 -0
- package/package.json +11 -9
- package/src/plugin.ts +2 -3
- package/dist/compilers/compilerUtils.js +0 -182
- package/dist/compilers/htmlCompiler.js +0 -35
- package/dist/compilers/index.js +0 -17
- package/dist/compilers/reactCompiler.js +0 -58
- package/dist/compilers/vanjsCompiler.js +0 -55
- package/dist/external/figmaApi.js +0 -74
- package/dist/external/imageDownloader.js +0 -82
- package/dist/index.js +0 -3
- package/dist/plugin.js +0 -56
- package/node_modules/@design-embed/config/README.md +0 -5
- package/node_modules/@design-embed/config/dist/index.js +0 -283
- package/node_modules/@design-embed/config/package.json +0 -19
- package/node_modules/@design-embed/config/src/index.ts +0 -518
- package/node_modules/@design-embed/core/README.md +0 -5
- package/node_modules/@design-embed/core/dist/diagnostics/diagnostic.js +0 -3
- package/node_modules/@design-embed/core/dist/diagnostics/jsonDiagnostic.js +0 -35
- package/node_modules/@design-embed/core/dist/index.js +0 -351
- package/node_modules/@design-embed/core/dist/pipeline/checkMode.js +0 -29
- package/node_modules/@design-embed/core/dist/plugins/pluginApi.js +0 -1
- package/node_modules/@design-embed/core/dist/plugins/pluginRegistry.js +0 -25
- package/node_modules/@design-embed/core/package.json +0 -19
- package/node_modules/@design-embed/core/src/diagnostics/diagnostic.ts +0 -18
- package/node_modules/@design-embed/core/src/diagnostics/jsonDiagnostic.ts +0 -51
- package/node_modules/@design-embed/core/src/index.ts +0 -591
- package/node_modules/@design-embed/core/src/pipeline/checkMode.ts +0 -46
- package/node_modules/@design-embed/core/src/plugins/pluginApi.ts +0 -78
- package/node_modules/@design-embed/core/src/plugins/pluginRegistry.ts +0 -37
- /package/dist/{types.js → types.mjs} +0 -0
package/LICENSE
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
MIT License
|
|
2
2
|
|
|
3
|
-
Copyright (c) 2026 Jin
|
|
3
|
+
Copyright (c) 2026 Jin Woo Lee
|
|
4
4
|
|
|
5
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
6
|
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { FigmaNode } from "../types.mjs";
|
|
2
|
+
|
|
3
|
+
//#region packages/plugin-figma-html/src/compilers/compilerUtils.d.ts
|
|
4
|
+
declare function toComponentName(name: string | undefined, fallback?: string): string;
|
|
5
|
+
declare function escapeHtml(value: string | undefined): string;
|
|
6
|
+
declare function escapeJsString(value: string | undefined): string;
|
|
7
|
+
declare function getNodeStyles(node: FigmaNode, parent?: FigmaNode): Record<string, string | number>;
|
|
8
|
+
declare function toReactStyle(styles: Record<string, string | number>): string;
|
|
9
|
+
declare function toCssText(styles: Record<string, string | number>): string;
|
|
10
|
+
//#endregion
|
|
11
|
+
export { escapeHtml, escapeJsString, getNodeStyles, toComponentName, toCssText, toReactStyle };
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
//#region packages/plugin-figma-html/src/compilers/compilerUtils.ts
|
|
2
|
+
function toComponentName(name, fallback = "FigmaExport") {
|
|
3
|
+
const cleaned = (name || "").replace(/[^a-zA-Z0-9가-힣]/g, "");
|
|
4
|
+
if (!cleaned) return fallback;
|
|
5
|
+
return /^[0-9]/.test(cleaned) ? `${fallback}${cleaned}` : cleaned;
|
|
6
|
+
}
|
|
7
|
+
function escapeHtml(value) {
|
|
8
|
+
return (value || "").replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """).replace(/'/g, "'");
|
|
9
|
+
}
|
|
10
|
+
function escapeJsString(value) {
|
|
11
|
+
return JSON.stringify(value || "");
|
|
12
|
+
}
|
|
13
|
+
function getNodeStyles(node, parent) {
|
|
14
|
+
const styles = {};
|
|
15
|
+
const bounds = node.absoluteBoundingBox;
|
|
16
|
+
const parentBounds = parent?.absoluteBoundingBox;
|
|
17
|
+
const parentUsesLayout = Boolean(parent?.layoutMode && parent.layoutMode !== "NONE");
|
|
18
|
+
if (Boolean(parent && (!parentUsesLayout || node.layoutPositioning === "ABSOLUTE")) && bounds && parentBounds) {
|
|
19
|
+
styles.position = "absolute";
|
|
20
|
+
styles.left = `${Math.round((bounds.x || 0) - (parentBounds.x || 0))}px`;
|
|
21
|
+
styles.top = `${Math.round((bounds.y || 0) - (parentBounds.y || 0))}px`;
|
|
22
|
+
} else if (node.children?.length || node.layoutMode === "NONE") styles.position = "relative";
|
|
23
|
+
if (bounds && node.layoutSizingHorizontal !== "HUG") styles.width = `${Math.round(bounds.width || 0)}px`;
|
|
24
|
+
if (bounds && node.layoutSizingVertical !== "HUG") styles.height = `${Math.round(bounds.height || 0)}px`;
|
|
25
|
+
if (node.layoutMode === "HORIZONTAL" || node.layoutMode === "VERTICAL") {
|
|
26
|
+
styles.display = "flex";
|
|
27
|
+
styles.flexDirection = node.layoutMode === "HORIZONTAL" ? "row" : "column";
|
|
28
|
+
styles.boxSizing = "border-box";
|
|
29
|
+
if (node.layoutWrap === "WRAP") styles.flexWrap = "wrap";
|
|
30
|
+
if (node.itemSpacing !== void 0) styles.gap = `${Math.round(node.itemSpacing)}px`;
|
|
31
|
+
if (node.counterAxisSpacing !== void 0 && node.layoutWrap === "WRAP") styles.rowGap = `${Math.round(node.counterAxisSpacing)}px`;
|
|
32
|
+
styles.justifyContent = mapPrimaryAxisAlignment(node.primaryAxisAlignItems);
|
|
33
|
+
styles.alignItems = mapCounterAxisAlignment(node.counterAxisAlignItems);
|
|
34
|
+
} else if (node.layoutMode === "GRID") {
|
|
35
|
+
styles.display = "grid";
|
|
36
|
+
styles.boxSizing = "border-box";
|
|
37
|
+
if (node.gridColumnsSizing) styles.gridTemplateColumns = node.gridColumnsSizing;
|
|
38
|
+
if (node.gridRowsSizing) styles.gridTemplateRows = node.gridRowsSizing;
|
|
39
|
+
if (node.gridColumnGap !== void 0) styles.columnGap = `${Math.round(node.gridColumnGap)}px`;
|
|
40
|
+
if (node.gridRowGap !== void 0) styles.rowGap = `${Math.round(node.gridRowGap)}px`;
|
|
41
|
+
}
|
|
42
|
+
if (node.layoutSizingHorizontal === "FILL" || node.layoutGrow === 1) {
|
|
43
|
+
styles.flex = 1;
|
|
44
|
+
styles.width = "100%";
|
|
45
|
+
}
|
|
46
|
+
if (node.layoutSizingVertical === "FILL") styles.height = "100%";
|
|
47
|
+
if (node.layoutAlign === "STRETCH") styles.alignSelf = "stretch";
|
|
48
|
+
if (node.gridColumnSpan && node.gridColumnSpan > 1) styles.gridColumn = `span ${node.gridColumnSpan}`;
|
|
49
|
+
if (node.gridRowSpan && node.gridRowSpan > 1) styles.gridRow = `span ${node.gridRowSpan}`;
|
|
50
|
+
if (node.paddingTop !== void 0) styles.paddingTop = `${node.paddingTop}px`;
|
|
51
|
+
if (node.paddingBottom !== void 0) styles.paddingBottom = `${node.paddingBottom}px`;
|
|
52
|
+
if (node.paddingLeft !== void 0) styles.paddingLeft = `${node.paddingLeft}px`;
|
|
53
|
+
if (node.paddingRight !== void 0) styles.paddingRight = `${node.paddingRight}px`;
|
|
54
|
+
if (node.clipsContent) styles.overflow = "hidden";
|
|
55
|
+
if (node.opacity !== void 0 && node.opacity !== 1) styles.opacity = node.opacity;
|
|
56
|
+
const fill = node.fills?.find((item) => item.type === "SOLID" && item.color);
|
|
57
|
+
if (fill?.color) if (node.type === "TEXT") styles.color = toRgba(fill.color, fill.opacity ?? 1);
|
|
58
|
+
else styles.backgroundColor = toRgba(fill.color, fill.opacity ?? 1);
|
|
59
|
+
const imageFill = node.fills?.find((item) => item.type === "IMAGE" && (item.imageLocalPath || item.imageUrl));
|
|
60
|
+
const imageSource = imageFill?.imageLocalPath || imageFill?.imageUrl;
|
|
61
|
+
if (imageSource) {
|
|
62
|
+
styles.backgroundImage = `url("${imageSource}")`;
|
|
63
|
+
styles.backgroundRepeat = "no-repeat";
|
|
64
|
+
styles.backgroundPosition = "center";
|
|
65
|
+
styles.backgroundSize = mapImageScaleMode(imageFill.scaleMode);
|
|
66
|
+
}
|
|
67
|
+
const stroke = node.strokes?.find((item) => item.type === "SOLID" && item.color);
|
|
68
|
+
if (stroke?.color && node.strokeWeight) {
|
|
69
|
+
styles.border = `${Math.round(node.strokeWeight)}px solid ${toRgba(stroke.color, stroke.opacity ?? 1)}`;
|
|
70
|
+
styles.boxSizing = "border-box";
|
|
71
|
+
}
|
|
72
|
+
if (node.rectangleCornerRadii?.length === 4) styles.borderRadius = node.rectangleCornerRadii.map((radius) => `${Math.round(radius)}px`).join(" ");
|
|
73
|
+
else if (node.cornerRadius) styles.borderRadius = `${Math.round(node.cornerRadius)}px`;
|
|
74
|
+
if (node.type === "TEXT") {
|
|
75
|
+
const textStyle = node.style || {};
|
|
76
|
+
if (textStyle.fontSize) styles.fontSize = `${textStyle.fontSize}px`;
|
|
77
|
+
if (textStyle.fontWeight) styles.fontWeight = textStyle.fontWeight;
|
|
78
|
+
if (textStyle.fontFamily) styles.fontFamily = `"${textStyle.fontFamily}", sans-serif`;
|
|
79
|
+
if (textStyle.lineHeightPx) styles.lineHeight = `${Math.round(textStyle.lineHeightPx)}px`;
|
|
80
|
+
}
|
|
81
|
+
return styles;
|
|
82
|
+
}
|
|
83
|
+
function toReactStyle(styles) {
|
|
84
|
+
return JSON.stringify(styles).replace(/"([^"]+)":/g, "$1:");
|
|
85
|
+
}
|
|
86
|
+
function toCssText(styles) {
|
|
87
|
+
return Object.entries(styles).map(([key, value]) => `${toKebabCase(key)}: ${value};`).join(" ");
|
|
88
|
+
}
|
|
89
|
+
function toKebabCase(value) {
|
|
90
|
+
return value.replace(/[A-Z]/g, (match) => `-${match.toLowerCase()}`);
|
|
91
|
+
}
|
|
92
|
+
function mapPrimaryAxisAlignment(value) {
|
|
93
|
+
switch (value) {
|
|
94
|
+
case "CENTER": return "center";
|
|
95
|
+
case "MAX": return "flex-end";
|
|
96
|
+
case "SPACE_BETWEEN": return "space-between";
|
|
97
|
+
default: return "flex-start";
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
function mapCounterAxisAlignment(value) {
|
|
101
|
+
switch (value) {
|
|
102
|
+
case "CENTER": return "center";
|
|
103
|
+
case "MAX": return "flex-end";
|
|
104
|
+
case "BASELINE": return "baseline";
|
|
105
|
+
default: return "flex-start";
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
function mapImageScaleMode(value) {
|
|
109
|
+
switch (value) {
|
|
110
|
+
case "FIT": return "contain";
|
|
111
|
+
case "TILE": return "auto";
|
|
112
|
+
default: return "cover";
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
function toRgba(color, opacity) {
|
|
116
|
+
return `rgba(${Math.round(color.r * 255)}, ${Math.round(color.g * 255)}, ${Math.round(color.b * 255)}, ${opacity})`;
|
|
117
|
+
}
|
|
118
|
+
//#endregion
|
|
119
|
+
export { escapeHtml, escapeJsString, getNodeStyles, toComponentName, toCssText, toReactStyle };
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { escapeHtml, getNodeStyles, toCssText } from "./compilerUtils.mjs";
|
|
2
|
+
//#region packages/plugin-figma-html/src/compilers/htmlCompiler.ts
|
|
3
|
+
const compileHtml = (node) => [{
|
|
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, void 0, 2).trimEnd()}
|
|
14
|
+
</body>
|
|
15
|
+
</html>
|
|
16
|
+
`
|
|
17
|
+
}];
|
|
18
|
+
function walkHtml(node, parent, depth = 0) {
|
|
19
|
+
if (!node || node.visible === false) return "";
|
|
20
|
+
const indent = " ".repeat(depth);
|
|
21
|
+
const childIndent = " ".repeat(depth + 1);
|
|
22
|
+
const name = escapeHtml(node.name || "LayoutBox");
|
|
23
|
+
const style = escapeHtml(toCssText(getNodeStyles(node, parent)));
|
|
24
|
+
if (node.type === "TEXT") return `${indent}<span style="${style}" data-layer="${name}">
|
|
25
|
+
${childIndent}${escapeHtml(node.characters)}
|
|
26
|
+
${indent}</span>\n`;
|
|
27
|
+
return `${indent}<div style="${style}" data-layer="${name}">
|
|
28
|
+
${node.children?.map((child) => walkHtml(child, node, depth + 1)).join("") || ""}${indent}</div>\n`;
|
|
29
|
+
}
|
|
30
|
+
//#endregion
|
|
31
|
+
export { compileHtml };
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { CompilerMode, FigmaCompiler } from "../types.mjs";
|
|
2
|
+
import { compileHtml } from "./htmlCompiler.mjs";
|
|
3
|
+
import { compileReact } from "./reactCompiler.mjs";
|
|
4
|
+
import { compileVanjs } from "./vanjsCompiler.mjs";
|
|
5
|
+
|
|
6
|
+
//#region packages/plugin-figma-html/src/compilers/index.d.ts
|
|
7
|
+
declare const compilers: Record<CompilerMode, FigmaCompiler>;
|
|
8
|
+
declare function isCompilerMode(value: string | undefined): value is CompilerMode;
|
|
9
|
+
declare function getCompiler(mode: CompilerMode): FigmaCompiler;
|
|
10
|
+
//#endregion
|
|
11
|
+
export { compileHtml, compileReact, compileVanjs, compilers, getCompiler, isCompilerMode };
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { compileHtml } from "./htmlCompiler.mjs";
|
|
2
|
+
import { compileReact } from "./reactCompiler.mjs";
|
|
3
|
+
import { compileVanjs } from "./vanjsCompiler.mjs";
|
|
4
|
+
//#region packages/plugin-figma-html/src/compilers/index.ts
|
|
5
|
+
const compilers = {
|
|
6
|
+
react: compileReact,
|
|
7
|
+
html: compileHtml,
|
|
8
|
+
vanjs: compileVanjs
|
|
9
|
+
};
|
|
10
|
+
function isCompilerMode(value) {
|
|
11
|
+
return Boolean(value && value in compilers);
|
|
12
|
+
}
|
|
13
|
+
function getCompiler(mode) {
|
|
14
|
+
return compilers[mode];
|
|
15
|
+
}
|
|
16
|
+
//#endregion
|
|
17
|
+
export { compileHtml, compileReact, compileVanjs, compilers, getCompiler, isCompilerMode };
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { };
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { compileHtml } from "./htmlCompiler.mjs";
|
|
2
|
+
import { compileReact } from "./reactCompiler.mjs";
|
|
3
|
+
import { compileVanjs } from "./vanjsCompiler.mjs";
|
|
4
|
+
import { getCompiler } from "./index.mjs";
|
|
5
|
+
import assert from "node:assert/strict";
|
|
6
|
+
import { describe, test } from "node:test";
|
|
7
|
+
//#region packages/plugin-figma-html/src/compilers/index.test.ts
|
|
8
|
+
const sampleNode = {
|
|
9
|
+
name: "Sample Frame",
|
|
10
|
+
type: "FRAME",
|
|
11
|
+
layoutMode: "VERTICAL",
|
|
12
|
+
absoluteBoundingBox: {
|
|
13
|
+
x: 0,
|
|
14
|
+
y: 0,
|
|
15
|
+
width: 100,
|
|
16
|
+
height: 80
|
|
17
|
+
},
|
|
18
|
+
children: [{
|
|
19
|
+
name: "Title",
|
|
20
|
+
type: "TEXT",
|
|
21
|
+
characters: "Hello",
|
|
22
|
+
absoluteBoundingBox: {
|
|
23
|
+
x: 8,
|
|
24
|
+
y: 8,
|
|
25
|
+
width: 40,
|
|
26
|
+
height: 20
|
|
27
|
+
}
|
|
28
|
+
}]
|
|
29
|
+
};
|
|
30
|
+
describe("compiler registry", () => {
|
|
31
|
+
test("returns compiler functions without external dependencies", () => {
|
|
32
|
+
assert.equal(getCompiler("html"), compileHtml);
|
|
33
|
+
assert.equal(getCompiler("react"), compileReact);
|
|
34
|
+
assert.equal(getCompiler("vanjs"), compileVanjs);
|
|
35
|
+
});
|
|
36
|
+
});
|
|
37
|
+
describe("compilers", () => {
|
|
38
|
+
test("compile a Figma node into generated files", () => {
|
|
39
|
+
assert.match(compileHtml(sampleNode)[0]?.contents ?? "", /Hello/);
|
|
40
|
+
assert.match(compileReact(sampleNode)[0]?.contents ?? "", /SampleFrame/);
|
|
41
|
+
assert.deepEqual(compileVanjs(sampleNode).map((file) => file.path), ["index.html", "main.js"]);
|
|
42
|
+
});
|
|
43
|
+
});
|
|
44
|
+
//#endregion
|
|
45
|
+
export {};
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { escapeHtml, escapeJsString, getNodeStyles, toComponentName, toReactStyle } from "./compilerUtils.mjs";
|
|
2
|
+
//#region packages/plugin-figma-html/src/compilers/reactCompiler.ts
|
|
3
|
+
const compileReact = (node) => {
|
|
4
|
+
const componentName = toComponentName(node.name);
|
|
5
|
+
return [{
|
|
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
|
+
function walkReact(node, depth = 2) {
|
|
23
|
+
if (!node || node.visible === false) return "";
|
|
24
|
+
const indent = " ".repeat(depth);
|
|
25
|
+
const childIndent = " ".repeat(depth + 1);
|
|
26
|
+
const name = escapeHtml(node.name || "LayoutBox");
|
|
27
|
+
const styles = toReactStyle(getNodeStyles(node));
|
|
28
|
+
if (node.type === "TEXT") return `${indent}<span style={${styles}} data-layer={${escapeJsString(node.name || "LayoutBox")}}>
|
|
29
|
+
${childIndent}{${escapeJsString(node.characters)}}
|
|
30
|
+
${indent}</span>\n`;
|
|
31
|
+
return `${indent}<div style={${styles}} data-layer="${name}">
|
|
32
|
+
${node.children?.map((child) => walkReactWithParent(child, node, depth + 1)).join("") || ""}${indent}</div>\n`;
|
|
33
|
+
}
|
|
34
|
+
function walkReactWithParent(node, parent, depth) {
|
|
35
|
+
if (!node || node.visible === false) return "";
|
|
36
|
+
const indent = " ".repeat(depth);
|
|
37
|
+
const childIndent = " ".repeat(depth + 1);
|
|
38
|
+
const name = escapeHtml(node.name || "LayoutBox");
|
|
39
|
+
const styles = toReactStyle(getNodeStyles(node, parent));
|
|
40
|
+
if (node.type === "TEXT") return `${indent}<span style={${styles}} data-layer={${escapeJsString(node.name || "LayoutBox")}}>
|
|
41
|
+
${childIndent}{${escapeJsString(node.characters)}}
|
|
42
|
+
${indent}</span>\n`;
|
|
43
|
+
return `${indent}<div style={${styles}} data-layer="${name}">
|
|
44
|
+
${node.children?.map((child) => walkReactWithParent(child, node, depth + 1)).join("") || ""}${indent}</div>\n`;
|
|
45
|
+
}
|
|
46
|
+
//#endregion
|
|
47
|
+
export { compileReact };
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { escapeHtml, escapeJsString, getNodeStyles, toCssText } from "./compilerUtils.mjs";
|
|
2
|
+
//#region packages/plugin-figma-html/src/compilers/vanjsCompiler.ts
|
|
3
|
+
const compileVanjs = (node) => [{
|
|
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
|
+
path: "main.js",
|
|
20
|
+
contents: `import van from 'https://cdn.jsdelivr.net/npm/vanjs-core@1.5.5/src/van.min.js';
|
|
21
|
+
|
|
22
|
+
const { div, span } = van.tags;
|
|
23
|
+
|
|
24
|
+
const App = () => (
|
|
25
|
+
${walkVanjs(node, void 0, 1).trimEnd()}
|
|
26
|
+
);
|
|
27
|
+
|
|
28
|
+
van.add(document.getElementById('app'), App());
|
|
29
|
+
`
|
|
30
|
+
}];
|
|
31
|
+
function walkVanjs(node, parent, depth = 0) {
|
|
32
|
+
if (!node || node.visible === false) return "null";
|
|
33
|
+
const indent = " ".repeat(depth);
|
|
34
|
+
const childIndent = " ".repeat(depth + 1);
|
|
35
|
+
const tag = node.type === "TEXT" ? "span" : "div";
|
|
36
|
+
const props = `{
|
|
37
|
+
${childIndent}style: ${escapeJsString(toCssText(getNodeStyles(node, parent)))},
|
|
38
|
+
${childIndent}"data-layer": ${escapeJsString(node.name || "LayoutBox")}
|
|
39
|
+
${indent}}`;
|
|
40
|
+
if (node.type === "TEXT") return `${indent}${tag}(${props}, ${escapeJsString(node.characters)})\n`;
|
|
41
|
+
const children = (node.children || []).map((child) => walkVanjs(child, node, depth + 1)).filter((value) => value.trim() !== "null").join(",");
|
|
42
|
+
if (!children) return `${indent}${tag}(${props})\n`;
|
|
43
|
+
return `${indent}${tag}(${props},
|
|
44
|
+
${children}${indent})\n`;
|
|
45
|
+
}
|
|
46
|
+
//#endregion
|
|
47
|
+
export { compileVanjs };
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { SourceLocation } from "../nodes.mjs";
|
|
2
|
+
|
|
3
|
+
//#region packages/design-embed/src/core/diagnostics/diagnostic.d.ts
|
|
4
|
+
type DiagnosticSeverity = "error" | "warning" | "info";
|
|
5
|
+
interface Diagnostic {
|
|
6
|
+
code: string;
|
|
7
|
+
message: string;
|
|
8
|
+
severity: DiagnosticSeverity;
|
|
9
|
+
file?: string;
|
|
10
|
+
source?: SourceLocation;
|
|
11
|
+
selector?: string;
|
|
12
|
+
property?: string;
|
|
13
|
+
details?: Record<string, unknown>;
|
|
14
|
+
}
|
|
15
|
+
//#endregion
|
|
16
|
+
export { Diagnostic };
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
//#region packages/design-embed/src/core/nodes.d.ts
|
|
2
|
+
/**
|
|
3
|
+
* Location in the source HTML file.
|
|
4
|
+
*/
|
|
5
|
+
interface SourceLocation {
|
|
6
|
+
/** Absolute offset in characters. */
|
|
7
|
+
offset: number;
|
|
8
|
+
/** 1-based line number. */
|
|
9
|
+
line: number;
|
|
10
|
+
/** 1-based column number. */
|
|
11
|
+
column: number;
|
|
12
|
+
}
|
|
13
|
+
//#endregion
|
|
14
|
+
export { SourceLocation };
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { Diagnostic } from "../diagnostics/diagnostic.mjs";
|
|
2
|
+
|
|
3
|
+
//#region packages/design-embed/src/core/plugins/pluginApi.d.ts
|
|
4
|
+
/**
|
|
5
|
+
* Represents a file generated by the compiler.
|
|
6
|
+
*/
|
|
7
|
+
interface GeneratedFile {
|
|
8
|
+
/** Relative path from the output directory. */
|
|
9
|
+
path: string;
|
|
10
|
+
/** File content. */
|
|
11
|
+
contents: string;
|
|
12
|
+
}
|
|
13
|
+
interface GeneratedAsset {
|
|
14
|
+
path: string;
|
|
15
|
+
contents?: string | Uint8Array;
|
|
16
|
+
sourceUrl?: string;
|
|
17
|
+
}
|
|
18
|
+
interface SourcePlugin {
|
|
19
|
+
name: string;
|
|
20
|
+
run(input: SourcePluginInput): Promise<SourcePluginResult>;
|
|
21
|
+
}
|
|
22
|
+
interface SourcePluginInput {
|
|
23
|
+
cwd: string;
|
|
24
|
+
config?: unknown;
|
|
25
|
+
}
|
|
26
|
+
interface SourcePluginResult {
|
|
27
|
+
html?: string;
|
|
28
|
+
css?: string;
|
|
29
|
+
assets?: GeneratedAsset[];
|
|
30
|
+
files?: GeneratedFile[];
|
|
31
|
+
diagnostics: Diagnostic[];
|
|
32
|
+
}
|
|
33
|
+
//#endregion
|
|
34
|
+
export { SourcePlugin, SourcePluginInput, SourcePluginResult };
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { ExtractedParams, FigmaNode } from "../types.mjs";
|
|
2
|
+
|
|
3
|
+
//#region packages/plugin-figma-html/src/external/figmaApi.d.ts
|
|
4
|
+
type FigmaApiResponse = unknown;
|
|
5
|
+
type FigmaFetcher = (input: string, init?: RequestInit) => Promise<Response>;
|
|
6
|
+
interface FigmaClientOptions {
|
|
7
|
+
token: string;
|
|
8
|
+
fetcher?: FigmaFetcher;
|
|
9
|
+
}
|
|
10
|
+
declare function extractParamsFromURL(input: string): ExtractedParams;
|
|
11
|
+
declare function fetchFigmaNode(fileKey: string, nodeId: string | null, options: FigmaClientOptions): Promise<FigmaNode>;
|
|
12
|
+
declare function fetchFigmaApiResponse(fileKey: string, nodeId: string | null, options: FigmaClientOptions): Promise<FigmaApiResponse>;
|
|
13
|
+
declare function buildFigmaNodeEndpoint(fileKey: string, nodeId: string | null): string;
|
|
14
|
+
declare function fetchFigmaImageFills(fileKey: string, options: FigmaClientOptions): Promise<Record<string, string>>;
|
|
15
|
+
declare function buildFigmaImageFillsEndpoint(fileKey: string): string;
|
|
16
|
+
//#endregion
|
|
17
|
+
export { FigmaApiResponse, FigmaClientOptions, FigmaFetcher, buildFigmaImageFillsEndpoint, buildFigmaNodeEndpoint, extractParamsFromURL, fetchFigmaApiResponse, fetchFigmaImageFills, fetchFigmaNode };
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
//#region packages/plugin-figma-html/src/external/figmaApi.ts
|
|
2
|
+
function extractParamsFromURL(input) {
|
|
3
|
+
const cleanInput = input.trim();
|
|
4
|
+
const fileKey = cleanInput.match(/figma\.com\/(?:file|design)\/([^/]+)/)?.[1] || cleanInput;
|
|
5
|
+
let nodeId = null;
|
|
6
|
+
try {
|
|
7
|
+
if (cleanInput.includes("figma.com")) {
|
|
8
|
+
const rawNodeId = new URL(cleanInput).searchParams.get("node-id");
|
|
9
|
+
if (rawNodeId) nodeId = rawNodeId.replace(/-/g, ":");
|
|
10
|
+
}
|
|
11
|
+
} catch {}
|
|
12
|
+
return {
|
|
13
|
+
fileKey,
|
|
14
|
+
nodeId
|
|
15
|
+
};
|
|
16
|
+
}
|
|
17
|
+
async function fetchFigmaNode(fileKey, nodeId, options) {
|
|
18
|
+
const data = await fetchFigmaApiResponse(fileKey, nodeId, options);
|
|
19
|
+
const rootNode = nodeId ? data.nodes?.[nodeId]?.document : data.document;
|
|
20
|
+
if (!rootNode) throw new Error(`Could not find valid element tree for Node ID: ${nodeId}`);
|
|
21
|
+
attachImageFillUrls(rootNode, await fetchFigmaImageFills(fileKey, options));
|
|
22
|
+
return rootNode;
|
|
23
|
+
}
|
|
24
|
+
async function fetchFigmaApiResponse(fileKey, nodeId, options) {
|
|
25
|
+
const endpoint = buildFigmaNodeEndpoint(fileKey, nodeId);
|
|
26
|
+
const response = await (options.fetcher ?? fetch)(endpoint, {
|
|
27
|
+
method: "GET",
|
|
28
|
+
headers: { "X-Figma-Token": options.token }
|
|
29
|
+
});
|
|
30
|
+
if (!response.ok) throw new Error(`Figma API Error: ${response.status} ${response.statusText}`);
|
|
31
|
+
return response.json();
|
|
32
|
+
}
|
|
33
|
+
function buildFigmaNodeEndpoint(fileKey, nodeId) {
|
|
34
|
+
if (!nodeId) return `https://api.figma.com/v1/files/${fileKey}?depth=2`;
|
|
35
|
+
return `https://api.figma.com/v1/files/${fileKey}/nodes?ids=${encodeURIComponent(nodeId)}`;
|
|
36
|
+
}
|
|
37
|
+
async function fetchFigmaImageFills(fileKey, options) {
|
|
38
|
+
const endpoint = buildFigmaImageFillsEndpoint(fileKey);
|
|
39
|
+
const response = await (options.fetcher ?? fetch)(endpoint, {
|
|
40
|
+
method: "GET",
|
|
41
|
+
headers: { "X-Figma-Token": options.token }
|
|
42
|
+
});
|
|
43
|
+
if (!response.ok) throw new Error(`Figma image fills API Error: ${response.status} ${response.statusText}`);
|
|
44
|
+
const data = await response.json();
|
|
45
|
+
return Object.fromEntries(Object.entries(data.images || {}).filter((entry) => typeof entry[1] === "string"));
|
|
46
|
+
}
|
|
47
|
+
function buildFigmaImageFillsEndpoint(fileKey) {
|
|
48
|
+
return `https://api.figma.com/v1/files/${fileKey}/images`;
|
|
49
|
+
}
|
|
50
|
+
function attachImageFillUrls(node, imageFills) {
|
|
51
|
+
for (const fill of node.fills || []) if (fill.type === "IMAGE" && fill.imageRef) fill.imageUrl = imageFills[fill.imageRef];
|
|
52
|
+
for (const child of node.children || []) attachImageFillUrls(child, imageFills);
|
|
53
|
+
}
|
|
54
|
+
//#endregion
|
|
55
|
+
export { buildFigmaImageFillsEndpoint, buildFigmaNodeEndpoint, extractParamsFromURL, fetchFigmaApiResponse, fetchFigmaImageFills, fetchFigmaNode };
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { };
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import { buildFigmaImageFillsEndpoint, buildFigmaNodeEndpoint, extractParamsFromURL, fetchFigmaApiResponse, fetchFigmaImageFills, fetchFigmaNode } from "./figmaApi.mjs";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import { describe, test } from "node:test";
|
|
4
|
+
//#region packages/plugin-figma-html/src/external/figmaApi.test.ts
|
|
5
|
+
describe("extractParamsFromURL", () => {
|
|
6
|
+
test("extracts file key and node id from a Figma design URL", () => {
|
|
7
|
+
assert.deepEqual(extractParamsFromURL("https://www.figma.com/design/file123/Sample?node-id=1-2&t=abc"), {
|
|
8
|
+
fileKey: "file123",
|
|
9
|
+
nodeId: "1:2"
|
|
10
|
+
});
|
|
11
|
+
});
|
|
12
|
+
test("treats non-url input as a raw file key", () => {
|
|
13
|
+
assert.deepEqual(extractParamsFromURL("file123"), {
|
|
14
|
+
fileKey: "file123",
|
|
15
|
+
nodeId: null
|
|
16
|
+
});
|
|
17
|
+
});
|
|
18
|
+
});
|
|
19
|
+
describe("buildFigmaNodeEndpoint", () => {
|
|
20
|
+
test("builds root document endpoint", () => {
|
|
21
|
+
assert.equal(buildFigmaNodeEndpoint("file123", null), "https://api.figma.com/v1/files/file123?depth=2");
|
|
22
|
+
});
|
|
23
|
+
test("builds node endpoint with an encoded node id", () => {
|
|
24
|
+
assert.equal(buildFigmaNodeEndpoint("file123", "1:2"), "https://api.figma.com/v1/files/file123/nodes?ids=1%3A2");
|
|
25
|
+
});
|
|
26
|
+
});
|
|
27
|
+
describe("buildFigmaImageFillsEndpoint", () => {
|
|
28
|
+
test("builds image fills endpoint", () => {
|
|
29
|
+
assert.equal(buildFigmaImageFillsEndpoint("file123"), "https://api.figma.com/v1/files/file123/images");
|
|
30
|
+
});
|
|
31
|
+
});
|
|
32
|
+
describe("fetchFigmaNode", () => {
|
|
33
|
+
test("uses an injected fetcher and returns the selected node with image fill URLs", async () => {
|
|
34
|
+
const calls = [];
|
|
35
|
+
const fetcher = async (url, init) => {
|
|
36
|
+
calls.push([url, init]);
|
|
37
|
+
if (url.endsWith("/images")) return new Response(JSON.stringify({ images: { image123: "https://example.com/image.png" } }));
|
|
38
|
+
return new Response(JSON.stringify({ nodes: { "1:2": { document: {
|
|
39
|
+
id: "1:2",
|
|
40
|
+
name: "Button",
|
|
41
|
+
type: "FRAME",
|
|
42
|
+
fills: [{
|
|
43
|
+
type: "IMAGE",
|
|
44
|
+
imageRef: "image123"
|
|
45
|
+
}]
|
|
46
|
+
} } } }));
|
|
47
|
+
};
|
|
48
|
+
const node = await fetchFigmaNode("file123", "1:2", {
|
|
49
|
+
token: "token",
|
|
50
|
+
fetcher
|
|
51
|
+
});
|
|
52
|
+
assert.deepEqual(node, {
|
|
53
|
+
id: "1:2",
|
|
54
|
+
name: "Button",
|
|
55
|
+
type: "FRAME",
|
|
56
|
+
fills: [{
|
|
57
|
+
type: "IMAGE",
|
|
58
|
+
imageRef: "image123",
|
|
59
|
+
imageUrl: "https://example.com/image.png"
|
|
60
|
+
}]
|
|
61
|
+
});
|
|
62
|
+
assert.deepEqual(calls, [["https://api.figma.com/v1/files/file123/nodes?ids=1%3A2", {
|
|
63
|
+
method: "GET",
|
|
64
|
+
headers: { "X-Figma-Token": "token" }
|
|
65
|
+
}], ["https://api.figma.com/v1/files/file123/images", {
|
|
66
|
+
method: "GET",
|
|
67
|
+
headers: { "X-Figma-Token": "token" }
|
|
68
|
+
}]]);
|
|
69
|
+
});
|
|
70
|
+
});
|
|
71
|
+
describe("fetchFigmaImageFills", () => {
|
|
72
|
+
test("returns only successful image fill URLs", async () => {
|
|
73
|
+
const fetcher = async () => new Response(JSON.stringify({ images: {
|
|
74
|
+
image123: "https://example.com/image.png",
|
|
75
|
+
image456: null
|
|
76
|
+
} }));
|
|
77
|
+
assert.deepEqual(await fetchFigmaImageFills("file123", {
|
|
78
|
+
token: "token",
|
|
79
|
+
fetcher
|
|
80
|
+
}), { image123: "https://example.com/image.png" });
|
|
81
|
+
});
|
|
82
|
+
});
|
|
83
|
+
describe("fetchFigmaApiResponse", () => {
|
|
84
|
+
test("returns the raw API payload without extracting a document node", async () => {
|
|
85
|
+
const payload = {
|
|
86
|
+
name: "Raw file response",
|
|
87
|
+
document: {
|
|
88
|
+
id: "0:0",
|
|
89
|
+
name: "Document",
|
|
90
|
+
type: "DOCUMENT"
|
|
91
|
+
}
|
|
92
|
+
};
|
|
93
|
+
const fetcher = async () => new Response(JSON.stringify(payload));
|
|
94
|
+
assert.deepEqual(await fetchFigmaApiResponse("file123", null, {
|
|
95
|
+
token: "token",
|
|
96
|
+
fetcher
|
|
97
|
+
}), payload);
|
|
98
|
+
});
|
|
99
|
+
});
|
|
100
|
+
//#endregion
|
|
101
|
+
export {};
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { FigmaNode } from "../types.mjs";
|
|
2
|
+
import { FigmaFetcher } from "./figmaApi.mjs";
|
|
3
|
+
|
|
4
|
+
//#region packages/plugin-figma-html/src/external/imageDownloader.d.ts
|
|
5
|
+
interface DownloadFigmaImagesOptions {
|
|
6
|
+
fetcher?: FigmaFetcher;
|
|
7
|
+
publicPath?: string;
|
|
8
|
+
}
|
|
9
|
+
interface DownloadedFigmaImage {
|
|
10
|
+
imageRef: string;
|
|
11
|
+
sourceUrl: string;
|
|
12
|
+
filePath: string;
|
|
13
|
+
publicPath: string;
|
|
14
|
+
}
|
|
15
|
+
declare function downloadFigmaImageFills(rootNode: FigmaNode, outDir: string, options?: DownloadFigmaImagesOptions): Promise<DownloadedFigmaImage[]>;
|
|
16
|
+
//#endregion
|
|
17
|
+
export { DownloadFigmaImagesOptions, DownloadedFigmaImage, downloadFigmaImageFills };
|