Skip to content

Commit a1e0a12

Browse files
committed
Improve Figma runner artifact fidelity
1 parent 23d3338 commit a1e0a12

7 files changed

Lines changed: 332 additions & 37 deletions

File tree

plugins/figma-to-wordpress/src/code.ts

Lines changed: 53 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -32,35 +32,81 @@ function normalizePaints(paints: unknown) {
3232
type: typedPaint.type,
3333
visible: typedPaint.visible,
3434
opacity: typedPaint.opacity,
35+
imageHash: "imageHash" in typedPaint && typeof typedPaint.imageHash === "string" ? typedPaint.imageHash : undefined,
3536
color: "color" in typedPaint ? typedPaint.color : undefined,
3637
};
3738
});
3839
}
3940

41+
function hasImagePaint(paints: unknown): boolean {
42+
return Array.isArray(paints) && paints.some((paint) => {
43+
return !!paint && typeof paint === "object" && "type" in paint && paint.type === "IMAGE" && (!("visible" in paint) || paint.visible !== false);
44+
});
45+
}
46+
4047
function normalizeTextStyle(node: TextNode) {
4148
return {
4249
fontFamily: typeof node.fontName === "object" ? node.fontName.family : undefined,
4350
fontSize: typeof node.fontSize === "number" ? node.fontSize : undefined,
4451
fontWeight: typeof node.fontName === "object" ? node.fontName.style : undefined,
52+
lineHeight: typeof node.lineHeight === "object" && node.lineHeight.unit === "PIXELS" ? node.lineHeight.value : undefined,
4553
textAlignHorizontal: node.textAlignHorizontal,
4654
};
4755
}
4856

49-
function normalizeNode(node: SceneNode | PageNode, fallbackType?: string): NormalizedSceneNode {
57+
function applyDerivedBounds(node: NormalizedSceneNode) {
58+
const children = (node.children || []).filter(
59+
(child) => child.x !== undefined && child.y !== undefined && child.width !== undefined && child.height !== undefined,
60+
);
61+
62+
if (!children.length) {
63+
return;
64+
}
65+
66+
const minX = Math.min(...children.map((child) => child.x || 0));
67+
const minY = Math.min(...children.map((child) => child.y || 0));
68+
const maxX = Math.max(...children.map((child) => (child.x || 0) + (child.width || 0)));
69+
const maxY = Math.max(...children.map((child) => (child.y || 0) + (child.height || 0)));
70+
71+
if (node.x === undefined) normalizedNumberAssign(node, "x", minX);
72+
if (node.y === undefined) normalizedNumberAssign(node, "y", minY);
73+
if (node.width === undefined) normalizedNumberAssign(node, "width", maxX - minX);
74+
if (node.height === undefined) normalizedNumberAssign(node, "height", maxY - minY);
75+
}
76+
77+
function normalizedNumberAssign(node: NormalizedSceneNode, key: "x" | "y" | "width" | "height", value: number) {
78+
if (Number.isFinite(value)) {
79+
node[key] = value;
80+
}
81+
}
82+
83+
async function normalizeNode(node: SceneNode | PageNode, fallbackType?: string): Promise<NormalizedSceneNode> {
5084
const bounds = "absoluteBoundingBox" in node ? node.absoluteBoundingBox : null;
85+
const fills = "fills" in node ? clonePaints(node, "fills") : undefined;
86+
const hasImageFill = hasImagePaint(fills);
5187
const normalized: NormalizedSceneNode = {
5288
id: node.id,
5389
name: node.name,
54-
type: fallbackType || node.type,
90+
type: fallbackType || (hasImageFill ? "IMAGE" : node.type),
5591
visible: "visible" in node ? node.visible : true,
5692
x: bounds?.x,
5793
y: bounds?.y,
5894
width: bounds?.width,
5995
height: bounds?.height,
60-
fills: "fills" in node ? normalizePaints(clonePaints(node, "fills")) : undefined,
96+
fills: normalizePaints(fills),
6197
strokes: "strokes" in node ? normalizePaints(clonePaints(node, "strokes")) : undefined,
6298
};
6399

100+
if (hasImageFill && "exportAsync" in node) {
101+
const asset = await exportAsset(node as SceneNode);
102+
if (asset) {
103+
normalized.image = {
104+
dataUri: asset.dataUrl,
105+
alt: node.name,
106+
};
107+
}
108+
}
109+
64110
if (node.type === "TEXT") {
65111
normalized.characters = node.characters;
66112
normalized.style = normalizeTextStyle(node);
@@ -75,7 +121,8 @@ function normalizeNode(node: SceneNode | PageNode, fallbackType?: string): Norma
75121
}
76122

77123
if ("children" in node) {
78-
normalized.children = node.children.map((child) => normalizeNode(child));
124+
normalized.children = await Promise.all(node.children.map((child) => normalizeNode(child)));
125+
applyDerivedBounds(normalized);
79126
}
80127

81128
return normalized;
@@ -110,14 +157,15 @@ async function getDocument(): Promise<NormalizedDocument> {
110157
await figma.loadAllPagesAsync();
111158
}
112159

113-
const pages = figma.root.children.map((page) => normalizeNode(page, "PAGE"));
160+
const pages = await Promise.all(figma.root.children.map((page) => normalizeNode(page, "PAGE")));
114161
const root: NormalizedSceneNode = {
115162
id: figma.root.id,
116163
name: figma.root.name || "Figma document",
117164
type: "DOCUMENT",
118165
visible: true,
119166
children: pages,
120167
};
168+
applyDerivedBounds(root);
121169

122170
return {
123171
id: figma.root.id,

plugins/figma-to-wordpress/src/index.ts

Lines changed: 64 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -134,7 +134,7 @@ export function generateWebsiteArtifact(
134134
};
135135
}
136136

137-
function renderNode(node: FigmaSceneNode, context: RenderContext, isRoot = false): string {
137+
function renderNode(node: FigmaSceneNode, context: RenderContext, isRoot = false, parent?: FigmaSceneNode): string {
138138
context.nodeCount += 1;
139139

140140
if (node.visible === false) {
@@ -152,22 +152,22 @@ function renderNode(node: FigmaSceneNode, context: RenderContext, isRoot = false
152152
}
153153

154154
if (node.type === "TEXT") {
155-
return renderText(node, context);
155+
return renderText(node, context, parent);
156156
}
157157

158158
if (node.type === "IMAGE") {
159-
return renderImage(node, context);
159+
return renderImage(node, context, parent);
160160
}
161161

162162
if (node.type === "RECTANGLE" && !node.children?.length) {
163-
return renderRectangle(node, context);
163+
return renderRectangle(node, context, parent);
164164
}
165165

166-
return renderContainer(node, context, isRoot);
166+
return renderContainer(node, context, isRoot, parent);
167167
}
168168

169-
function renderContainer(node: FigmaSceneNode, context: RenderContext, isRoot: boolean): string {
170-
const className = addCssRule(node, context, ["section"]);
169+
function renderContainer(node: FigmaSceneNode, context: RenderContext, isRoot: boolean, parent?: FigmaSceneNode): string {
170+
const className = addCssRule(node, context, ["section"], isRoot, parent);
171171
const tagName = isRoot ? "main" : isButtonLike(node) ? "a" : "section";
172172
const attrs = [
173173
`class="${className}"`,
@@ -178,21 +178,21 @@ function renderContainer(node: FigmaSceneNode, context: RenderContext, isRoot: b
178178
return `<${tagName} ${attrs.join(" ")}>\n${children}\n</${tagName}>`;
179179
}
180180

181-
function renderText(node: FigmaSceneNode, context: RenderContext): string {
182-
const className = addCssRule(node, context, ["text"]);
181+
function renderText(node: FigmaSceneNode, context: RenderContext, parent?: FigmaSceneNode): string {
182+
const className = addCssRule(node, context, ["text"], false, parent);
183183
const content = escapeHtml(node.characters || "");
184184
const tagName = textTagName(node);
185185

186186
return `<${tagName} class="${className}">${content}</${tagName}>`;
187187
}
188188

189-
function renderRectangle(node: FigmaSceneNode, context: RenderContext): string {
190-
const className = addCssRule(node, context, ["shape"]);
189+
function renderRectangle(node: FigmaSceneNode, context: RenderContext, parent?: FigmaSceneNode): string {
190+
const className = addCssRule(node, context, ["shape"], false, parent);
191191
return `<div class="${className}" aria-hidden="true"></div>`;
192192
}
193193

194-
function renderImage(node: FigmaSceneNode, context: RenderContext): string {
195-
const className = addCssRule(node, context, ["image"]);
194+
function renderImage(node: FigmaSceneNode, context: RenderContext, parent?: FigmaSceneNode): string {
195+
const className = addCssRule(node, context, ["image"], false, parent);
196196
const src = resolveImageSource(node, context);
197197
const alt = escapeAttribute(node.image?.alt || node.name || "");
198198

@@ -201,17 +201,17 @@ function renderImage(node: FigmaSceneNode, context: RenderContext): string {
201201

202202
function renderChildren(node: FigmaSceneNode, context: RenderContext): string {
203203
return (node.children || [])
204-
.map((child) => renderNode(child, context))
204+
.map((child) => renderNode(child, context, false, node))
205205
.filter(Boolean)
206206
.join("\n");
207207
}
208208

209-
function addCssRule(node: FigmaSceneNode, context: RenderContext, parts: string[]): string {
209+
function addCssRule(node: FigmaSceneNode, context: RenderContext, parts: string[], isRoot = false, parent?: FigmaSceneNode): string {
210210
const baseClass = toClassName([node.name].concat(parts).join("-"));
211211
const count = context.usedClasses.get(baseClass) || 0;
212212
context.usedClasses.set(baseClass, count + 1);
213213
const className = count === 0 ? baseClass : `${baseClass}-${count + 1}`;
214-
const declarations = cssDeclarations(node);
214+
const declarations = cssDeclarations(node, isRoot, parent);
215215

216216
if (declarations.length) {
217217
context.cssRules.push(`.${className} {\n${declarations.map((rule) => ` ${rule}`).join("\n")}\n}`);
@@ -220,28 +220,43 @@ function addCssRule(node: FigmaSceneNode, context: RenderContext, parts: string[
220220
return className;
221221
}
222222

223-
function cssDeclarations(node: FigmaSceneNode): string[] {
223+
function cssDeclarations(node: FigmaSceneNode, isRoot: boolean, parent?: FigmaSceneNode): string[] {
224224
const declarations: string[] = [];
225225
const fill = firstVisiblePaint(node.fills);
226226
const stroke = firstVisiblePaint(node.strokes);
227+
const positioned = shouldAbsolutelyPosition(node, parent);
227228

229+
if (positioned) {
230+
declarations.push("position: absolute;");
231+
declarations.push(`left: ${formatPx((node.x || 0) - (parent?.x || 0))};`);
232+
declarations.push(`top: ${formatPx((node.y || 0) - (parent?.y || 0))};`);
233+
}
228234
if (node.width !== undefined) declarations.push(`width: ${formatPx(node.width)};`);
229235
if (node.height !== undefined && node.type !== "TEXT") declarations.push(`min-height: ${formatPx(node.height)};`);
230236
if (node.opacity !== undefined && node.opacity < 1) declarations.push(`opacity: ${node.opacity};`);
231237
if (node.cornerRadius !== undefined) declarations.push(`border-radius: ${formatPx(node.cornerRadius)};`);
232-
if (fill) declarations.push(`background: ${paintToCss(fill)};`);
238+
if (fill && node.type === "TEXT") declarations.push(`color: ${paintToCss(fill)};`);
239+
if (fill && node.type !== "TEXT") declarations.push(`background: ${paintToCss(fill)};`);
233240
if (stroke) declarations.push(`border: 1px solid ${paintToCss(stroke)};`);
234241

235242
if (containerTypes.has(node.type)) {
236-
declarations.push("display: flex;");
237-
declarations.push("flex-direction: column;");
238-
declarations.push("gap: 1rem;");
243+
if (!positioned) declarations.push("position: relative;");
244+
if (!hasPositionedChildren(node)) {
245+
declarations.push("display: flex;");
246+
declarations.push("flex-direction: column;");
247+
declarations.push("gap: 1rem;");
248+
}
249+
if (isRoot) {
250+
declarations.push("margin: 0 auto;");
251+
declarations.push("overflow: hidden;");
252+
}
239253
}
240254

241255
if (node.type === "TEXT") {
256+
declarations.push("margin: 0;");
242257
if (node.style?.fontFamily) declarations.push(`font-family: ${quoteFontFamily(node.style.fontFamily)};`);
243258
if (node.style?.fontSize) declarations.push(`font-size: ${formatPx(node.style.fontSize)};`);
244-
if (node.style?.fontWeight) declarations.push(`font-weight: ${node.style.fontWeight};`);
259+
if (node.style?.fontWeight) declarations.push(`font-weight: ${formatFontWeight(node.style.fontWeight)};`);
245260
if (node.style?.lineHeight) declarations.push(`line-height: ${formatLineHeight(node.style.lineHeight)};`);
246261
if (node.style?.textAlignHorizontal) declarations.push(`text-align: ${textAlign(node.style.textAlignHorizontal)};`);
247262
}
@@ -260,6 +275,14 @@ function cssDeclarations(node: FigmaSceneNode): string[] {
260275
return declarations;
261276
}
262277

278+
function shouldAbsolutelyPosition(node: FigmaSceneNode, parent?: FigmaSceneNode): boolean {
279+
return !!parent && node.x !== undefined && node.y !== undefined && hasPositionedChildren(parent);
280+
}
281+
282+
function hasPositionedChildren(node: FigmaSceneNode): boolean {
283+
return (node.children || []).some((child) => child.x !== undefined && child.y !== undefined);
284+
}
285+
263286
function resolveImageSource(node: FigmaSceneNode, context: RenderContext): string {
264287
if (node.image?.src) {
265288
return node.image.src;
@@ -377,6 +400,25 @@ function formatLineHeight(value: number | string): string {
377400
return typeof value === "number" ? formatPx(value) : value;
378401
}
379402

403+
function formatFontWeight(value: number | string): string {
404+
if (typeof value === "number") {
405+
return String(value);
406+
}
407+
408+
const normalized = value.toLowerCase();
409+
if (normalized.includes("thin")) return "100";
410+
if (normalized.includes("extra light") || normalized.includes("ultra light")) return "200";
411+
if (normalized.includes("light")) return "300";
412+
if (normalized.includes("regular") || normalized.includes("book")) return "400";
413+
if (normalized.includes("medium")) return "500";
414+
if (normalized.includes("semi bold") || normalized.includes("semibold") || normalized.includes("demi bold")) return "600";
415+
if (normalized.includes("extra bold") || normalized.includes("ultra bold")) return "800";
416+
if (normalized.includes("bold")) return "700";
417+
if (normalized.includes("black") || normalized.includes("heavy")) return "900";
418+
419+
return value;
420+
}
421+
380422
function textAlign(value: string): string {
381423
const normalized = value.toLowerCase();
382424
return normalized === "justified" ? "justify" : normalized;

plugins/figma-to-wordpress/src/payload.ts

Lines changed: 55 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,12 @@
11
import type { WebsiteArtifact } from "./index";
2-
import type { NormalizedSelection } from "./types";
2+
import type { NormalizedAsset, NormalizedSceneNode, NormalizedSelection } from "./types";
33

44
export type WebsiteArtifactFileRole = "html" | "css" | "js" | "asset" | "metadata";
55

66
export interface WebsiteArtifactBundleFile {
77
path: string;
8-
content: string;
8+
content?: string;
9+
content_base64?: string;
910
role?: WebsiteArtifactFileRole;
1011
mime_type?: string;
1112
}
@@ -27,24 +28,58 @@ export interface WordPressRunnerRequest {
2728
exportedAt: string;
2829
};
2930
goal: string;
31+
figma: FigmaScenegraphPayload;
3032
artifact_bundle: WebsiteArtifactBundle;
3133
}
3234

35+
export interface FigmaScenegraphPayload {
36+
schema: "figma-to-wordpress/scenegraph/v1";
37+
name: string;
38+
exportedAt: string;
39+
root: NormalizedSceneNode;
40+
nodes: NormalizedSceneNode[];
41+
assets: NormalizedAsset[];
42+
}
43+
3344
export function toWebsiteArtifactBundle(artifact: WebsiteArtifact, selection: NormalizedSelection): WebsiteArtifactBundle {
3445
return {
3546
schema: "figma-to-wordpress/website-artifact-bundle/v1",
3647
root: "website/",
3748
entrypoint: "website/index.html",
38-
files: Object.entries(artifact.files).map(([filePath, content]) => ({
39-
path: filePath.startsWith("website/") ? filePath : `website/${filePath}`,
40-
content,
41-
role: fileRole(filePath),
42-
mime_type: mimeType(filePath),
43-
})),
49+
files: Object.entries(artifact.files).map(([filePath, content]) => toWebsiteArtifactBundleFile(filePath, content)),
4450
import_source: "figma-to-wordpress",
4551
};
4652
}
4753

54+
function toWebsiteArtifactBundleFile(filePath: string, content: string): WebsiteArtifactBundleFile {
55+
const file: WebsiteArtifactBundleFile = {
56+
path: filePath.startsWith("website/") ? filePath : `website/${filePath}`,
57+
role: fileRole(filePath),
58+
mime_type: mimeType(filePath),
59+
};
60+
const dataUri = parseDataUri(content);
61+
if (dataUri) {
62+
file.content_base64 = dataUri.contentBase64;
63+
file.mime_type = dataUri.mimeType;
64+
} else {
65+
file.content = content;
66+
}
67+
68+
return file;
69+
}
70+
71+
function parseDataUri(content: string): { mimeType: string; contentBase64: string } | null {
72+
const match = content.match(/^data:([^;,]+);base64,(.+)$/s);
73+
if (!match) {
74+
return null;
75+
}
76+
77+
return {
78+
mimeType: match[1],
79+
contentBase64: match[2],
80+
};
81+
}
82+
4883
export function buildWordPressRunnerRequest(bundle: WebsiteArtifactBundle, selection: NormalizedSelection): WordPressRunnerRequest {
4984
return {
5085
schema: "figma-to-wordpress/runner-request/v1",
@@ -54,10 +89,22 @@ export function buildWordPressRunnerRequest(bundle: WebsiteArtifactBundle, selec
5489
exportedAt: selection.exportedAt,
5590
},
5691
goal: `Import ${selection.name} into WordPress using Static Site Importer inside a browser Playground session.`,
92+
figma: toFigmaScenegraphPayload(selection),
5793
artifact_bundle: bundle,
5894
};
5995
}
6096

97+
function toFigmaScenegraphPayload(selection: NormalizedSelection): FigmaScenegraphPayload {
98+
return {
99+
schema: "figma-to-wordpress/scenegraph/v1",
100+
name: selection.name,
101+
exportedAt: selection.exportedAt,
102+
root: selection.root,
103+
nodes: selection.root.children?.length ? selection.root.children : [selection.root],
104+
assets: selection.assets,
105+
};
106+
}
107+
61108
function fileRole(filePath: string): WebsiteArtifactFileRole {
62109
if (/\.html?$/.test(filePath)) return "html";
63110
if (/\.css$/.test(filePath)) return "css";

0 commit comments

Comments
 (0)