diff --git a/.env.example b/.env.example index dadb53ca..2d2f6777 100644 --- a/.env.example +++ b/.env.example @@ -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 @@ -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" \ No newline at end of file +# OUTPUT_FORMAT="json" +======= +FIGMA_API_KEY= +>>>>>>> Stashed changes diff --git a/README.md b/README.md index 29839d53..3be1d8b1 100644 --- a/README.md +++ b/README.md @@ -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"] } } } @@ -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"] } } } diff --git a/images/ginger.png b/images/ginger.png new file mode 100644 index 00000000..5136d41c Binary files /dev/null and b/images/ginger.png differ diff --git a/images/onion.png b/images/onion.png new file mode 100644 index 00000000..909cb77d Binary files /dev/null and b/images/onion.png differ diff --git a/images/tomato.png b/images/tomato.png new file mode 100644 index 00000000..a0af84bd Binary files /dev/null and b/images/tomato.png differ diff --git a/src/services/simplify-node-response.ts b/src/services/simplify-node-response.ts new file mode 100644 index 00000000..8d84725b --- /dev/null +++ b/src/services/simplify-node-response.ts @@ -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; +}; + +export interface SimplifiedDesign { + name: string; + lastModified: string; + thumbnailUrl: string; + nodes: SimplifiedNode[]; + components: Record; + componentSets: Record; + 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; +} + +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 = {}; + const aggregatedComponentSets: Record = {}; + let nodesToParse: Array; + + 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; +} diff --git a/src/transformers/textFormatter.ts b/src/transformers/textFormatter.ts new file mode 100644 index 00000000..5a4a9fba --- /dev/null +++ b/src/transformers/textFormatter.ts @@ -0,0 +1,81 @@ +import type { Node as FigmaDocumentNode } from "@figma/rest-api-spec"; +import { hasValue } from "~/utils/identity.js"; + +export function buildSimplifiedText(n: FigmaDocumentNode): string | undefined { + if (!hasValue("characters", n) || !n.characters) { + return undefined; + } + + const characters = n.characters; + const characterStyleOverrides = (n as any).characterStyleOverrides || []; + const styleOverrideTable = (n as any).styleOverrideTable || {}; + const baseStyle = (n as any).style || {}; + + // If there are no overrides, return plain text + if (characterStyleOverrides.length === 0) { + return characters; + } + + let html = ""; + let currentStyle = null; + let currentText = ""; + + for (let i = 0; i < characters.length; i++) { + const char = characters[i]; + const styleId = characterStyleOverrides[i]; + + // Get the style for this character (lucky character aye...) + let charStyle = { ...baseStyle }; + + // If styleId > 0, merge with override (0 = use base style, the starting default) + if (styleId && styleId > 0 && styleOverrideTable[styleId]) { + charStyle = { ...charStyle, ...styleOverrideTable[styleId] }; + } + + // Create style object for comparison + const cssStyle = { + fontWeight: charStyle.fontWeight, + fontStyle: charStyle.fontStyle, + textDecoration: charStyle.textDecoration + }; + + // If style changed, output previous span and start new one + if (JSON.stringify(cssStyle) !== JSON.stringify(currentStyle)) { + if (currentText) { + html += createSpan(currentText, currentStyle); + } + currentStyle = cssStyle; + currentText = char; + } else { + currentText += char; + } + } + + // Output final span + if (currentText) { + html += createSpan(currentText, currentStyle); + } + + return html; +} + +function createSpan(text: string, style: any): string { + if (!style) { + return text; + } + + let cssStyles = []; + if (style.fontWeight) cssStyles.push(`font-weight: ${style.fontWeight}`); + if (style.fontStyle === "ITALIC") cssStyles.push(`font-style: italic`); + if (style.textDecoration === "UNDERLINE") cssStyles.push(`text-decoration: underline`); + + if (cssStyles.length === 0) { + return text; + } + + return `${text}`; +} + + + +