Skip to main content
Glama
common.ts9.38 kB
import type { CSSHexColor, CSSRGBAColor, SimplifiedFill } from '@fastmcp/services/figma/simplify-node-response' import type { Paint, RGBA } from '@figma/rest-api-spec' import fs from 'node:fs' import path from 'node:path' 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); } } catch (err) { writer.end(); fs.unlink(fullPath, () => { }); reject(err); } }; // Resolve only when the stream is fully written writer.on('finish', () => { resolve(fullPath); }); 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}`); } } /** * Check if an element is visible * @param element - The item to check * @returns True if the item is visible, false otherwise */ export function isVisible(element: { visible?: boolean }): boolean { return element.visible ?? true; } /** * Rounds a number to two decimal places, suitable for pixel value processing. * @param num The number to be rounded. * @returns The rounded number with two decimal places. * @throws TypeError If the input is not a valid number */ export function pixelRound(num: number): number { if (isNaN(num)) { throw new TypeError(`Input must be a valid number`); } return Number(Number(num).toFixed(2)); }

Latest Blog Posts

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/Panzer-Jack/feuse-mcp'

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