From 0972c4a038b6c5242e91a103cc59a2b83559f3e2 Mon Sep 17 00:00:00 2001 From: Hein van Vlastuin Date: Tue, 18 Aug 2026 13:19:37 +0200 Subject: [PATCH 1/9] Add injectable backend host for headless conversion packages/backend previously assumed a live Figma plugin sandbox: the figma.mixed sentinel and figma.getNodeByIdAsync()/exportAsync() were referenced directly throughout the conversion path. That makes the package unusable anywhere without a running plugin, including a server converting already-fetched REST API JSON. setBackendHost() lets a caller supply mixed/getNodeExport/ getVariableName; when unset, getBackendHost() falls back to wrapping the real figma global, so the existing plugin app is unaffected. --- packages/backend/src/common/commonRadius.ts | 3 +- packages/backend/src/common/commonStroke.ts | 3 +- .../backend/src/common/exportAsyncProxy.ts | 21 +------ packages/backend/src/compose/composeMain.ts | 3 +- .../backend/src/compose/composeTextBuilder.ts | 9 +-- .../backend/src/flutter/flutterContainer.ts | 5 +- packages/backend/src/host.ts | 59 +++++++++++++++++++ .../backend/src/html/builderImpl/htmlColor.ts | 3 +- .../backend/src/html/htmlDefaultBuilder.ts | 3 +- packages/backend/src/html/htmlTextBuilder.ts | 3 +- packages/backend/src/index.ts | 6 ++ .../swiftui/builderImpl/swiftuiTextWeight.ts | 3 +- packages/backend/src/swiftui/swiftuiMain.ts | 3 +- .../backend/src/tailwind/conversionTables.ts | 7 ++- .../src/tailwind/tailwindTextBuilder.ts | 3 +- 15 files changed, 97 insertions(+), 37 deletions(-) create mode 100644 packages/backend/src/host.ts diff --git a/packages/backend/src/common/commonRadius.ts b/packages/backend/src/common/commonRadius.ts index 5f8070eb..ac301b57 100644 --- a/packages/backend/src/common/commonRadius.ts +++ b/packages/backend/src/common/commonRadius.ts @@ -1,4 +1,5 @@ import { CornerRadius } from "types"; +import { getMixed } from "../host"; export const getCommonRadius = (node: SceneNode): CornerRadius => { if ("rectangleCornerRadii" in node) { @@ -22,7 +23,7 @@ export const getCommonRadius = (node: SceneNode): CornerRadius => { if ( "cornerRadius" in node && - node.cornerRadius !== figma.mixed && + node.cornerRadius !== getMixed() && node.cornerRadius ) { return { all: node.cornerRadius }; diff --git a/packages/backend/src/common/commonStroke.ts b/packages/backend/src/common/commonStroke.ts index d82e1380..8116ba22 100644 --- a/packages/backend/src/common/commonStroke.ts +++ b/packages/backend/src/common/commonStroke.ts @@ -1,4 +1,5 @@ import { BorderSide } from "types"; +import { getMixed } from "../host"; export const commonStroke = ( node: SceneNode, @@ -23,7 +24,7 @@ export const commonStroke = ( right: node.strokeRightWeight / divideBy, bottom: node.strokeBottomWeight / divideBy, }; - } else if (node.strokeWeight !== figma.mixed && node.strokeWeight !== 0) { + } else if (node.strokeWeight !== getMixed() && node.strokeWeight !== 0) { return { all: node.strokeWeight / divideBy }; } diff --git a/packages/backend/src/common/exportAsyncProxy.ts b/packages/backend/src/common/exportAsyncProxy.ts index 04af43ab..c5a6cef2 100644 --- a/packages/backend/src/common/exportAsyncProxy.ts +++ b/packages/backend/src/common/exportAsyncProxy.ts @@ -1,4 +1,5 @@ import { postConversionStart } from "../messaging"; +import { getBackendHost } from "../host"; let isRunning = false; @@ -21,25 +22,7 @@ export const exportAsyncProxy = async < await new Promise((resolve) => setTimeout(resolve, 30)); } - const figmaNode = (await figma.getNodeByIdAsync(node.id)) as ExportMixin; - // console.log("getting figma id for", figmaNode); - - if (figmaNode.exportAsync === undefined) { - // console.log(node); - throw new TypeError( - "Something went wrong. This node doesn't have an exportAsync() function. Maybe check the type before calling this function.", - ); - } - - // The following is necessary for typescript to not lose its mind. - let result; - if (settings.format === "SVG_STRING") { - result = await figmaNode.exportAsync(settings as ExportSettingsSVGString); - // } else if (settings.format === "JSON_REST_V1") { - // result = await node.exportAsync(settings as ExportSettingsREST); - } else { - result = await figmaNode.exportAsync(settings as ExportSettings); - } + const result = await getBackendHost().getNodeExport(node.id, settings); isRunning = false; return result as T; diff --git a/packages/backend/src/compose/composeMain.ts b/packages/backend/src/compose/composeMain.ts index 18fc7b93..b7c8bf57 100644 --- a/packages/backend/src/compose/composeMain.ts +++ b/packages/backend/src/compose/composeMain.ts @@ -10,6 +10,7 @@ import { } from "./builderImpl/composeAutoLayout"; import { PluginSettings } from "types"; import { addWarning } from "../common/commonConversionWarnings"; +import { getMixed } from "../host"; import { getVisibleNodes } from "../common/nodeVisibility"; let localSettings: PluginSettings; @@ -204,7 +205,7 @@ const composeContainer = (node: SceneNode, child: string): string => { if ( "fills" in node && - node.fills !== figma.mixed && + node.fills !== getMixed() && retrieveTopFill(node.fills as any)?.type === "IMAGE" ) { addWarning("Image fills are replaced with placeholders in Compose"); diff --git a/packages/backend/src/compose/composeTextBuilder.ts b/packages/backend/src/compose/composeTextBuilder.ts index 1b0245a3..585666b5 100644 --- a/packages/backend/src/compose/composeTextBuilder.ts +++ b/packages/backend/src/compose/composeTextBuilder.ts @@ -3,6 +3,7 @@ import { numberToFixedString } from "../common/numToAutoFixed"; import { ComposeDefaultBuilder } from "./composeDefaultBuilder"; import { rgbTo6hex } from "../common/color"; import { retrieveTopFill } from "../common/retrieveFill"; +import { getMixed } from "../host"; // Cache static mappings for performance const FONT_WEIGHT_MAP: Record = { @@ -73,7 +74,7 @@ export class ComposeTextBuilder extends ComposeDefaultBuilder { // Font size if ( - node.fontSize !== figma.mixed && + node.fontSize !== getMixed() && typeof node.fontSize === "number" && node.fontSize > 0 ) { @@ -82,7 +83,7 @@ export class ComposeTextBuilder extends ComposeDefaultBuilder { // Font weight if ( - node.fontWeight !== figma.mixed && + node.fontWeight !== getMixed() && typeof node.fontWeight === "number" ) { const weight = this.mapFontWeight(node.fontWeight); @@ -99,7 +100,7 @@ export class ComposeTextBuilder extends ComposeDefaultBuilder { } // Letter spacing - if (node.letterSpacing !== figma.mixed && node.letterSpacing !== 0) { + if (node.letterSpacing !== getMixed() && node.letterSpacing !== 0) { const spacing = commonLetterSpacing( node.letterSpacing, node.fontSize as number, @@ -109,7 +110,7 @@ export class ComposeTextBuilder extends ComposeDefaultBuilder { // Line height if ( - node.lineHeight !== figma.mixed && + node.lineHeight !== getMixed() && typeof node.lineHeight === "object" && node.lineHeight.unit === "PIXELS" ) { diff --git a/packages/backend/src/flutter/flutterContainer.ts b/packages/backend/src/flutter/flutterContainer.ts index 703ca7c8..7f441f5e 100644 --- a/packages/backend/src/flutter/flutterContainer.ts +++ b/packages/backend/src/flutter/flutterContainer.ts @@ -14,6 +14,7 @@ import { numberToFixedString } from "../common/numToAutoFixed"; import { getCommonRadius } from "../common/commonRadius"; import { commonStroke } from "../common/commonStroke"; import { generateRotationMatrix } from "./builderImpl/flutterBlend"; +import { getMixed } from "../host"; export const flutterContainer = ( node: SceneNode, @@ -138,7 +139,7 @@ const getDecoration = ( shapeDecorationBorder = generatePolygonBorder(node); } else if (node.type === "ELLIPSE") { shapeDecorationBorder = generateOvalBorder(node); - } else if ("strokeWeight" in node && node.strokeWeight !== figma.mixed) { + } else if ("strokeWeight" in node && node.strokeWeight !== getMixed()) { shapeDecorationBorder = skipDefaultProperty( generateRoundedRectangleBorder(node), "RoundedRectangleBorder()", @@ -217,7 +218,7 @@ const generateStarBorder = (node: StarNode): string => { const innerRadiusRatio = node.innerRadius; const cornerRadius = node.cornerRadius; - const pointRounding = cornerRadius === figma.mixed ? 0 : cornerRadius; + const pointRounding = cornerRadius === getMixed() ? 0 : cornerRadius; const valleyRounding = 0; // Assuming no valley rounding, modify if needed const rotation = 0; // Assuming no rotation, modify if needed const squash = 0; // Assuming no squash, modify if needed diff --git a/packages/backend/src/host.ts b/packages/backend/src/host.ts new file mode 100644 index 00000000..b3679546 --- /dev/null +++ b/packages/backend/src/host.ts @@ -0,0 +1,59 @@ +/** + * Seam that lets `packages/backend` run outside the Figma plugin sandbox. + * By default every function here reads the live `figma` global, exactly as + * before; a host embedding this package in a non-plugin environment (no + * `figma` global — e.g. a server converting REST API JSON) must call + * `setBackendHost()` once before running any conversion. + */ + +export interface ExportRequest { + format?: string; + constraint?: { type: string; value: number }; +} + +export interface BackendHost { + mixed: symbol; + getNodeExport: ( + id: string, + settings: ExportRequest, + ) => Promise; + getVariableName?: (id: string) => Promise; +} + +function defaultHost(): BackendHost | null { + if (typeof figma === "undefined") return null; + + return { + mixed: figma.mixed as unknown as symbol, + getNodeExport: async (id, settings) => { + const node = (await figma.getNodeByIdAsync(id)) as ExportMixin; + if (node.exportAsync === undefined) { + throw new TypeError( + `Node ${id} doesn't have an exportAsync() function.`, + ); + } + return node.exportAsync(settings as ExportSettings); + }, + getVariableName: async (id) => + (await figma.variables.getVariableByIdAsync(id))?.name ?? null, + }; +} + +let overrideHost: BackendHost | null = null; + +export const setBackendHost = (host: BackendHost | null): void => { + overrideHost = host; +}; + +export const getBackendHost = (): BackendHost => { + const host = overrideHost ?? defaultHost(); + if (!host) { + throw new Error( + "No backend host configured. Call setBackendHost() before running " + + "conversion outside the Figma plugin sandbox.", + ); + } + return host; +}; + +export const getMixed = (): symbol => getBackendHost().mixed; diff --git a/packages/backend/src/html/builderImpl/htmlColor.ts b/packages/backend/src/html/builderImpl/htmlColor.ts index a941b7bc..67318a7a 100644 --- a/packages/backend/src/html/builderImpl/htmlColor.ts +++ b/packages/backend/src/html/builderImpl/htmlColor.ts @@ -1,6 +1,7 @@ import { numberToFixedString } from "../../common/numToAutoFixed"; import { retrieveTopFill } from "../../common/retrieveFill"; import { GradientPaint, Paint } from "../../api_types"; +import { getMixed } from "../../host"; /** * Helper to process a color with variable binding if present @@ -240,7 +241,7 @@ export const htmlDiamondGradient = (fill: GradientPaint) => { export const buildBackgroundValues = ( paintArray: ReadonlyArray | PluginAPI["mixed"], ): string => { - if (paintArray === figma.mixed) { + if (paintArray === getMixed()) { return ""; } diff --git a/packages/backend/src/html/htmlDefaultBuilder.ts b/packages/backend/src/html/htmlDefaultBuilder.ts index b31dbd9d..31fc35df 100644 --- a/packages/backend/src/html/htmlDefaultBuilder.ts +++ b/packages/backend/src/html/htmlDefaultBuilder.ts @@ -28,6 +28,7 @@ import { formatStyleAttribute, } from "../common/commonFormatAttributes"; import { HTMLSettings } from "types"; +import { getMixed } from "../host"; import { cssCollection, generateUniqueClassName, @@ -300,7 +301,7 @@ export class HtmlDefaultBuilder { this.addStyles(formatWithJSX("background", this.isJSX, backgroundValues)); // Add blend mode property if multiple fills exist with different blend modes - if (paintArray !== figma.mixed) { + if (paintArray !== getMixed()) { const blendModes = this.buildBackgroundBlendModes(paintArray); if (blendModes) { this.addStyles( diff --git a/packages/backend/src/html/htmlTextBuilder.ts b/packages/backend/src/html/htmlTextBuilder.ts index 5cf6baad..31792744 100644 --- a/packages/backend/src/html/htmlTextBuilder.ts +++ b/packages/backend/src/html/htmlTextBuilder.ts @@ -10,6 +10,7 @@ import { commonLineHeight, } from "../common/commonTextHeightSpacing"; import { HTMLSettings, StyledTextSegmentSubset } from "types"; +import { getMixed } from "../host"; import { cssCollection, generateUniqueClassName, @@ -136,7 +137,7 @@ export class HtmlTextBuilder extends HtmlDefaultBuilder { } fontSize(node: TextNode, isUI = false): this { - if (node.fontSize !== figma.mixed) { + if (node.fontSize !== getMixed()) { const value = isUI ? Math.min(node.fontSize, 24) : node.fontSize; this.addStyles(formatWithJSX("font-size", this.isJSX, value)); } diff --git a/packages/backend/src/index.ts b/packages/backend/src/index.ts index 3a636fb1..0717c40c 100644 --- a/packages/backend/src/index.ts +++ b/packages/backend/src/index.ts @@ -10,3 +10,9 @@ export { } from "./zipGenerator"; export { run } from "./code"; export * from "./messaging"; +export { + setBackendHost, + getBackendHost, + type BackendHost, + type ExportRequest, +} from "./host"; diff --git a/packages/backend/src/swiftui/builderImpl/swiftuiTextWeight.ts b/packages/backend/src/swiftui/builderImpl/swiftuiTextWeight.ts index 240fd167..7ba8bbfb 100644 --- a/packages/backend/src/swiftui/builderImpl/swiftuiTextWeight.ts +++ b/packages/backend/src/swiftui/builderImpl/swiftuiTextWeight.ts @@ -1,9 +1,10 @@ +import { getMixed } from "../../host"; /** * Large (Default) * https://developer.apple.com/design/human-interface-guidelines/ios/visual-design/typography/ */ export const swiftuiFontMatcher = (node: TextNode): string => { - if (node.fontSize === figma.mixed) { + if (node.fontSize === getMixed()) { return ""; } diff --git a/packages/backend/src/swiftui/swiftuiMain.ts b/packages/backend/src/swiftui/swiftuiMain.ts index 7fffe3f4..5ecf10a4 100644 --- a/packages/backend/src/swiftui/swiftuiMain.ts +++ b/packages/backend/src/swiftui/swiftuiMain.ts @@ -9,6 +9,7 @@ import { PluginSettings } from "types"; import { addWarning } from "../common/commonConversionWarnings"; import { getVisibleNodes } from "../common/nodeVisibility"; import { getPlaceholderImage } from "../common/images"; +import { getMixed } from "../host"; let localSettings: PluginSettings; let previousExecutionCache: string[]; @@ -160,7 +161,7 @@ const getSwiftUIImage = (node: SceneNode): string => { const fills = node.fills; const fill = - fills !== figma.mixed && Array.isArray(fills) + fills !== getMixed() && Array.isArray(fills) ? [...fills].reverse().find((candidate) => candidate.visible !== false) : undefined; if (!fill || fill.type !== "IMAGE") { diff --git a/packages/backend/src/tailwind/conversionTables.ts b/packages/backend/src/tailwind/conversionTables.ts index 4532d608..de5c9148 100644 --- a/packages/backend/src/tailwind/conversionTables.ts +++ b/packages/backend/src/tailwind/conversionTables.ts @@ -3,6 +3,7 @@ import { numberToFixedString } from "../common/numToAutoFixed"; import { localTailwindSettings } from "./tailwindMain"; import { config } from "./tailwindConfig"; import { rgbTo6hex } from "../common/color"; +import { getBackendHost } from "../host"; export const nearestValue = (goal: number, array: Array): number => { return array.reduce((prev, curr) => { @@ -161,10 +162,10 @@ export const nearestColorFromRgb = (color: RGB) => { }; export const variableToColorName = async (id: string) => { + const name = await getBackendHost().getVariableName?.(id); return ( - (await figma.variables.getVariableByIdAsync(id))?.name - .replaceAll("/", "-") - .replaceAll(" ", "-") || id.toLowerCase().replaceAll(":", "-") + name?.replaceAll("/", "-").replaceAll(" ", "-") || + id.toLowerCase().replaceAll(":", "-") ); }; diff --git a/packages/backend/src/tailwind/tailwindTextBuilder.ts b/packages/backend/src/tailwind/tailwindTextBuilder.ts index e8e16dcd..674ab1f4 100644 --- a/packages/backend/src/tailwind/tailwindTextBuilder.ts +++ b/packages/backend/src/tailwind/tailwindTextBuilder.ts @@ -14,6 +14,7 @@ import { TailwindDefaultBuilder } from "./tailwindDefaultBuilder"; import { config } from "./tailwindConfig"; import { StyledTextSegmentSubset } from "types"; import { localTailwindSettings } from "./tailwindMain"; +import { getMixed } from "../host"; export class TailwindTextBuilder extends TailwindDefaultBuilder { getTextSegments(node: TextNode): { @@ -166,7 +167,7 @@ export class TailwindTextBuilder extends TailwindDefaultBuilder { * example: italic */ fontStyle(node: TextNode): this { - if (node.fontName !== figma.mixed) { + if (node.fontName !== getMixed()) { const lowercaseStyle = node.fontName.style.toLowerCase(); if (lowercaseStyle.match("italic")) { From 3b67ed50c411fcb13d05fd51c4687fd8cc04b402 Mon Sep 17 00:00:00 2001 From: Hein van Vlastuin Date: Tue, 18 Aug 2026 13:21:34 +0200 Subject: [PATCH 2/9] Preserve mixed-sentinel narrowing in getMixed() getBackendHost().mixed is plain symbol (BackendHost.mixed avoids requiring @figma/plugin-typings for third-party host authors), but callers rely on TypeScript narrowing T | typeof figma.mixed unions after comparing against it. A plain symbol return type broke that narrowing wherever the branch result was used arithmetically afterward. --- packages/backend/src/host.ts | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/packages/backend/src/host.ts b/packages/backend/src/host.ts index b3679546..f416602a 100644 --- a/packages/backend/src/host.ts +++ b/packages/backend/src/host.ts @@ -56,4 +56,10 @@ export const getBackendHost = (): BackendHost => { return host; }; -export const getMixed = (): symbol => getBackendHost().mixed; +// Cast back to the plugin API's literal sentinel type (BackendHost.mixed is +// plain `symbol` so host authors outside this package don't need +// @figma/plugin-typings) — callers compare fontSize/fills/etc against this, +// and TypeScript only narrows `T | typeof figma.mixed` unions away from a +// value typed as the literal `typeof figma.mixed`, not generic `symbol`. +export const getMixed = (): typeof figma.mixed => + getBackendHost().mixed as unknown as typeof figma.mixed; From e0d73a245479a13e0efc62b559b55c98a2ccea4a Mon Sep 17 00:00:00 2001 From: Hein van Vlastuin Date: Tue, 18 Aug 2026 17:18:28 +0200 Subject: [PATCH 3/9] Add docstrings to the new host seam Satisfies the PR's docstring-coverage check: every exported symbol in host.ts now has JSDoc, exportAsyncProxy's existing comment is converted to JSDoc format, and the touched variableToColorName gets one too. --- .../backend/src/common/exportAsyncProxy.ts | 2 +- packages/backend/src/host.ts | 38 ++++++++++++++++--- .../backend/src/tailwind/conversionTables.ts | 5 +++ 3 files changed, 39 insertions(+), 6 deletions(-) diff --git a/packages/backend/src/common/exportAsyncProxy.ts b/packages/backend/src/common/exportAsyncProxy.ts index c5a6cef2..86d22632 100644 --- a/packages/backend/src/common/exportAsyncProxy.ts +++ b/packages/backend/src/common/exportAsyncProxy.ts @@ -3,7 +3,7 @@ import { getBackendHost } from "../host"; let isRunning = false; -/* +/** * This is a wrapper for exportAsync() This allows us to pass a message to the UI every time * this rather costly operation gets run so that it can display a loading message. This avoids * showing a loading message every time anything in the UI changes and only showing it when diff --git a/packages/backend/src/host.ts b/packages/backend/src/host.ts index f416602a..e35dc765 100644 --- a/packages/backend/src/host.ts +++ b/packages/backend/src/host.ts @@ -6,20 +6,35 @@ * `setBackendHost()` once before running any conversion. */ +/** Options accepted by {@link BackendHost.getNodeExport}. */ export interface ExportRequest { format?: string; constraint?: { type: string; value: number }; } +/** + * Everything the conversion path needs from a live Figma document, made + * pluggable. Implement this to run `packages/backend` outside the plugin + * sandbox — e.g. backed by Figma's REST API instead of `figma.*`. + */ export interface BackendHost { + /** Stands in for the plugin API's `figma.mixed` sentinel. */ mixed: symbol; + /** Replaces `figma.getNodeByIdAsync(id).exportAsync(settings)`. */ getNodeExport: ( id: string, settings: ExportRequest, ) => Promise; + /** Replaces `figma.variables.getVariableByIdAsync(id)?.name`. */ getVariableName?: (id: string) => Promise; } +/** + * The host used when nobody has called `setBackendHost()`: wraps the real + * `figma` global, so existing plugin code keeps working unchanged. Returns + * `null` when no `figma` global exists (e.g. a server process), in which + * case the caller must have configured a host explicitly. + */ function defaultHost(): BackendHost | null { if (typeof figma === "undefined") return null; @@ -41,10 +56,20 @@ function defaultHost(): BackendHost | null { let overrideHost: BackendHost | null = null; +/** + * Registers the host the conversion path should use going forward. Pass + * `null` to revert to wrapping the real `figma` global. + */ export const setBackendHost = (host: BackendHost | null): void => { overrideHost = host; }; +/** + * Returns the active host: whatever was passed to `setBackendHost()`, or a + * wrapper around the real `figma` global if nothing was set. Throws if + * neither is available — i.e. running outside the plugin without having + * configured a host. + */ export const getBackendHost = (): BackendHost => { const host = overrideHost ?? defaultHost(); if (!host) { @@ -56,10 +81,13 @@ export const getBackendHost = (): BackendHost => { return host; }; -// Cast back to the plugin API's literal sentinel type (BackendHost.mixed is -// plain `symbol` so host authors outside this package don't need -// @figma/plugin-typings) — callers compare fontSize/fills/etc against this, -// and TypeScript only narrows `T | typeof figma.mixed` unions away from a -// value typed as the literal `typeof figma.mixed`, not generic `symbol`. +/** + * The active host's mixed-value sentinel, cast back to the plugin API's + * literal sentinel type. `BackendHost.mixed` is plain `symbol` so host + * authors outside this package don't need `@figma/plugin-typings`, but + * callers compare `fontSize`/`fills`/etc. against this value, and + * TypeScript only narrows `T | typeof figma.mixed` unions away from a value + * typed as the literal `typeof figma.mixed` — not generic `symbol`. + */ export const getMixed = (): typeof figma.mixed => getBackendHost().mixed as unknown as typeof figma.mixed; diff --git a/packages/backend/src/tailwind/conversionTables.ts b/packages/backend/src/tailwind/conversionTables.ts index de5c9148..5f5f1f66 100644 --- a/packages/backend/src/tailwind/conversionTables.ts +++ b/packages/backend/src/tailwind/conversionTables.ts @@ -161,6 +161,11 @@ export const nearestColorFromRgb = (color: RGB) => { return { name, value }; }; +/** + * Turns a bound Figma Variable ID into a Tailwind-safe class name fragment, + * falling back to a sanitized form of the ID itself if the variable's name + * can't be resolved (e.g. `getVariableName` isn't implemented by the host). + */ export const variableToColorName = async (id: string) => { const name = await getBackendHost().getVariableName?.(id); return ( From 79dcfed0aa4a9ad2e4a1ad4862a61dc9ac25a18a Mon Sep 17 00:00:00 2001 From: Hein van Vlastuin Date: Tue, 18 Aug 2026 17:22:10 +0200 Subject: [PATCH 4/9] Type ExportRequest as Figma's real export settings union ExportRequest was a loose custom interface (format?: string, constraint?: {type: string, value: number}) accepting any string, including invalid formats or {}. defaultHost() then cast that value to ExportSettings to call exportAsync(), bypassing the type checker entirely. Aliasing ExportRequest to ExportSettings | ExportSettingsSVGString (the actual overloaded exportAsync() parameter type) removes the cast: narrowing on settings.format now statically selects the right overload, the same way the pre-refactor exportAsyncProxy.ts did. --- packages/backend/src/host.ts | 20 ++++++++++++++------ 1 file changed, 14 insertions(+), 6 deletions(-) diff --git a/packages/backend/src/host.ts b/packages/backend/src/host.ts index e35dc765..569b0345 100644 --- a/packages/backend/src/host.ts +++ b/packages/backend/src/host.ts @@ -6,11 +6,13 @@ * `setBackendHost()` once before running any conversion. */ -/** Options accepted by {@link BackendHost.getNodeExport}. */ -export interface ExportRequest { - format?: string; - constraint?: { type: string; value: number }; -} +/** + * Options accepted by {@link BackendHost.getNodeExport} — the same + * discriminated union `figma.*.exportAsync()` accepts, so a host + * implementation gets exhaustive `format` checking for free and this + * package never needs to cast a request past the type checker. + */ +export type ExportRequest = ExportSettings | ExportSettingsSVGString; /** * Everything the conversion path needs from a live Figma document, made @@ -47,7 +49,13 @@ function defaultHost(): BackendHost | null { `Node ${id} doesn't have an exportAsync() function.`, ); } - return node.exportAsync(settings as ExportSettings); + // exportAsync is overloaded on the SVG_STRING/ExportSettings split + // (string vs. Uint8Array return); narrowing on `format` — rather than + // casting — is what selects the right overload here. + if (settings.format === "SVG_STRING") { + return node.exportAsync(settings); + } + return node.exportAsync(settings); }, getVariableName: async (id) => (await figma.variables.getVariableByIdAsync(id))?.name ?? null, From 76de4ba09e0b6a5e7e0e950baea5bfa465b08f92 Mon Sep 17 00:00:00 2001 From: Hein van Vlastuin Date: Tue, 18 Aug 2026 21:36:08 +0200 Subject: [PATCH 5/9] Make Figma plugin context fully replaceable by the rest api context --- .../altNodes/jsonNodeConversion.smoke.test.ts | 124 ++++++++++ .../src/altNodes/jsonNodeConversion.ts | 125 ++++------ .../src/common/restStyledTextSegments.test.ts | 217 ++++++++++++++++++ .../src/common/restStyledTextSegments.ts | 182 +++++++++++++++ packages/backend/src/host.ts | 48 ++++ packages/backend/src/index.ts | 2 + 6 files changed, 624 insertions(+), 74 deletions(-) create mode 100644 packages/backend/src/altNodes/jsonNodeConversion.smoke.test.ts create mode 100644 packages/backend/src/common/restStyledTextSegments.test.ts create mode 100644 packages/backend/src/common/restStyledTextSegments.ts diff --git a/packages/backend/src/altNodes/jsonNodeConversion.smoke.test.ts b/packages/backend/src/altNodes/jsonNodeConversion.smoke.test.ts new file mode 100644 index 00000000..1ea98114 --- /dev/null +++ b/packages/backend/src/altNodes/jsonNodeConversion.smoke.test.ts @@ -0,0 +1,124 @@ +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/); + }); +}); diff --git a/packages/backend/src/altNodes/jsonNodeConversion.ts b/packages/backend/src/altNodes/jsonNodeConversion.ts index d9b23f32..b5d186c3 100644 --- a/packages/backend/src/altNodes/jsonNodeConversion.ts +++ b/packages/backend/src/altNodes/jsonNodeConversion.ts @@ -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; @@ -256,10 +257,12 @@ 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 @@ -267,7 +270,6 @@ function adjustChildrenOrder(node: any) { */ const processNodePair = async ( jsonNode: AltNode, - figmaNode: SceneNode, settings: PluginSettings, parentNode?: AltNode, parentCumulativeRotation: number = 0, @@ -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) { @@ -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), @@ -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 @@ -551,24 +533,17 @@ 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); @@ -576,14 +551,9 @@ const processNodePair = async ( // 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, @@ -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, + nodes: ReadonlyArray<{ id: string }>, settings: PluginSettings, ): Promise => { // Reset name counters for each conversion @@ -640,17 +613,22 @@ 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 + const nodeDoc = (await getBackendHost().getNodeDocument?.( + node.id, + )) as any; + if (!nodeDoc) { + 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.", + ); + } let nodeCumulativeRotation = 0; // Wire GROUPs into FRAME. - if (node.type === "GROUP") { + if (nodeDoc.type === "GROUP") { nodeDoc.type = "FRAME"; // Fix rotation for children. @@ -667,11 +645,11 @@ export const nodesToJSON = async ( }), ); - if (nodes.length > 0) { + if (nodeResults.length > 0) { console.log("[debug] initial node summary", { - id: nodes[0].id, - type: nodes[0].type, - name: nodes[0].name, + id: nodeResults[0].nodeDoc.id, + type: nodeResults[0].nodeDoc.type, + name: nodeResults[0].nodeDoc.name, }); } @@ -679,14 +657,13 @@ export const nodesToJSON = async ( `[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, diff --git a/packages/backend/src/common/restStyledTextSegments.test.ts b/packages/backend/src/common/restStyledTextSegments.test.ts new file mode 100644 index 00000000..e852ee3e --- /dev/null +++ b/packages/backend/src/common/restStyledTextSegments.test.ts @@ -0,0 +1,217 @@ +import { describe, expect, it } from "vitest"; +import { resolveStyledTextSegmentsFromRest } from "./restStyledTextSegments"; +import type { TextNode } from "../api_types"; + +const baseNode = ( + overrides: Partial< + Pick< + TextNode, + | "characters" + | "style" + | "characterStyleOverrides" + | "styleOverrideTable" + | "lineTypes" + | "lineIndentations" + > + >, +) => ({ + characters: "", + style: { fontFamily: "Inter", fontSize: 16, fontWeight: 400 }, + characterStyleOverrides: [], + styleOverrideTable: {}, + lineTypes: [], + lineIndentations: [], + ...overrides, +}); + +describe("resolveStyledTextSegmentsFromRest", () => { + it("returns no segments for empty text", () => { + expect( + resolveStyledTextSegmentsFromRest(baseNode({}), ["fontSize"]), + ).toEqual([]); + }); + + it("returns a single run covering the whole string when there are no overrides", () => { + const segments = resolveStyledTextSegmentsFromRest( + baseNode({ characters: "hello" }), + ["fontSize", "fontWeight"], + ); + + expect(segments).toEqual([ + { + characters: "hello", + start: 0, + end: 5, + fontSize: 16, + fontWeight: 400, + }, + ]); + }); + + it("splits into runs at override boundaries", () => { + const segments = resolveStyledTextSegmentsFromRest( + baseNode({ + characters: "bold text", + characterStyleOverrides: [1, 1, 1, 1, 0, 0, 0, 0, 0], + styleOverrideTable: { "1": { fontWeight: 700 } }, + }), + ["fontWeight"], + ); + + expect(segments).toEqual([ + { characters: "bold", start: 0, end: 4, fontWeight: 700 }, + { characters: " text", start: 4, end: 9, fontWeight: 400 }, + ]); + }); + + it("falls back to the base style when the override array is shorter than the text", () => { + const segments = resolveStyledTextSegmentsFromRest( + baseNode({ + characters: "ab", + characterStyleOverrides: [1], + styleOverrideTable: { "1": { fontWeight: 700 } }, + }), + ["fontWeight"], + ); + + expect(segments).toEqual([ + { characters: "a", start: 0, end: 1, fontWeight: 700 }, + { characters: "b", start: 1, end: 2, fontWeight: 400 }, + ]); + }); + + it("joins indentation and listOptions from the line at the run's start", () => { + const segments = resolveStyledTextSegmentsFromRest( + baseNode({ + characters: "one\ntwo", + lineTypes: ["ORDERED", "UNORDERED"], + lineIndentations: [0, 2], + }), + ["indentation", "listOptions"], + ); + + expect(segments).toEqual([ + { + characters: "one\ntwo", + start: 0, + end: 7, + indentation: 0, + listOptions: { type: "ORDERED" }, + }, + ]); + }); + + it("translates FONT_SIZE_% line height into the plugin's PERCENT shape", () => { + const segments = resolveStyledTextSegmentsFromRest( + baseNode({ + characters: "x", + style: { + fontFamily: "Inter", + lineHeightUnit: "FONT_SIZE_%", + lineHeightPercentFontSize: 150, + }, + }), + ["lineHeight"], + ); + + expect(segments).toEqual([ + { + characters: "x", + start: 0, + end: 1, + lineHeight: { value: 150, unit: "PERCENT" }, + }, + ]); + }); + + it("translates INTRINSIC_% line height into the plugin's AUTO shape", () => { + const segments = resolveStyledTextSegmentsFromRest( + baseNode({ + characters: "x", + style: { fontFamily: "Inter", lineHeightUnit: "INTRINSIC_%" }, + }), + ["lineHeight"], + ); + + expect(segments).toEqual([ + { characters: "x", start: 0, end: 1, lineHeight: { unit: "AUTO" } }, + ]); + }); + + it("defaults letterSpacing to a zero PIXELS value when absent", () => { + const segments = resolveStyledTextSegmentsFromRest( + baseNode({ characters: "x" }), + ["letterSpacing"], + ); + + expect(segments).toEqual([ + { + characters: "x", + start: 0, + end: 1, + letterSpacing: { value: 0, unit: "PIXELS" }, + }, + ]); + }); + + it("leaves textStyleId and fillStyleId undefined since the REST schema has no equivalent", () => { + const segments = resolveStyledTextSegmentsFromRest( + baseNode({ characters: "x" }), + ["textStyleId", "fillStyleId"], + ); + + expect(segments).toEqual([ + { + characters: "x", + start: 0, + end: 1, + textStyleId: undefined, + fillStyleId: undefined, + }, + ]); + }); + + it("converts hyperlink url/nodeID into the plugin's value field", () => { + const segments = resolveStyledTextSegmentsFromRest( + baseNode({ + characters: "x", + style: { + fontFamily: "Inter", + hyperlink: { type: "URL", url: "https://example.com" }, + }, + }), + ["hyperlink"], + ); + + expect(segments).toEqual([ + { + characters: "x", + start: 0, + end: 1, + hyperlink: { type: "URL", value: "https://example.com" }, + }, + ]); + }); + + it("converts opentypeFlags 1/0 map into a boolean map", () => { + const segments = resolveStyledTextSegmentsFromRest( + baseNode({ + characters: "x", + style: { + fontFamily: "Inter", + opentypeFlags: { LIGA: 1, SMCP: 0 }, + }, + }), + ["openTypeFeatures"], + ); + + expect(segments).toEqual([ + { + characters: "x", + start: 0, + end: 1, + openTypeFeatures: { LIGA: true, SMCP: false }, + }, + ]); + }); +}); diff --git a/packages/backend/src/common/restStyledTextSegments.ts b/packages/backend/src/common/restStyledTextSegments.ts new file mode 100644 index 00000000..96fbe531 --- /dev/null +++ b/packages/backend/src/common/restStyledTextSegments.ts @@ -0,0 +1,182 @@ +import type { TextNode, TypeStyle } from "../api_types"; +import type { StyledTextSegmentField } from "../host"; +import type { StyledTextSegmentSubset } from "types"; + +type LineType = TextNode["lineTypes"][number]; + +type FullSegmentValues = { + characters: string; + start: number; + end: number; + fontName: { family: string; style: string }; + fontSize: number; + fontWeight: number; + fills: TypeStyle["fills"]; + hyperlink: { type: "URL" | "NODE"; value: string } | null; + indentation: number; + letterSpacing: { value: number; unit: "PIXELS" }; + lineHeight: + | { value: number; unit: "PIXELS" | "PERCENT" } + | { unit: "AUTO" }; + listOptions: { type: LineType }; + textCase: + | "ORIGINAL" + | "UPPER" + | "LOWER" + | "TITLE" + | "SMALL_CAPS" + | "SMALL_CAPS_FORCED"; + textDecoration: "NONE" | "UNDERLINE" | "STRIKETHROUGH"; + textStyleId: undefined; + fillStyleId: undefined; + openTypeFeatures: Record; +}; + +const mergeStyle = ( + base: TypeStyle, + override: TypeStyle | undefined, +): TypeStyle => (override ? { ...base, ...override } : base); + +const lineIndexPerCharacter = (characters: string): number[] => { + const lines: number[] = []; + let line = 0; + for (const ch of characters) { + lines.push(line); + if (ch === "\n") line++; + } + return lines; +}; + +const translateLineHeight = ( + style: TypeStyle, +): FullSegmentValues["lineHeight"] => { + switch (style.lineHeightUnit) { + case "FONT_SIZE_%": + return { + value: style.lineHeightPercentFontSize ?? 100, + unit: "PERCENT", + }; + case "INTRINSIC_%": + return { unit: "AUTO" }; + case "PIXELS": + default: + return { value: style.lineHeightPx ?? 0, unit: "PIXELS" }; + } +}; + +const buildFullSegment = ( + characters: string, + start: number, + end: number, + style: TypeStyle, + lineIndex: number, + lineTypes: ReadonlyArray, + lineIndentations: ReadonlyArray, +): FullSegmentValues => ({ + characters, + start, + end, + fontName: { + family: style.fontFamily ?? "", + style: style.fontStyle ?? (style.italic ? "Italic" : "Regular"), + }, + fontSize: style.fontSize ?? 0, + fontWeight: style.fontWeight ?? 400, + fills: style.fills ?? [], + hyperlink: style.hyperlink + ? { + type: style.hyperlink.type, + value: style.hyperlink.url ?? style.hyperlink.nodeID ?? "", + } + : null, + indentation: lineIndentations[lineIndex] ?? 0, + letterSpacing: { value: style.letterSpacing ?? 0, unit: "PIXELS" }, + lineHeight: translateLineHeight(style), + listOptions: { type: lineTypes[lineIndex] ?? "NONE" }, + textCase: style.textCase ?? "ORIGINAL", + textDecoration: style.textDecoration ?? "NONE", + // The REST API only exposes resolved TypeStyle values, never the id of + // the TextStyle/PaintStyle a run is linked to — there is no field on + // TypeStyle that carries it, so these can't be reconstructed from JSON. + textStyleId: undefined, + fillStyleId: undefined, + openTypeFeatures: Object.fromEntries( + Object.entries(style.opentypeFlags ?? {}).map(([feature, flag]) => [ + feature, + flag === 1, + ]), + ), +}); + +type RestTextSegmentSource = Pick< + TextNode, + | "characters" + | "style" + | "characterStyleOverrides" + | "styleOverrideTable" + | "lineTypes" + | "lineIndentations" +>; + +/** + * Pure REST-JSON equivalent of `figmaNode.getStyledTextSegments(fields)` — + * decodes a TEXT node's `characterStyleOverrides`/`styleOverrideTable` into + * per-run segments instead of calling the live plugin API. Groups + * consecutive characters that share an override index into one run, merges + * that run's style with the node's base `style`, and joins `lineTypes`/ + * `lineIndentations` by line for `listOptions`/`indentation`. + * + * `textStyleId`/`fillStyleId` are always `undefined` — the REST API has no + * field carrying them. `fontName.style` is approximated from `fontStyle`/ + * `italic` since the plugin's resolved display string (e.g. "Bold Italic") + * isn't present either. + */ +export const resolveStyledTextSegmentsFromRest = ( + node: RestTextSegmentSource, + fields: StyledTextSegmentField[], +): StyledTextSegmentSubset[] => { + const { characters } = node; + if (characters.length === 0) return []; + + const lineIndex = lineIndexPerCharacter(characters); + const overrides = node.characterStyleOverrides ?? []; + const keys = ["characters", "start", "end", ...fields] as const; + + const segments: StyledTextSegmentSubset[] = []; + let runStart = 0; + let runOverride = overrides[0] ?? 0; + + const flushRun = (end: number) => { + const overrideStyle = + runOverride === 0 + ? undefined + : node.styleOverrideTable[String(runOverride)]; + const merged = mergeStyle(node.style, overrideStyle); + const full = buildFullSegment( + characters.slice(runStart, end), + runStart, + end, + merged, + lineIndex[runStart] ?? 0, + node.lineTypes, + node.lineIndentations, + ); + const picked: Record = {}; + for (const key of keys) { + picked[key] = (full as unknown as Record)[key]; + } + segments.push(picked as unknown as StyledTextSegmentSubset); + }; + + for (let i = 1; i < characters.length; i++) { + const index = overrides[i] ?? 0; + if (index !== runOverride) { + flushRun(i); + runStart = i; + runOverride = index; + } + } + flushRun(characters.length); + + return segments; +}; diff --git a/packages/backend/src/host.ts b/packages/backend/src/host.ts index 569b0345..0c13f7ae 100644 --- a/packages/backend/src/host.ts +++ b/packages/backend/src/host.ts @@ -1,3 +1,6 @@ +import type { Node } from "./api_types"; +import type { StyledTextSegmentSubset } from "types"; + /** * Seam that lets `packages/backend` run outside the Figma plugin sandbox. * By default every function here reads the live `figma` global, exactly as @@ -14,6 +17,12 @@ */ export type ExportRequest = ExportSettings | ExportSettingsSVGString; +/** The field list accepted by `figma.*.getStyledTextSegments()`. */ +export type StyledTextSegmentField = keyof Omit< + StyledTextSegment, + "characters" | "start" | "end" +>; + /** * Everything the conversion path needs from a live Figma document, made * pluggable. Implement this to run `packages/backend` outside the plugin @@ -29,6 +38,24 @@ export interface BackendHost { ) => Promise; /** Replaces `figma.variables.getVariableByIdAsync(id)?.name`. */ getVariableName?: (id: string) => Promise; + /** + * Replaces `figma.getNodeByIdAsync(id).exportAsync({format:"JSON_REST_V1"}).document` + * — the REST-JSON document for a node subtree. A REST-backed host can + * usually satisfy this from JSON it already fetched, with no further call. + */ + getNodeDocument?: (id: string) => Promise; + /** + * Replaces `figma.getNodeByIdAsync(id).getStyledTextSegments(fields)` for + * TEXT nodes. Figma's REST API has no direct equivalent call, but a + * REST-backed host can derive segments from a TEXT node's + * `characterStyleOverrides`/`styleOverrideTable` — see + * `common/restStyledTextSegments.ts`. Hosts that leave this undefined + * degrade to unstyled text runs rather than failing the conversion. + */ + getStyledTextSegments?: ( + id: string, + fields: StyledTextSegmentField[], + ) => Promise; } /** @@ -59,6 +86,27 @@ function defaultHost(): BackendHost | null { }, getVariableName: async (id) => (await figma.variables.getVariableByIdAsync(id))?.name ?? null, + getNodeDocument: async (id) => { + const node = (await figma.getNodeByIdAsync(id)) as ExportMixin; + if (node.exportAsync === undefined) { + throw new TypeError( + `Node ${id} doesn't have an exportAsync() function.`, + ); + } + const exported = (await node.exportAsync({ + format: "JSON_REST_V1", + })) as unknown as { document: Node }; + return exported.document; + }, + getStyledTextSegments: async (id, fields) => { + const node = await figma.getNodeByIdAsync(id); + if (!node || node.type !== "TEXT") { + throw new TypeError(`Node ${id} is not a TEXT node.`); + } + return node.getStyledTextSegments( + fields, + ) as unknown as StyledTextSegmentSubset[]; + }, }; } diff --git a/packages/backend/src/index.ts b/packages/backend/src/index.ts index 0717c40c..2cf8c714 100644 --- a/packages/backend/src/index.ts +++ b/packages/backend/src/index.ts @@ -9,10 +9,12 @@ export { replaceProjectImagePlaceholders, } from "./zipGenerator"; export { run } from "./code"; +export { nodesToJSON } from "./altNodes/jsonNodeConversion"; export * from "./messaging"; export { setBackendHost, getBackendHost, type BackendHost, type ExportRequest, + type StyledTextSegmentField, } from "./host"; From b0b1c65eef7025d2ce791935f1dc95201163741a Mon Sep 17 00:00:00 2001 From: Hein van Vlastuin Date: Tue, 18 Aug 2026 21:51:01 +0200 Subject: [PATCH 6/9] Clone the fetched REST document and drop the node-name debug log MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit nodesToJSON() passed getBackendHost().getNodeDocument()'s return value straight into processNodePair(), which mutates it in place (type, rotation, computed geometry, children). A REST-backed host that caches and reuses the same document object across calls would have that cache corrupted by the first conversion, silently feeding an already- transformed document into the second. The unconditional debug log also included nodeDoc.name — a Figma layer name, which can carry customer/business-sensitive text. Harmless in the plugin's own devtools console; not harmless once this runs server-side and gets centrally logged. --- .../altNodes/jsonNodeConversion.smoke.test.ts | 27 +++++++++++++++++++ .../src/altNodes/jsonNodeConversion.ts | 20 +++++--------- 2 files changed, 34 insertions(+), 13 deletions(-) diff --git a/packages/backend/src/altNodes/jsonNodeConversion.smoke.test.ts b/packages/backend/src/altNodes/jsonNodeConversion.smoke.test.ts index 1ea98114..4650bf41 100644 --- a/packages/backend/src/altNodes/jsonNodeConversion.smoke.test.ts +++ b/packages/backend/src/altNodes/jsonNodeConversion.smoke.test.ts @@ -121,4 +121,31 @@ describe("default conversion pipeline outside the Figma plugin sandbox", () => { 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); + } + }); }); diff --git a/packages/backend/src/altNodes/jsonNodeConversion.ts b/packages/backend/src/altNodes/jsonNodeConversion.ts index b5d186c3..865caf39 100644 --- a/packages/backend/src/altNodes/jsonNodeConversion.ts +++ b/packages/backend/src/altNodes/jsonNodeConversion.ts @@ -613,17 +613,19 @@ export const nodesToJSON = async ( // First get the JSON representation of nodes with rotation handling const nodeResults = await Promise.all( nodes.map(async (node) => { - // Fetch the REST document for this node - const nodeDoc = (await getBackendHost().getNodeDocument?.( - node.id, - )) as any; - if (!nodeDoc) { + // 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; @@ -645,14 +647,6 @@ export const nodesToJSON = async ( }), ); - if (nodeResults.length > 0) { - console.log("[debug] initial node summary", { - id: nodeResults[0].nodeDoc.id, - type: nodeResults[0].nodeDoc.type, - name: nodeResults[0].nodeDoc.name, - }); - } - console.log( `[benchmark][inside nodesToJSON] JSON_REST_V1 export: ${Date.now() - exportJsonStart}ms`, ); From f2fb8485ebb02a11e10e10454bd01b3344d21cfc Mon Sep 17 00:00:00 2001 From: Hein van Vlastuin Date: Tue, 18 Aug 2026 21:51:05 +0200 Subject: [PATCH 7/9] Reset isRunning in a finally block so a rejected export doesn't stick MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit If getNodeExport() rejected, control never reached the line that reset isRunning to false. Every export after the first failure would then skip postConversionStart() — the loading-state message just silently stops appearing, with no error to point at why. --- .../src/common/exportAsyncProxy.test.ts | 56 +++++++++++++++++++ .../backend/src/common/exportAsyncProxy.ts | 10 ++-- 2 files changed, 62 insertions(+), 4 deletions(-) create mode 100644 packages/backend/src/common/exportAsyncProxy.test.ts diff --git a/packages/backend/src/common/exportAsyncProxy.test.ts b/packages/backend/src/common/exportAsyncProxy.test.ts new file mode 100644 index 00000000..59a10e1b --- /dev/null +++ b/packages/backend/src/common/exportAsyncProxy.test.ts @@ -0,0 +1,56 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { exportAsyncProxy } from "./exportAsyncProxy"; +import { setBackendHost, type BackendHost } from "../host"; + +const postConversionStart = vi.hoisted(() => vi.fn()); +vi.mock("../messaging", () => ({ postConversionStart })); + +const node = { id: "1:1" } as SceneNode; +const settings: ExportSettings = { + format: "PNG", + constraint: { type: "SCALE", value: 1 }, +}; + +afterEach(() => { + setBackendHost(null); + postConversionStart.mockClear(); +}); + +describe("exportAsyncProxy", () => { + it("resolves with the host's export result", async () => { + const bytes = new Uint8Array([1, 2, 3]); + setBackendHost({ + mixed: Symbol("mixed"), + getNodeExport: async () => bytes, + } satisfies BackendHost); + + await expect(exportAsyncProxy(node, settings)).resolves.toBe(bytes); + }); + + it("resets isRunning after a rejected export, instead of leaving later exports silently un-flagged", async () => { + setBackendHost({ + mixed: Symbol("mixed"), + getNodeExport: async () => { + throw new Error("export failed"); + }, + } satisfies BackendHost); + + await expect(exportAsyncProxy(node, settings)).rejects.toThrow( + "export failed", + ); + expect(postConversionStart).toHaveBeenCalledTimes(1); + + setBackendHost({ + mixed: Symbol("mixed"), + getNodeExport: async () => new Uint8Array([9]), + } satisfies BackendHost); + + await expect(exportAsyncProxy(node, settings)).resolves.toEqual( + new Uint8Array([9]), + ); + // If isRunning were stuck `true` from the earlier rejection, this + // second call would skip postConversionStart() entirely — it's called + // again here, which is the observable proof isRunning was reset. + expect(postConversionStart).toHaveBeenCalledTimes(2); + }); +}); diff --git a/packages/backend/src/common/exportAsyncProxy.ts b/packages/backend/src/common/exportAsyncProxy.ts index 86d22632..3212c001 100644 --- a/packages/backend/src/common/exportAsyncProxy.ts +++ b/packages/backend/src/common/exportAsyncProxy.ts @@ -22,8 +22,10 @@ export const exportAsyncProxy = async < await new Promise((resolve) => setTimeout(resolve, 30)); } - const result = await getBackendHost().getNodeExport(node.id, settings); - - isRunning = false; - return result as T; + try { + const result = await getBackendHost().getNodeExport(node.id, settings); + return result as T; + } finally { + isRunning = false; + } }; From 272299da86881245d6b0ff4823bb89a7db8b5e76 Mon Sep 17 00:00:00 2001 From: Hein van Vlastuin Date: Tue, 18 Aug 2026 21:51:09 +0200 Subject: [PATCH 8/9] Fix two correctness bugs in the REST styled-text run resolver MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit lineIndexPerCharacter() walked characters with for...of (one entry per Unicode code point), but characterStyleOverrides indices and .slice() elsewhere operate on UTF-16 code units. Text with an astral character (e.g. an emoji) before a newline threw those offsets out of sync, so a run starting at or after the newline could read the wrong line's indentation/listOptions. Separately, run-splitting only triggered on a characterStyleOverrides change. indentation/listOptions can change at a line boundary with no accompanying style override, and the resolver was applying the first line's metadata to every later line in the run — Figma's own getStyledTextSegments() splits at those boundaries too. --- .../src/common/restStyledTextSegments.test.ts | 81 ++++++++++++++++++- .../src/common/restStyledTextSegments.ts | 21 ++++- 2 files changed, 98 insertions(+), 4 deletions(-) diff --git a/packages/backend/src/common/restStyledTextSegments.test.ts b/packages/backend/src/common/restStyledTextSegments.test.ts index e852ee3e..70f07ef2 100644 --- a/packages/backend/src/common/restStyledTextSegments.test.ts +++ b/packages/backend/src/common/restStyledTextSegments.test.ts @@ -80,7 +80,7 @@ describe("resolveStyledTextSegmentsFromRest", () => { ]); }); - it("joins indentation and listOptions from the line at the run's start", () => { + it("splits into a run per line when indentation or listOptions changes at a line boundary", () => { const segments = resolveStyledTextSegmentsFromRest( baseNode({ characters: "one\ntwo", @@ -90,14 +90,93 @@ describe("resolveStyledTextSegmentsFromRest", () => { ["indentation", "listOptions"], ); + expect(segments).toEqual([ + { + characters: "one\n", + start: 0, + end: 4, + indentation: 0, + listOptions: { type: "ORDERED" }, + }, + { + characters: "two", + start: 4, + end: 7, + indentation: 2, + listOptions: { type: "UNORDERED" }, + }, + ]); + }); + + it("keeps one run across a line boundary when indentation and listOptions are unchanged", () => { + const segments = resolveStyledTextSegmentsFromRest( + baseNode({ + characters: "one\ntwo", + lineTypes: ["NONE", "NONE"], + lineIndentations: [0, 0], + }), + ["indentation", "listOptions"], + ); + expect(segments).toEqual([ { characters: "one\ntwo", start: 0, end: 7, indentation: 0, + listOptions: { type: "NONE" }, + }, + ]); + }); + + it("indexes line metadata by UTF-16 code unit, not Unicode code point", () => { + // "😀" is a surrogate pair — 1 code point, 2 UTF-16 units. The override + // boundary below lands exactly on the "\n" (UTF-16 index 2). A + // code-point-indexed line array is one short there and misreads that + // run as already being on line 1, instead of the line the "\n" itself + // still belongs to. + const segments = resolveStyledTextSegmentsFromRest( + baseNode({ + characters: "😀\ntwo", + characterStyleOverrides: [0, 0, 1, 1, 1, 1], + styleOverrideTable: { "1": { fontWeight: 700 } }, + lineTypes: ["ORDERED", "UNORDERED"], + lineIndentations: [0, 2], + }), + ["fontWeight", "indentation", "listOptions"], + ); + + expect(segments).toEqual([ + { + characters: "😀", + start: 0, + end: 2, + fontWeight: 400, + indentation: 0, + listOptions: { type: "ORDERED" }, + }, + { + // The override boundary lands exactly on "\n" (UTF-16 index 2) — + // it still reports line 0's metadata, proving the "\n" itself was + // correctly attributed to line 0 and not line 1. + characters: "\n", + start: 2, + end: 3, + fontWeight: 700, + indentation: 0, listOptions: { type: "ORDERED" }, }, + { + // "two" starts on line 1, whose metadata differs from line 0's — + // that's a separate run split (line-boundary fix), not the bug + // under test here. + characters: "two", + start: 3, + end: 6, + fontWeight: 700, + indentation: 2, + listOptions: { type: "UNORDERED" }, + }, ]); }); diff --git a/packages/backend/src/common/restStyledTextSegments.ts b/packages/backend/src/common/restStyledTextSegments.ts index 96fbe531..2cf791eb 100644 --- a/packages/backend/src/common/restStyledTextSegments.ts +++ b/packages/backend/src/common/restStyledTextSegments.ts @@ -37,12 +37,16 @@ const mergeStyle = ( override: TypeStyle | undefined, ): TypeStyle => (override ? { ...base, ...override } : base); +// Indexed by UTF-16 code unit, not Unicode code point, to line up with +// `characterStyleOverrides`/`runStart`/`.slice()` below — a `for...of` loop +// walks code points, so an astral character (e.g. an emoji) before a +// newline would throw those offsets out of sync with everything else here. const lineIndexPerCharacter = (characters: string): number[] => { const lines: number[] = []; let line = 0; - for (const ch of characters) { + for (let i = 0; i < characters.length; i++) { lines.push(line); - if (ch === "\n") line++; + if (characters[i] === "\n") line++; } return lines; }; @@ -170,7 +174,18 @@ export const resolveStyledTextSegmentsFromRest = ( for (let i = 1; i < characters.length; i++) { const index = overrides[i] ?? 0; - if (index !== runOverride) { + // A line boundary can change `indentation`/`listOptions` with no + // accompanying style override — Figma's own getStyledTextSegments() + // splits there too, so a plain override-index comparison under-splits. + const currentLine = lineIndex[i] ?? 0; + const runLine = lineIndex[runStart] ?? 0; + const lineMetadataChanged = + currentLine !== runLine && + ((node.lineIndentations[currentLine] ?? 0) !== + (node.lineIndentations[runLine] ?? 0) || + (node.lineTypes[currentLine] ?? "NONE") !== + (node.lineTypes[runLine] ?? "NONE")); + if (index !== runOverride || lineMetadataChanged) { flushRun(i); runStart = i; runOverride = index; From fc53de47dcf37db489bc44253c0a002a00bc30d5 Mon Sep 17 00:00:00 2001 From: Hein van Vlastuin Date: Tue, 18 Aug 2026 22:00:37 +0200 Subject: [PATCH 9/9] Only split styled-text runs on line-metadata fields actually requested MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit lineMetadataChanged compared indentation and listOptions unconditionally, so a caller that requested neither (e.g. just fontWeight) still got runs split on indentation/listOptions changes it never asked about. Figma's own getStyledTextSegments() docs say segments split "whenever the value of any [requested] property changes" — unrequested fields shouldn't factor into the split decision at all. --- .../src/common/restStyledTextSegments.test.ts | 56 +++++++++++++++++++ .../src/common/restStyledTextSegments.ts | 16 ++++-- 2 files changed, 67 insertions(+), 5 deletions(-) diff --git a/packages/backend/src/common/restStyledTextSegments.test.ts b/packages/backend/src/common/restStyledTextSegments.test.ts index 70f07ef2..5cc133a3 100644 --- a/packages/backend/src/common/restStyledTextSegments.test.ts +++ b/packages/backend/src/common/restStyledTextSegments.test.ts @@ -129,6 +129,62 @@ describe("resolveStyledTextSegmentsFromRest", () => { ]); }); + it("does not split on a line-metadata change when that field wasn't requested", () => { + const segments = resolveStyledTextSegmentsFromRest( + baseNode({ + characters: "one\ntwo", + lineTypes: ["ORDERED", "UNORDERED"], + lineIndentations: [0, 2], + }), + ["fontWeight"], + ); + + expect(segments).toEqual([ + { characters: "one\ntwo", start: 0, end: 7, fontWeight: 400 }, + ]); + }); + + it("splits on indentation alone when only indentation was requested", () => { + const segments = resolveStyledTextSegmentsFromRest( + baseNode({ + characters: "one\ntwo", + // listOptions differs across the line boundary too, but wasn't + // requested — it must not influence the split decision here. + lineTypes: ["ORDERED", "UNORDERED"], + lineIndentations: [0, 2], + }), + ["indentation"], + ); + + expect(segments).toEqual([ + { characters: "one\n", start: 0, end: 4, indentation: 0 }, + { characters: "two", start: 4, end: 7, indentation: 2 }, + ]); + }); + + it("splits on listOptions alone when only listOptions was requested", () => { + const segments = resolveStyledTextSegmentsFromRest( + baseNode({ + characters: "one\ntwo", + lineTypes: ["ORDERED", "UNORDERED"], + // indentation differs across the line boundary too, but wasn't + // requested — it must not influence the split decision here. + lineIndentations: [0, 2], + }), + ["listOptions"], + ); + + expect(segments).toEqual([ + { + characters: "one\n", + start: 0, + end: 4, + listOptions: { type: "ORDERED" }, + }, + { characters: "two", start: 4, end: 7, listOptions: { type: "UNORDERED" } }, + ]); + }); + it("indexes line metadata by UTF-16 code unit, not Unicode code point", () => { // "😀" is a surrogate pair — 1 code point, 2 UTF-16 units. The override // boundary below lands exactly on the "\n" (UTF-16 index 2). A diff --git a/packages/backend/src/common/restStyledTextSegments.ts b/packages/backend/src/common/restStyledTextSegments.ts index 2cf791eb..5cba5c42 100644 --- a/packages/backend/src/common/restStyledTextSegments.ts +++ b/packages/backend/src/common/restStyledTextSegments.ts @@ -145,6 +145,8 @@ export const resolveStyledTextSegmentsFromRest = ( const lineIndex = lineIndexPerCharacter(characters); const overrides = node.characterStyleOverrides ?? []; const keys = ["characters", "start", "end", ...fields] as const; + const watchIndentation = fields.includes("indentation"); + const watchListOptions = fields.includes("listOptions"); const segments: StyledTextSegmentSubset[] = []; let runStart = 0; @@ -176,15 +178,19 @@ export const resolveStyledTextSegmentsFromRest = ( const index = overrides[i] ?? 0; // A line boundary can change `indentation`/`listOptions` with no // accompanying style override — Figma's own getStyledTextSegments() - // splits there too, so a plain override-index comparison under-splits. + // splits there too, but only for fields actually requested: it + // documents that segments split "whenever the value of any [requested] + // property changes," not on unrequested ones. const currentLine = lineIndex[i] ?? 0; const runLine = lineIndex[runStart] ?? 0; const lineMetadataChanged = currentLine !== runLine && - ((node.lineIndentations[currentLine] ?? 0) !== - (node.lineIndentations[runLine] ?? 0) || - (node.lineTypes[currentLine] ?? "NONE") !== - (node.lineTypes[runLine] ?? "NONE")); + ((watchIndentation && + (node.lineIndentations[currentLine] ?? 0) !== + (node.lineIndentations[runLine] ?? 0)) || + (watchListOptions && + (node.lineTypes[currentLine] ?? "NONE") !== + (node.lineTypes[runLine] ?? "NONE"))); if (index !== runOverride || lineMetadataChanged) { flushRun(i); runStart = i;