Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion packages/backend/src/common/commonRadius.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { CornerRadius } from "types";
import { getMixed } from "../host";

export const getCommonRadius = (node: SceneNode): CornerRadius => {
if ("rectangleCornerRadii" in node) {
Expand All @@ -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 };
Expand Down
3 changes: 2 additions & 1 deletion packages/backend/src/common/commonStroke.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { BorderSide } from "types";
import { getMixed } from "../host";

export const commonStroke = (
node: SceneNode,
Expand All @@ -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 };
}

Expand Down
23 changes: 3 additions & 20 deletions packages/backend/src/common/exportAsyncProxy.ts
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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;
Expand Down
3 changes: 2 additions & 1 deletion packages/backend/src/compose/composeMain.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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");
Expand Down
9 changes: 5 additions & 4 deletions packages/backend/src/compose/composeTextBuilder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<number, string> = {
Expand Down Expand Up @@ -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
) {
Expand All @@ -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);
Expand All @@ -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,
Expand All @@ -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"
) {
Expand Down
5 changes: 3 additions & 2 deletions packages/backend/src/flutter/flutterContainer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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()",
Expand Down Expand Up @@ -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
Expand Down
101 changes: 101 additions & 0 deletions packages/backend/src/host.ts
Original file line number Diff line number Diff line change
@@ -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<string | Uint8Array>;
/** Replaces `figma.variables.getVariableByIdAsync(id)?.name`. */
getVariableName?: (id: string) => Promise<string | null>;
}

/**
* 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;
3 changes: 2 additions & 1 deletion packages/backend/src/html/builderImpl/htmlColor.ts
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -240,7 +241,7 @@ export const htmlDiamondGradient = (fill: GradientPaint) => {
export const buildBackgroundValues = (
paintArray: ReadonlyArray<Paint> | PluginAPI["mixed"],
): string => {
if (paintArray === figma.mixed) {
if (paintArray === getMixed()) {
return "";
}

Expand Down
3 changes: 2 additions & 1 deletion packages/backend/src/html/htmlDefaultBuilder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ import {
formatStyleAttribute,
} from "../common/commonFormatAttributes";
import { HTMLSettings } from "types";
import { getMixed } from "../host";
import {
cssCollection,
generateUniqueClassName,
Expand Down Expand Up @@ -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(
Expand Down
3 changes: 2 additions & 1 deletion packages/backend/src/html/htmlTextBuilder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
commonLineHeight,
} from "../common/commonTextHeightSpacing";
import { HTMLSettings, StyledTextSegmentSubset } from "types";
import { getMixed } from "../host";
import {
cssCollection,
generateUniqueClassName,
Expand Down Expand Up @@ -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));
}
Expand Down
6 changes: 6 additions & 0 deletions packages/backend/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,3 +10,9 @@ export {
} from "./zipGenerator";
export { run } from "./code";
export * from "./messaging";
export {
setBackendHost,
getBackendHost,
type BackendHost,
type ExportRequest,
} from "./host";
Original file line number Diff line number Diff line change
@@ -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 "";
}

Expand Down
3 changes: 2 additions & 1 deletion packages/backend/src/swiftui/swiftuiMain.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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[];
Expand Down Expand Up @@ -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") {
Expand Down
12 changes: 9 additions & 3 deletions packages/backend/src/tailwind/conversionTables.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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>): number => {
return array.reduce((prev, curr) => {
Expand Down Expand Up @@ -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(":", "-")
);
};

Expand Down
3 changes: 2 additions & 1 deletion packages/backend/src/tailwind/tailwindTextBuilder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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): {
Expand Down Expand Up @@ -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")) {
Expand Down