Skip to content

Commit e9e7661

Browse files
authored
Merge pull request #94 from Automattic/fix-figma-server-transform-payload
Send Figma assets to WordPress imports
2 parents 8cd7520 + 1cfafeb commit e9e7661

4 files changed

Lines changed: 125 additions & 28 deletions

File tree

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

Lines changed: 101 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,12 @@ function postToUi(message: PluginToUiMessage) {
99
figma.ui.postMessage(message);
1010
}
1111

12+
type AssetExportFormat = "PNG" | "SVG";
13+
14+
type NormalizeContext = {
15+
assets: Map<string, NormalizedAsset>;
16+
};
17+
1218
function clonePaints(node: SceneNode | PageNode, property: "fills" | "strokes") {
1319
if (property === "fills" && "fills" in node) {
1420
return node.fills === figma.mixed ? "mixed" : node.fills;
@@ -21,20 +27,22 @@ function clonePaints(node: SceneNode | PageNode, property: "fills" | "strokes")
2127
return undefined;
2228
}
2329

24-
function normalizePaints(paints: unknown) {
30+
function normalizePaints(paints: unknown, assetId?: string) {
2531
if (!Array.isArray(paints)) {
2632
return undefined;
2733
}
2834

2935
return paints
3036
.filter((paint) => paint && typeof paint === "object" && "type" in paint)
3137
.map((paint) => {
32-
const typedPaint = paint as SolidPaint;
38+
const typedPaint = paint as Paint;
3339
return {
3440
type: typedPaint.type,
3541
visible: typedPaint.visible,
3642
opacity: typedPaint.opacity,
3743
imageHash: "imageHash" in typedPaint && typeof typedPaint.imageHash === "string" ? typedPaint.imageHash : undefined,
44+
imageRef: "imageHash" in typedPaint && typeof typedPaint.imageHash === "string" ? typedPaint.imageHash : undefined,
45+
asset_id: typedPaint.type === "IMAGE" ? assetId : undefined,
3846
color: "color" in typedPaint ? typedPaint.color : undefined,
3947
};
4048
});
@@ -46,6 +54,32 @@ function hasImagePaint(paints: unknown): boolean {
4654
});
4755
}
4856

57+
function firstImageHash(paints: unknown): string | undefined {
58+
if (!Array.isArray(paints)) {
59+
return undefined;
60+
}
61+
62+
for (const paint of paints) {
63+
if (
64+
paint &&
65+
typeof paint === "object" &&
66+
"type" in paint &&
67+
paint.type === "IMAGE" &&
68+
(!("visible" in paint) || paint.visible !== false) &&
69+
"imageHash" in paint &&
70+
typeof paint.imageHash === "string"
71+
) {
72+
return paint.imageHash;
73+
}
74+
}
75+
76+
return undefined;
77+
}
78+
79+
function isVectorLikeNode(node: SceneNode | PageNode): node is SceneNode {
80+
return ["VECTOR", "STAR", "LINE", "ELLIPSE", "POLYGON", "BOOLEAN_OPERATION"].includes(node.type);
81+
}
82+
4983
function normalizeTextStyle(node: TextNode) {
5084
return {
5185
fontFamily: typeof node.fontName === "object" ? node.fontName.family : undefined,
@@ -82,31 +116,44 @@ function normalizedNumberAssign(node: NormalizedSceneNode, key: "x" | "y" | "wid
82116
}
83117
}
84118

85-
async function normalizeNode(node: SceneNode | PageNode, fallbackType?: string): Promise<NormalizedSceneNode> {
119+
async function normalizeNode(node: SceneNode | PageNode, context: NormalizeContext, fallbackType?: string): Promise<NormalizedSceneNode> {
86120
const bounds = "absoluteBoundingBox" in node ? node.absoluteBoundingBox : null;
87121
const fills = "fills" in node ? clonePaints(node, "fills") : undefined;
88122
const hasImageFill = hasImagePaint(fills);
123+
const shouldExportAsAsset = hasImageFill || isVectorLikeNode(node);
124+
const exportedAsset = shouldExportAsAsset && "exportAsync" in node
125+
? await exportAsset(node as SceneNode, isVectorLikeNode(node) ? "SVG" : "PNG", firstImageHash(fills))
126+
: null;
127+
if (exportedAsset) {
128+
context.assets.set(exportedAsset.id, exportedAsset);
129+
}
89130
const normalized: NormalizedSceneNode = {
90131
id: node.id,
91132
name: node.name,
92-
type: fallbackType || (hasImageFill ? "IMAGE" : node.type),
133+
type: fallbackType || (shouldExportAsAsset && exportedAsset ? "IMAGE" : node.type),
93134
visible: "visible" in node ? node.visible : true,
94135
x: bounds?.x,
95136
y: bounds?.y,
96137
width: bounds?.width,
97138
height: bounds?.height,
98-
fills: normalizePaints(fills),
99-
strokes: "strokes" in node ? normalizePaints(clonePaints(node, "strokes")) : undefined,
139+
absoluteBoundingBox: bounds ? {
140+
x: bounds.x,
141+
y: bounds.y,
142+
width: bounds.width,
143+
height: bounds.height,
144+
} : undefined,
145+
fills: normalizePaints(fills, exportedAsset?.id),
146+
strokes: "strokes" in node ? normalizePaints(clonePaints(node, "strokes"), exportedAsset?.id) : undefined,
100147
};
101148

102-
if (hasImageFill && "exportAsync" in node) {
103-
const asset = await exportAsset(node as SceneNode);
104-
if (asset) {
105-
normalized.image = {
106-
dataUri: asset.dataUrl,
107-
alt: node.name,
108-
};
109-
}
149+
if (exportedAsset) {
150+
normalized.asset_id = exportedAsset.id;
151+
normalized.image = {
152+
asset_id: exportedAsset.id,
153+
imageHash: exportedAsset.imageHash,
154+
dataUri: exportedAsset.dataUrl,
155+
alt: node.name,
156+
};
110157
}
111158

112159
if (node.type === "TEXT") {
@@ -123,7 +170,7 @@ async function normalizeNode(node: SceneNode | PageNode, fallbackType?: string):
123170
}
124171

125172
if ("children" in node) {
126-
normalized.children = await Promise.all(node.children.map((child) => normalizeNode(child)));
173+
normalized.children = await Promise.all(node.children.map((child) => normalizeNode(child, context)));
127174
applyDerivedBounds(normalized);
128175
}
129176

@@ -134,22 +181,51 @@ function bytesToDataUrl(bytes: Uint8Array, mimeType: string) {
134181
return `data:${mimeType};base64,${figma.base64Encode(bytes)}`;
135182
}
136183

137-
async function exportAsset(node: SceneNode): Promise<NormalizedAsset | null> {
184+
function assetId(node: SceneNode, format: AssetExportFormat) {
185+
return `${node.id}:${format.toLowerCase()}`;
186+
}
187+
188+
function assetPath(node: SceneNode, format: AssetExportFormat) {
189+
const extension = format === "SVG" ? "svg" : "png";
190+
const slug = node.name
191+
.trim()
192+
.toLowerCase()
193+
.replace(/[^a-z0-9]+/g, "-")
194+
.replace(/^-+|-+$/g, "") || "asset";
195+
196+
return `assets/${slug}-${node.id.replace(/[^a-z0-9]+/gi, "-")}.${extension}`;
197+
}
198+
199+
function contentBase64FromDataUrl(dataUrl: string) {
200+
return dataUrl.replace(/^data:[^;,]+;base64,/, "");
201+
}
202+
203+
async function exportAsset(node: SceneNode, format: AssetExportFormat, imageHash?: string): Promise<NormalizedAsset | null> {
138204
if (!("exportAsync" in node)) {
139205
return null;
140206
}
141207

208+
const mimeType = format === "SVG" ? "image/svg+xml" : "image/png";
209+
142210
try {
143-
const bytes = await node.exportAsync({ format: "PNG", constraint: { type: "SCALE", value: 1 } });
211+
const bytes = format === "SVG"
212+
? await node.exportAsync({ format: "SVG" })
213+
: await node.exportAsync({ format: "PNG", constraint: { type: "SCALE", value: 1 } });
214+
const dataUrl = bytesToDataUrl(bytes, mimeType);
144215

145216
return {
146-
id: node.id,
147-
name: `${node.name}.png`,
148-
format: "PNG",
149-
dataUrl: bytesToDataUrl(bytes, "image/png"),
217+
id: assetId(node, format),
218+
name: `${node.name}.${format.toLowerCase()}`,
219+
format,
220+
dataUrl,
221+
mime_type: mimeType,
222+
content_base64: contentBase64FromDataUrl(dataUrl),
223+
path: assetPath(node, format),
224+
node_id: node.id,
225+
imageHash,
150226
};
151227
} catch (error) {
152-
console.warn("Unable to export selected node", error);
228+
console.warn("Unable to export Figma asset", error);
153229
return null;
154230
}
155231
}
@@ -159,7 +235,8 @@ async function getDocument(): Promise<NormalizedDocument> {
159235
await figma.loadAllPagesAsync();
160236
}
161237

162-
const pages = await Promise.all(figma.root.children.map((page) => normalizeNode(page, "PAGE")));
238+
const context: NormalizeContext = { assets: new Map() };
239+
const pages = await Promise.all(figma.root.children.map((page) => normalizeNode(page, context, "PAGE")));
163240
const root: NormalizedSceneNode = {
164241
id: figma.root.id,
165242
name: figma.root.name || "Figma document",
@@ -175,7 +252,7 @@ async function getDocument(): Promise<NormalizedDocument> {
175252
type: "DOCUMENT",
176253
exportedAt: new Date().toISOString(),
177254
root,
178-
assets: [],
255+
assets: Array.from(context.assets.values()),
179256
};
180257
}
181258

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

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ export interface FigmaSceneNode {
2424
visible?: boolean;
2525
style?: FigmaTextStyle;
2626
image?: FigmaImageSource;
27+
asset_id?: string;
2728
href?: string;
2829
}
2930

@@ -32,6 +33,9 @@ export interface FigmaPaint {
3233
visible?: boolean;
3334
color?: string | FigmaRgbColor;
3435
opacity?: number;
36+
imageHash?: string;
37+
imageRef?: string;
38+
asset_id?: string;
3539
}
3640

3741
export interface FigmaRgbColor {
@@ -50,6 +54,8 @@ export interface FigmaTextStyle {
5054
}
5155

5256
export interface FigmaImageSource {
57+
asset_id?: string;
58+
imageHash?: string;
5359
src?: string;
5460
dataUri?: string;
5561
alt?: string;

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

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,6 @@ export interface WordPressRunnerRequest {
2929
};
3030
goal: string;
3131
figma: FigmaScenegraphPayload;
32-
artifact_bundle: WebsiteArtifactBundle;
3332
}
3433

3534
export interface FigmaScenegraphPayload {
@@ -80,7 +79,7 @@ function parseDataUri(content: string): { mimeType: string; contentBase64: strin
8079
};
8180
}
8281

83-
export function buildWordPressRunnerRequest(bundle: WebsiteArtifactBundle, selection: NormalizedSelection): WordPressRunnerRequest {
82+
export function buildWordPressRunnerRequest(_bundle: WebsiteArtifactBundle, selection: NormalizedSelection): WordPressRunnerRequest {
8483
return {
8584
schema: "figma-to-wordpress/runner-request/v1",
8685
source: {
@@ -90,7 +89,6 @@ export function buildWordPressRunnerRequest(bundle: WebsiteArtifactBundle, selec
9089
},
9190
goal: `Import ${selection.name} into WordPress using Static Site Importer inside a browser Playground session.`,
9291
figma: toFigmaScenegraphPayload(selection),
93-
artifact_bundle: bundle,
9492
};
9593
}
9694

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

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,13 @@
11
export type NormalizedAsset = {
22
id: string;
33
name: string;
4-
format: "PNG";
4+
format: "PNG" | "SVG";
55
dataUrl: string;
6+
mime_type?: string;
7+
content_base64?: string;
8+
path?: string;
9+
node_id?: string;
10+
imageHash?: string;
611
};
712

813
export type NormalizedSceneNode = {
@@ -14,6 +19,12 @@ export type NormalizedSceneNode = {
1419
y?: number;
1520
width?: number;
1621
height?: number;
22+
absoluteBoundingBox?: {
23+
x: number;
24+
y: number;
25+
width: number;
26+
height: number;
27+
};
1728
fills?: NormalizedPaint[];
1829
strokes?: NormalizedPaint[];
1930
characters?: string;
@@ -27,10 +38,13 @@ export type NormalizedSceneNode = {
2738
textAlignHorizontal?: string;
2839
};
2940
image?: {
41+
asset_id?: string;
42+
imageHash?: string;
3043
src?: string;
3144
dataUri?: string;
3245
alt?: string;
3346
};
47+
asset_id?: string;
3448
href?: string;
3549
children?: NormalizedSceneNode[];
3650
};
@@ -40,6 +54,8 @@ export type NormalizedPaint = {
4054
visible?: boolean;
4155
opacity?: number;
4256
imageHash?: string;
57+
imageRef?: string;
58+
asset_id?: string;
4359
color?: {
4460
r: number;
4561
g: number;

0 commit comments

Comments
 (0)