Skip to main content
Glama

Figma MCP Server

by 1yhy
common.ts10.8 kB
import fs from "fs"; import path from "path"; import type { Paint, RGBA } from "@figma/rest-api-spec"; import { CSSHexColor, CSSRGBAColor, SimplifiedFill } from "~/services/simplify-node-response.js"; export type StyleId = `${string}_${string}` & { __brand: "StyleId" }; export interface ColorValue { hex: CSSHexColor; opacity: number; } /** * Download Figma image and save it locally * @param fileName - The filename to save as * @param localPath - The local path to save to * @param imageUrl - Image URL (images[nodeId]) * @returns A Promise that resolves to the full file path where the image was saved * @throws Error if download fails */ export async function downloadFigmaImage( fileName: string, localPath: string, imageUrl: string, ): Promise<string> { try { // Ensure local path exists if (!fs.existsSync(localPath)) { fs.mkdirSync(localPath, { recursive: true }); } // Build the complete file path const fullPath = path.join(localPath, fileName); // Use fetch to download the image const response = await fetch(imageUrl, { method: "GET", }); if (!response.ok) { throw new Error(`Failed to download image: ${response.statusText}`); } // Create write stream const writer = fs.createWriteStream(fullPath); // Get the response as a readable stream and pipe it to the file const reader = response.body?.getReader(); if (!reader) { throw new Error("Failed to get response body"); } return new Promise((resolve, reject) => { // Process stream const processStream = async () => { try { while (true) { const { done, value } = await reader.read(); if (done) { writer.end(); break; } writer.write(value); } resolve(fullPath); } catch (err) { writer.end(); fs.unlink(fullPath, () => {}); reject(err); } }; writer.on("error", (err) => { reader.cancel(); fs.unlink(fullPath, () => {}); reject(new Error(`Failed to write image: ${err.message}`)); }); processStream(); }); } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); throw new Error(`Error downloading image: ${errorMessage}`); } } /** * Remove keys with empty arrays or empty objects from an object. * @param input - The input object or value. * @returns The processed object or the original value. */ export function removeEmptyKeys<T>(input: T): T { // If not an object type or null, return directly if (typeof input !== "object" || input === null) { return input; } // Handle array type if (Array.isArray(input)) { return input.map((item) => removeEmptyKeys(item)) as T; } // Handle object type const result = {} as T; for (const key in input) { if (Object.prototype.hasOwnProperty.call(input, key)) { const value = input[key]; // Recursively process nested objects const cleanedValue = removeEmptyKeys(value); // Skip empty arrays and empty objects if ( cleanedValue !== undefined && !(Array.isArray(cleanedValue) && cleanedValue.length === 0) && !( typeof cleanedValue === "object" && cleanedValue !== null && Object.keys(cleanedValue).length === 0 ) ) { result[key] = cleanedValue; } } } return result; } /** * Convert hex color value and opacity to rgba format * @param hex - Hexadecimal color value (e.g., "#FF0000" or "#F00") * @param opacity - Opacity value (0-1) * @returns Color string in rgba format */ export function hexToRgba(hex: string, opacity: number = 1): string { // Remove possible # prefix hex = hex.replace("#", ""); // Handle shorthand hex values (e.g., #FFF) if (hex.length === 3) { hex = hex[0] + hex[0] + hex[1] + hex[1] + hex[2] + hex[2]; } // Convert hex to RGB values const r = parseInt(hex.substring(0, 2), 16); const g = parseInt(hex.substring(2, 4), 16); const b = parseInt(hex.substring(4, 6), 16); // Ensure opacity is in the 0-1 range const validOpacity = Math.min(Math.max(opacity, 0), 1); return `rgba(${r}, ${g}, ${b}, ${validOpacity})`; } /** * Convert color from RGBA to { hex, opacity } * * @param color - The color to convert, including alpha channel * @param opacity - The opacity of the color, if not included in alpha channel * @returns The converted color **/ export function convertColor(color: RGBA, opacity = 1): ColorValue { const r = Math.round(color.r * 255); const g = Math.round(color.g * 255); const b = Math.round(color.b * 255); // Alpha channel defaults to 1. If opacity and alpha are both and < 1, their effects are multiplicative const a = Math.round(opacity * color.a * 100) / 100; const hex = ("#" + ((1 << 24) + (r << 16) + (g << 8) + b).toString(16).slice(1).toUpperCase()) as CSSHexColor; return { hex, opacity: a }; } /** * Convert color from Figma RGBA to rgba(#, #, #, #) CSS format * * @param color - The color to convert, including alpha channel * @param opacity - The opacity of the color, if not included in alpha channel * @returns The converted color **/ export function formatRGBAColor(color: RGBA, opacity = 1): CSSRGBAColor { const r = Math.round(color.r * 255); const g = Math.round(color.g * 255); const b = Math.round(color.b * 255); // Alpha channel defaults to 1. If opacity and alpha are both and < 1, their effects are multiplicative const a = Math.round(opacity * color.a * 100) / 100; return `rgba(${r}, ${g}, ${b}, ${a})`; } /** * Generate a 6-character random variable ID * @param prefix - ID prefix * @returns A 6-character random ID string with prefix */ export function generateVarId(prefix: string = "var"): StyleId { const chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"; let result = ""; for (let i = 0; i < 6; i++) { const randomIndex = Math.floor(Math.random() * chars.length); result += chars[randomIndex]; } return `${prefix}_${result}` as StyleId; } /** * Generate a CSS shorthand for values that come with top, right, bottom, and left * * input: { top: 10, right: 10, bottom: 10, left: 10 } * output: "10px" * * input: { top: 10, right: 20, bottom: 10, left: 20 } * output: "10px 20px" * * input: { top: 10, right: 20, bottom: 30, left: 40 } * output: "10px 20px 30px 40px" * * @param values - The values to generate the shorthand for * @returns The generated shorthand */ export function generateCSSShorthand( values: { top: number; right: number; bottom: number; left: number; }, { ignoreZero = true, suffix = "px", }: { /** * If true and all values are 0, return undefined. Defaults to true. */ ignoreZero?: boolean; /** * The suffix to add to the shorthand. Defaults to "px". */ suffix?: string; } = {}, ) { const { top, right, bottom, left } = values; if (ignoreZero && top === 0 && right === 0 && bottom === 0 && left === 0) { return undefined; } if (top === right && right === bottom && bottom === left) { return `${top}${suffix}`; } if (right === left) { if (top === bottom) { return `${top}${suffix} ${right}${suffix}`; } return `${top}${suffix} ${right}${suffix} ${bottom}${suffix}`; } return `${top}${suffix} ${right}${suffix} ${bottom}${suffix} ${left}${suffix}`; } /** * Convert a Figma paint (solid, image, gradient) to a SimplifiedFill * @param raw - The Figma paint to convert * @returns The converted SimplifiedFill */ export function parsePaint(raw: Paint): SimplifiedFill { if (raw.type === "IMAGE") { return { type: "IMAGE", imageRef: raw.imageRef, scaleMode: raw.scaleMode, }; } else if (raw.type === "SOLID") { // treat as SOLID const { hex, opacity } = convertColor(raw.color!, raw.opacity); if (opacity === 1) { return hex; } else { return formatRGBAColor(raw.color!, opacity); } } else if ( ["GRADIENT_LINEAR", "GRADIENT_RADIAL", "GRADIENT_ANGULAR", "GRADIENT_DIAMOND"].includes( raw.type, ) ) { // treat as GRADIENT_LINEAR return { type: raw.type, gradientHandlePositions: raw.gradientHandlePositions, gradientStops: raw.gradientStops.map(({ position, color }) => ({ position, color: convertColor(color), })), }; } else { throw new Error(`Unknown paint type: ${raw.type}`); } } /** * 检查元素是否可见 * @param element - 要检查的元素 * @returns 如果元素可见则返回true,否则返回false */ export function isVisible(element: { visible?: boolean; opacity?: number; absoluteBoundingBox?: { x: number; y: number; width: number; height: number }; absoluteRenderBounds?: { x: number; y: number; width: number; height: number } | null; }): boolean { // 1. 显式可见性检查 if (element.visible === false) { return false; } // 2. 透明度检查 if (element.opacity === 0) { return false; } // 3. 渲染边界检查 - 如果明确没有渲染边界,则不可见 if (element.absoluteRenderBounds === null) { return false; } // 默认为可见 return true; } /** * 检查元素在父容器中是否可见 * @param element - 要检查的元素 * @param parent - 父元素信息 * @returns 如果元素可见则返回true,否则返回false */ export function isVisibleInParent( element: { visible?: boolean; opacity?: number; absoluteBoundingBox?: { x: number; y: number; width: number; height: number }; absoluteRenderBounds?: { x: number; y: number; width: number; height: number } | null; }, parent: { clipsContent?: boolean; absoluteBoundingBox?: { x: number; y: number; width: number; height: number }; } ): boolean { // 先检查元素本身是否可见 if (!isVisible(element)) { return false; } // 父容器裁剪检查 if (parent && parent.clipsContent === true && element.absoluteBoundingBox && parent.absoluteBoundingBox) { const elementBox = element.absoluteBoundingBox; const parentBox = parent.absoluteBoundingBox; // 检查元素是否完全在父容器外部 const outsideParent = elementBox.x >= parentBox.x + parentBox.width || // 右侧超出 elementBox.x + elementBox.width <= parentBox.x || // 左侧超出 elementBox.y >= parentBox.y + parentBox.height || // 底部超出 elementBox.y + elementBox.height <= parentBox.y; // 顶部超出 if (outsideParent) { return false; } } // 通过所有检查,认为元素可见 return true; }

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/1yhy/Figma-Context-MCP'

If you have feedback or need assistance with the MCP directory API, please join our Discord server