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
6 changes: 5 additions & 1 deletion .env.example
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
<<<<<<< Updated upstream
# Your Figma API access token
# Get it from your Figma account settings: https://www.figma.com/developers/api#access-tokens
FIGMA_API_KEY=your_figma_api_key_here
Expand All @@ -16,4 +17,7 @@ PORT=3333
# Output format can either be "yaml" or "json". Is YAML by default since it's
# smaller, but JSON is understood by most LLMs better.
#
# OUTPUT_FORMAT="json"
# OUTPUT_FORMAT="json"
=======
FIGMA_API_KEY=
>>>>>>> Stashed changes
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,7 @@ The `figma-developer-mcp` server can be configured by adding the following to yo
"mcpServers": {
"Framelink Figma MCP": {
"command": "npx",
"args": ["-y", "figma-developer-mcp", "--figma-api-key=YOUR-KEY", "--stdio"]
"args": ["-y", "figma-developer-mcp", "API KEY HERE", "--stdio"]
}
}
}
Expand All @@ -83,7 +83,7 @@ The `figma-developer-mcp` server can be configured by adding the following to yo
"mcpServers": {
"Framelink Figma MCP": {
"command": "cmd",
"args": ["/c", "npx", "-y", "figma-developer-mcp", "--figma-api-key=YOUR-KEY", "--stdio"]
"args": ["/c", "npx", "-y", "figma-developer-mcp", "API KEY HERE", "--stdio"]
}
}
}
Expand Down
Binary file added images/ginger.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added images/onion.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added images/tomato.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
360 changes: 360 additions & 0 deletions src/services/simplify-node-response.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,360 @@
import { type SimplifiedLayout, buildSimplifiedLayout } from "~/transformers/layout.js";
import type {
GetFileNodesResponse,
Node as FigmaDocumentNode,
Paint,
Vector,
GetFileResponse,
ComponentPropertyType,
Component,
ComponentSet,
} from "@figma/rest-api-spec";
import type {
SimplifiedComponentDefinition,
SimplifiedComponentSetDefinition,
} from "~/utils/sanitization.js";
import { sanitizeComponents, sanitizeComponentSets } from "~/utils/sanitization.js";
import { hasValue, isRectangleCornerRadii, isTruthy } from "~/utils/identity.js";
import {
removeEmptyKeys,
generateVarId,
type StyleId,
parsePaint,
isVisible,
} from "~/utils/common.js";
import { buildSimplifiedStrokes, type SimplifiedStroke } from "~/transformers/style.js";
import { buildSimplifiedEffects, type SimplifiedEffects } from "~/transformers/effects.js";
import { buildSimplifiedText } from "~/transformers/textFormatter.js";

/**
* TODO ITEMS
*
* - Improve layout handling—translate from Figma vocabulary to CSS
* - Pull image fills/vectors out to top level for better AI visibility
* ? Implement vector parents again for proper downloads
* ? Look up existing styles in new MCP endpoint—Figma supports individual lookups without enterprise /v1/styles/:key
* ? Parse out and save .cursor/rules/design-tokens file on command
**/

// -------------------- SIMPLIFIED STRUCTURES --------------------

export type TextStyle = Partial<{
fontFamily: string;
fontWeight: number;
fontSize: number;
lineHeight: string;
letterSpacing: string;
textCase: string;
textAlignHorizontal: string;
textAlignVertical: string;
}>;
export type StrokeWeights = {
top: number;
right: number;
bottom: number;
left: number;
};
type StyleTypes =
| TextStyle
| SimplifiedFill[]
| SimplifiedLayout
| SimplifiedStroke
| SimplifiedEffects
| string;
type GlobalVars = {
styles: Record<StyleId, StyleTypes>;
};

export interface SimplifiedDesign {
name: string;
lastModified: string;
thumbnailUrl: string;
nodes: SimplifiedNode[];
components: Record<string, SimplifiedComponentDefinition>;
componentSets: Record<string, SimplifiedComponentSetDefinition>;
globalVars: GlobalVars;
}

export interface ComponentProperties {
name: string;
value: string;
type: ComponentPropertyType;
}

export interface SimplifiedNode {
id: string;
name: string;
type: string; // e.g. FRAME, TEXT, INSTANCE, RECTANGLE, etc.
// geometry
boundingBox?: BoundingBox;
// text
text?: string;
textStyle?: string;
// appearance
fills?: string;
styles?: string;
strokes?: string;
effects?: string;
opacity?: number;
borderRadius?: string;
// layout & alignment
layout?: string;
// backgroundColor?: ColorValue; // Deprecated by Figma API
// for rect-specific strokes, etc.
componentId?: string;
componentProperties?: ComponentProperties[];
// children
children?: SimplifiedNode[];
// raw text style overrides
characterStyleOverrides?: number[];
styleOverrideTable?: Record<number, any>;
}

export interface BoundingBox {
x: number;
y: number;
width: number;
height: number;
}

export type CSSRGBAColor = `rgba(${number}, ${number}, ${number}, ${number})`;
export type CSSHexColor = `#${string}`;
export type SimplifiedFill =
| {
type?: Paint["type"];
hex?: string;
rgba?: string;
opacity?: number;
imageRef?: string;
scaleMode?: string;
gradientHandlePositions?: Vector[];
gradientStops?: {
position: number;
color: ColorValue | string;
}[];
}
| CSSRGBAColor
| CSSHexColor;

export interface ColorValue {
hex: string;
opacity: number;
}

// ---------------------- PARSING ----------------------
export function parseFigmaResponse(data: GetFileResponse | GetFileNodesResponse): SimplifiedDesign {
const aggregatedComponents: Record<string, Component> = {};
const aggregatedComponentSets: Record<string, ComponentSet> = {};
let nodesToParse: Array<FigmaDocumentNode>;

if ("nodes" in data) {
// GetFileNodesResponse
const nodeResponses = Object.values(data.nodes); // Compute once
nodeResponses.forEach((nodeResponse) => {
if (nodeResponse.components) {
Object.assign(aggregatedComponents, nodeResponse.components);
}
if (nodeResponse.componentSets) {
Object.assign(aggregatedComponentSets, nodeResponse.componentSets);
}
});
nodesToParse = nodeResponses.map((n) => n.document);
} else {
// GetFileResponse
Object.assign(aggregatedComponents, data.components);
Object.assign(aggregatedComponentSets, data.componentSets);
nodesToParse = data.document.children;
}

const sanitizedComponents = sanitizeComponents(aggregatedComponents);
const sanitizedComponentSets = sanitizeComponentSets(aggregatedComponentSets);

const { name, lastModified, thumbnailUrl } = data;

let globalVars: GlobalVars = {
styles: {},
};

const simplifiedNodes: SimplifiedNode[] = nodesToParse
.filter(isVisible)
.map((n) => parseNode(globalVars, n))
.filter((child) => child !== null && child !== undefined);

const simplifiedDesign: SimplifiedDesign = {
name,
lastModified,
thumbnailUrl: thumbnailUrl || "",
nodes: simplifiedNodes,
components: sanitizedComponents,
componentSets: sanitizedComponentSets,
globalVars,
};

return removeEmptyKeys(simplifiedDesign);
}

// Helper function to find node by ID
const findNodeById = (id: string, nodes: SimplifiedNode[]): SimplifiedNode | undefined => {
for (const node of nodes) {
if (node?.id === id) {
return node;
}

if (node?.children && node.children.length > 0) {
const foundInChildren = findNodeById(id, node.children);
if (foundInChildren) {
return foundInChildren;
}
}
}

return undefined;
};

/**
* Find or create global variables
* @param globalVars - Global variables object
* @param value - Value to store
* @param prefix - Variable ID prefix
* @returns Variable ID
*/
function findOrCreateVar(globalVars: GlobalVars, value: any, prefix: string): StyleId {
// Check if the same value already exists
const [existingVarId] =
Object.entries(globalVars.styles).find(
([_, existingValue]) => JSON.stringify(existingValue) === JSON.stringify(value),
) ?? [];

if (existingVarId) {
return existingVarId as StyleId;
}

// Create a new variable if it doesn't exist
const varId = generateVarId(prefix);
globalVars.styles[varId] = value;
return varId;
}

function parseNode(
globalVars: GlobalVars,
n: FigmaDocumentNode,
parent?: FigmaDocumentNode,
): SimplifiedNode | null {
const { id, name, type } = n;

const simplified: SimplifiedNode = {
id,
name,
type,
};

if (type === "INSTANCE") {
if (hasValue("componentId", n)) {
simplified.componentId = n.componentId;
}

// Add specific properties for instances of components
if (hasValue("componentProperties", n)) {
simplified.componentProperties = Object.entries(n.componentProperties ?? {}).map(
([name, { value, type }]) => ({
name,
value: value.toString(),
type,
}),
);
}
}

if (type === "TEXT") {
if (hasValue("characterStyleOverrides", n)) {
simplified.characterStyleOverrides = n.characterStyleOverrides;
}
if (hasValue("styleOverrideTable", n)) {
simplified.styleOverrideTable = n.styleOverrideTable;
}
}

// text
if (hasValue("style", n) && Object.keys(n.style).length) {
const style = n.style;
const textStyle: TextStyle = {
fontFamily: style.fontFamily,
fontWeight: style.fontWeight,
fontSize: style.fontSize,
lineHeight:
style.lineHeightPx && style.fontSize
? `${style.lineHeightPx / style.fontSize}em`
: undefined,
letterSpacing:
style.letterSpacing && style.letterSpacing !== 0 && style.fontSize
? `${(style.letterSpacing / style.fontSize) * 100}%`
: undefined,
textCase: style.textCase,
textAlignHorizontal: style.textAlignHorizontal,
textAlignVertical: style.textAlignVertical,
};
simplified.textStyle = findOrCreateVar(globalVars, textStyle, "style");
}

// fills & strokes
if (hasValue("fills", n) && Array.isArray(n.fills) && n.fills.length) {
// const fills = simplifyFills(n.fills.map(parsePaint));
const fills = n.fills.map(parsePaint);
simplified.fills = findOrCreateVar(globalVars, fills, "fill");
}

const strokes = buildSimplifiedStrokes(n);
if (strokes.colors.length) {
simplified.strokes = findOrCreateVar(globalVars, strokes, "stroke");
}

const effects = buildSimplifiedEffects(n);
if (Object.keys(effects).length) {
simplified.effects = findOrCreateVar(globalVars, effects, "effect");
}

// Process layout
const layout = buildSimplifiedLayout(n, parent);
if (Object.keys(layout).length > 1) {
simplified.layout = findOrCreateVar(globalVars, layout, "layout");
}


//Text formatting

if (hasValue("characters", n)) {
simplified.text = buildSimplifiedText(n);
}


// opacity
if (hasValue("opacity", n) && typeof n.opacity === "number" && n.opacity !== 1) {
simplified.opacity = n.opacity;
}

if (hasValue("cornerRadius", n) && typeof n.cornerRadius === "number") {
simplified.borderRadius = `${n.cornerRadius}px`;
}
if (hasValue("rectangleCornerRadii", n, isRectangleCornerRadii)) {
simplified.borderRadius = `${n.rectangleCornerRadii[0]}px ${n.rectangleCornerRadii[1]}px ${n.rectangleCornerRadii[2]}px ${n.rectangleCornerRadii[3]}px`;
}

// Recursively process child nodes.
// Include children at the very end so all relevant configuration data for the element is output first and kept together for the AI.
if (hasValue("children", n) && n.children.length > 0) {
const children = n.children
.filter(isVisible)
.map((child) => parseNode(globalVars, child, n))
.filter((child) => child !== null && child !== undefined);
if (children.length) {
simplified.children = children;
}
}

// Convert VECTOR to IMAGE
if (type === "VECTOR") {
simplified.type = "IMAGE-SVG";
}

return simplified;
}
Loading