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..86d22632 100644 --- a/packages/backend/src/common/exportAsyncProxy.ts +++ b/packages/backend/src/common/exportAsyncProxy.ts @@ -1,8 +1,9 @@ import { postConversionStart } from "../messaging"; +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 @@ -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..569b0345 --- /dev/null +++ b/packages/backend/src/host.ts @@ -0,0 +1,101 @@ +/** + * 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. + */ + +/** + * 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 + * 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; + + 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.`, + ); + } + // 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, + }; +} + +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) { + throw new Error( + "No backend host configured. Call setBackendHost() before running " + + "conversion outside the Figma plugin sandbox.", + ); + } + return host; +}; + +/** + * 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/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..5f5f1f66 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) => { @@ -160,11 +161,16 @@ 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 ( - (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")) {