Skip to content
151 changes: 151 additions & 0 deletions packages/backend/src/altNodes/jsonNodeConversion.smoke.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,151 @@
import { describe, expect, it } from "vitest";
import { nodesToJSON } from "./jsonNodeConversion";
import { htmlMain } from "../html/htmlMain";
import { setBackendHost, type BackendHost } from "../host";
import { resolveStyledTextSegmentsFromRest } from "../common/restStyledTextSegments";
import type { PluginSettings } from "types";

const settings: PluginSettings = {
framework: "HTML",
showLayerNames: false,
useOldPluginVersion2025: false,
responsiveRoot: false,
flutterGenerationMode: "snippet",
swiftUIGenerationMode: "snippet",
composeGenerationMode: "snippet",
roundTailwindValues: true,
roundTailwindColors: true,
useColorVariables: false,
customTailwindPrefix: "",
embedImages: false,
embedVectors: false,
htmlGenerationMode: "html",
tailwindGenerationMode: "jsx",
baseFontSize: 16,
useTailwind4: true,
thresholdPercent: 15,
baseFontFamily: "",
fontFamilyCustomConfig: {},
};

// A captured-shape REST document: a FRAME containing one TEXT node, in the
// same shape Figma's `GET /v1/files/:key/nodes` returns — no plugin-only
// fields anywhere.
const frameDocument = {
id: "1:1",
name: "Card",
type: "FRAME",
visible: true,
absoluteBoundingBox: { x: 0, y: 0, width: 320, height: 120 },
layoutMode: "VERTICAL",
itemSpacing: 8,
paddingLeft: 16,
paddingRight: 16,
paddingTop: 16,
paddingBottom: 16,
fills: [
{
type: "SOLID",
color: { r: 1, g: 1, b: 1, a: 1 },
visible: true,
opacity: 1,
},
],
strokes: [],
cornerRadius: 8,
children: [
{
id: "1:2",
name: "Title",
type: "TEXT",
visible: true,
absoluteBoundingBox: { x: 16, y: 16, width: 288, height: 24 },
fills: [
{
type: "SOLID",
color: { r: 0, g: 0, b: 0, a: 1 },
visible: true,
opacity: 1,
},
],
strokes: [],
characters: "Hello from REST JSON",
style: { fontFamily: "Inter", fontSize: 18, fontWeight: 700 },
characterStyleOverrides: [],
styleOverrideTable: {},
lineTypes: ["NONE"],
lineIndentations: [0],
},
],
} as const;

const restBackedHost: BackendHost = {
mixed: Symbol("figma.mixed"),
getNodeExport: async () => {
throw new Error("getNodeExport should not be reached by this fixture");
},
getNodeDocument: async (id) => {
if (id === frameDocument.id) return frameDocument as any;
if (id === frameDocument.children[0].id)
return frameDocument.children[0] as any;
throw new Error(`No fixture document for node ${id}`);
},
getStyledTextSegments: async (id, fields) => {
const textNode = frameDocument.children.find((child) => child.id === id);
if (!textNode) throw new Error(`No fixture TEXT node for ${id}`);
return resolveStyledTextSegmentsFromRest(textNode as any, fields);
},
};

describe("default conversion pipeline outside the Figma plugin sandbox", () => {
it("has no figma global in this environment", () => {
expect(typeof (globalThis as any).figma).toBe("undefined");
});

it("converts a REST JSON document to HTML via a REST-backed BackendHost, with no figma global", async () => {
setBackendHost(restBackedHost);
try {
const altNodes = await nodesToJSON([{ id: frameDocument.id }], settings);
expect(altNodes).toHaveLength(1);

const output = await htmlMain(altNodes as any, settings);
expect(output.html).toContain("Hello from REST JSON");
} finally {
setBackendHost(null);
}
});

it("throws a clear error instead of a figma ReferenceError when no host is configured", async () => {
setBackendHost(null);
await expect(
nodesToJSON([{ id: frameDocument.id }], settings),
).rejects.toThrow(/No backend host configured/);
});

it("does not corrupt a cached document when converting the same node twice", async () => {
// A REST-backed host commonly caches the parsed document and returns
// the same object reference on every call — conversion must not mutate
// that shared object, or a second conversion of the same node would
// see the first conversion's already-transformed output as its input.
const cachedDocument = structuredClone(frameDocument);
const cachingHost: BackendHost = {
...restBackedHost,
getNodeDocument: async (id) => {
if (id === cachedDocument.id) return cachedDocument as any;
throw new Error(`No fixture document for node ${id}`);
},
};

setBackendHost(cachingHost);
try {
const first = await nodesToJSON([{ id: cachedDocument.id }], settings);
const second = await nodesToJSON([{ id: cachedDocument.id }], settings);

expect(second).toEqual(first);
expect(cachedDocument.type).toBe("FRAME");
expect(cachedDocument.children).toHaveLength(1);
} finally {
setBackendHost(null);
}
});
});
127 changes: 49 additions & 78 deletions packages/backend/src/altNodes/jsonNodeConversion.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { HasGeometryTrait, Node, Paint } from "../api_types";
import { calculateRectangleFromBoundingBox } from "../common/commonPosition";
import { isLikelyIcon } from "./iconDetection";
import { AltNode } from "../alt_api_types";
import { getBackendHost } from "../host";

// Performance tracking counters
export let getNodeByIdAsyncTime = 0;
Expand Down Expand Up @@ -256,18 +257,19 @@ function adjustChildrenOrder(node: any) {
}

/**
* Recursively process both JSON node and Figma node to update with data not available in JSON
* This now includes the functionality from convertNodeToAltNode
* Recursively process a JSON node to fill in data the REST export doesn't
* carry. This now includes the functionality from convertNodeToAltNode.
* Operates purely on the JSON tree — the two pieces of data that used to
* require a live Figma node (styled text runs, the initial JSON_REST_V1
* document) now go through `getBackendHost()`, keyed by node id.
* @param jsonNode The JSON node to process
* @param figmaNode The corresponding Figma node
* @param settings Plugin settings
* @param parentNode Optional parent node reference to set
* @param parentCumulativeRotation Optional parent cumulative rotation to inherit
* @returns Potentially modified jsonNode, array of nodes (for inlined groups), or null
*/
const processNodePair = async (
jsonNode: AltNode,
figmaNode: SceneNode,
settings: PluginSettings,
parentNode?: AltNode,
parentCumulativeRotation: number = 0,
Expand Down Expand Up @@ -295,13 +297,7 @@ const processNodePair = async (
) {
// Convert to rectangle
(jsonNode as any).type = "RECTANGLE";
return processNodePair(
jsonNode,
figmaNode,
settings,
parentNode,
parentCumulativeRotation,
);
return processNodePair(jsonNode, settings, parentNode, parentCumulativeRotation);
}

if ("rotation" in jsonNode && jsonNode.rotation) {
Expand All @@ -312,30 +308,15 @@ const processNodePair = async (
if (nodeType === "GROUP" && jsonNode.children) {
const processedChildren = [];

if (
Array.isArray(jsonNode.children) &&
figmaNode &&
"children" in figmaNode
) {
if (Array.isArray(jsonNode.children)) {
// Get visible JSON children (filters out nodes with visible: false)
const visibleJsonChildren = jsonNode.children.filter(
(child) => child.visible !== false,
) as AltNode[];

// Map figma children to their IDs for matching
const figmaChildrenById = new Map();
figmaNode.children.forEach((child) => {
figmaChildrenById.set(child.id, child);
});

// Process all visible JSON children that have matching Figma nodes
for (const child of visibleJsonChildren) {
const figmaChild = figmaChildrenById.get(child.id);
if (!figmaChild) continue; // Skip if no matching Figma node found

const processedChild = await processNodePair(
child,
figmaChild,
settings,
parentNode, // The group's parent
parentCumulativeRotation + (jsonNode.rotation || 0),
Expand Down Expand Up @@ -380,25 +361,26 @@ const processNodePair = async (
: `${cleanName}_${count.toString().padStart(2, "0")}`;

// Handle text-specific properties
if (figmaNode.type === "TEXT") {
if (nodeType === "TEXT") {
const getSegmentsStart = Date.now();
getStyledTextSegmentsCalls++;
let styledTextSegments = figmaNode.getStyledTextSegments([
"fontName",
"fills",
"fontSize",
"fontWeight",
"hyperlink",
"indentation",
"letterSpacing",
"lineHeight",
"listOptions",
"textCase",
"textDecoration",
"textStyleId",
"fillStyleId",
"openTypeFeatures",
]);
let styledTextSegments =
(await getBackendHost().getStyledTextSegments?.(jsonNode.id, [
"fontName",
"fills",
"fontSize",
"fontWeight",
"hyperlink",
"indentation",
"letterSpacing",
"lineHeight",
"listOptions",
"textCase",
"textDecoration",
"textStyleId",
"fillStyleId",
"openTypeFeatures",
])) ?? [];
getStyledTextSegmentsTime += Date.now() - getSegmentsStart;

// Assign unique IDs to each segment
Expand Down Expand Up @@ -551,39 +533,27 @@ const processNodePair = async (
jsonNode.layoutSizingVertical = "FIXED";
}

// Process children recursively if both have children
// Process children recursively
if (
"children" in jsonNode &&
jsonNode.children &&
Array.isArray(jsonNode.children) &&
"children" in figmaNode
Array.isArray(jsonNode.children)
) {
// Get only visible JSON children
const visibleJsonChildren = jsonNode.children.filter(
(child) => child.visible !== false,
) as AltNode[];

// Create a map of figma children by ID for easier matching
const figmaChildrenById = new Map();
figmaNode.children.forEach((child) => {
figmaChildrenById.set(child.id, child);
});

const cumulative =
parentCumulativeRotation +
(jsonNode.type === "GROUP" ? jsonNode.rotation || 0 : 0);

// Process children and handle potential null returns
const processedChildren = [];

// Process all visible JSON children that have matching Figma nodes
for (const child of visibleJsonChildren) {
const figmaChild = figmaChildrenById.get(child.id);
if (!figmaChild) continue; // Skip if no matching Figma node found

const processedChild = await processNodePair(
child,
figmaChild,
settings,
jsonNode,
cumulative,
Expand Down Expand Up @@ -625,13 +595,16 @@ const processNodePair = async (
};

/**
* Convert Figma nodes to JSON format with parent references added
* @param nodes The Figma nodes to convert to JSON
* Convert Figma nodes to JSON format with parent references added. Takes
* just node ids — not live `SceneNode`s — since the REST document for each
* id now comes from `getBackendHost().getNodeDocument()`, which a
* REST-backed host can satisfy from JSON it already has.
* @param nodes The nodes to convert to JSON, identified by id
* @param settings Plugin settings
* @returns JSON representation of the nodes with parent references
*/
export const nodesToJSON = async (
nodes: ReadonlyArray<SceneNode>,
nodes: ReadonlyArray<{ id: string }>,
settings: PluginSettings,
): Promise<Node[]> => {
// Reset name counters for each conversion
Expand All @@ -640,17 +613,24 @@ export const nodesToJSON = async (
// First get the JSON representation of nodes with rotation handling
const nodeResults = await Promise.all(
nodes.map(async (node) => {
// Export node to JSON
const nodeDoc = (
(await node.exportAsync({
format: "JSON_REST_V1",
})) as any
).document;
// Fetch the REST document for this node. Conversion mutates the
// document in place (type, rotation, computed geometry, children) —
// clone it first so a host that returns a cached/shared object isn't
// corrupted by this or a later conversion of the same node.
const fetchedDoc = await getBackendHost().getNodeDocument?.(node.id);
if (!fetchedDoc) {
throw new Error(
`No backend host getNodeDocument() available for node ${node.id}. ` +
"Call setBackendHost() with a host that implements it before " +
"running nodesToJSON() outside the Figma plugin sandbox.",
);
}
const nodeDoc = structuredClone(fetchedDoc) as any;

let nodeCumulativeRotation = 0;

// Wire GROUPs into FRAME.
if (node.type === "GROUP") {
if (nodeDoc.type === "GROUP") {
nodeDoc.type = "FRAME";

// Fix rotation for children.
Expand All @@ -667,26 +647,17 @@ export const nodesToJSON = async (
}),
);

if (nodes.length > 0) {
console.log("[debug] initial node summary", {
id: nodes[0].id,
type: nodes[0].type,
name: nodes[0].name,
});
}

console.log(
`[benchmark][inside nodesToJSON] JSON_REST_V1 export: ${Date.now() - exportJsonStart}ms`,
);

// Now process each top-level node pair (JSON node + Figma node)
// Now process each top-level node
const processNodesStart = Date.now();
const result: Node[] = [];

for (let i = 0; i < nodes.length; i++) {
const processedNode = await processNodePair(
nodeResults[i].nodeDoc,
nodes[i],
settings,
undefined,
nodeResults[i].nodeCumulativeRotation,
Expand Down
Loading