diff --git a/src/mcp.ts b/src/mcp.ts index 3bf482f2..cd129c63 100644 --- a/src/mcp.ts +++ b/src/mcp.ts @@ -16,7 +16,7 @@ function createServer( ) { const server = new McpServer(serverInfo); // const figmaService = new FigmaService(figmaApiKey); - const figmaService = new FigmaService(authOptions); + const figmaService = FigmaService.getInstance(authOptions); registerTools(server, figmaService); Logger.isHTTP = isHTTP; @@ -92,111 +92,76 @@ function registerTools(server: McpServer, figmaService: FigmaService): void { // TODO: Clean up all image download related code, particularly getImages in Figma service // Tool to download images - server.tool( - "download_figma_images", - "Download SVG and PNG images used in a Figma file based on the IDs of image or icon nodes", - { - fileKey: z.string().describe("The key of the Figma file containing the node"), - nodes: z - .object({ - nodeId: z - .string() - .describe("The ID of the Figma image node to fetch, formatted as 1234:5678"), - imageRef: z - .string() - .optional() - .describe( - "If a node has an imageRef fill, you must include this variable. Leave blank when downloading Vector SVG images.", - ), - fileName: z.string().describe("The local name for saving the fetched file"), - }) - .array() - .describe("The nodes to fetch as images"), - pngScale: z - .number() - .positive() - .optional() - .default(2) - .describe( - "Export scale for PNG images. Optional, defaults to 2 if not specified. Affects PNG images only.", - ), - localPath: z - .string() - .describe( - "The absolute path to the directory where images are stored in the project. If the directory does not exist, it will be created. The format of this path should respect the directory format of the operating system you are running on. Don't use any special character escaping in the path name either.", - ), - svgOptions: z - .object({ - outlineText: z - .boolean() - .optional() - .default(true) - .describe("Whether to outline text in SVG exports. Default is true."), - includeId: z - .boolean() - .optional() - .default(false) - .describe("Whether to include IDs in SVG exports. Default is false."), - simplifyStroke: z - .boolean() - .optional() - .default(true) - .describe("Whether to simplify strokes in SVG exports. Default is true."), - }) - .optional() - .default({}) - .describe("Options for SVG export"), - }, - async ({ fileKey, nodes, localPath, svgOptions, pngScale }) => { - try { - const imageFills = nodes.filter(({ imageRef }) => !!imageRef) as { - nodeId: string; - imageRef: string; - fileName: string; - }[]; - const fillDownloads = figmaService.getImageFills(fileKey, imageFills, localPath); - const renderRequests = nodes - .filter(({ imageRef }) => !imageRef) - .map(({ nodeId, fileName }) => ({ - nodeId, - fileName, - fileType: fileName.endsWith(".svg") ? ("svg" as const) : ("png" as const), - })); - - const renderDownloads = figmaService.getImages( - fileKey, - renderRequests, - localPath, - pngScale, - svgOptions, - ); - - const downloads = await Promise.all([fillDownloads, renderDownloads]).then(([f, r]) => [ - ...f, - ...r, - ]); - - // If any download fails, return false - const saveSuccess = !downloads.find((success) => !success); - return { - content: [ - { - type: "text", - text: saveSuccess - ? `Success, ${downloads.length} images downloaded: ${downloads.join(", ")}` - : "Failed", - }, - ], - }; - } catch (error) { - Logger.error(`Error downloading images from file ${fileKey}:`, error); - return { - isError: true, - content: [{ type: "text", text: `Error downloading images: ${error}` }], - }; - } - }, - ); + // server.tool( + // "download_figma_images", + // "Download SVG and PNG images used in a Figma file based on the IDs of image or icon nodes", + // { + // fileKey: z.string().describe("The key of the Figma file containing the node"), + // nodes: z + // .object({ + // nodeId: z + // .string() + // .describe("The ID of the Figma image node to fetch, formatted as 1234:5678"), + // imageRef: z + // .string() + // .optional() + // .describe( + // "If a node has an imageRef fill, you must include this variable. Leave blank when downloading Vector SVG images.", + // ), + // fileName: z.string().describe("The local name for saving the fetched file"), + // }) + // .array() + // .describe("The nodes to fetch as images"), + // localPath: z + // .string() + // .describe( + // "The absolute path to the directory where images are stored in the project. If the directory does not exist, it will be created. The format of this path should respect the directory format of the operating system you are running on. Don't use any special character escaping in the path name either.", + // ), + // }, + // async ({ fileKey, nodes, localPath }) => { + // try { + // const imageFills = nodes.filter(({ imageRef }) => !!imageRef) as { + // nodeId: string; + // imageRef: string; + // fileName: string; + // }[]; + // const fillDownloads = figmaService.getImageFills(fileKey, imageFills, localPath); + // const renderRequests = nodes + // .filter(({ imageRef }) => !imageRef) + // .map(({ nodeId, fileName }) => ({ + // nodeId, + // fileName, + // fileType: fileName.endsWith(".svg") ? ("svg" as const) : ("png" as const), + // })); + // + // const renderDownloads = figmaService.getImages(fileKey, renderRequests, localPath); + // + // const downloads = await Promise.all([fillDownloads, renderDownloads]).then(([f, r]) => [ + // ...f, + // ...r, + // ]); + // + // // If any download fails, return false + // const saveSuccess = !downloads.find((success) => !success); + // return { + // content: [ + // { + // type: "text", + // text: saveSuccess + // ? `Success, ${downloads.length} images downloaded: ${downloads.join(", ")}` + // : "Failed", + // }, + // ], + // }; + // } catch (error) { + // Logger.error(`Error downloading images from file ${fileKey}:`, error); + // return { + // isError: true, + // content: [{ type: "text", text: `Error downloading images: ${error}` }], + // }; + // } + // }, + // ); } export { createServer }; diff --git a/src/services/figma.ts b/src/services/figma.ts index 714bf78c..09aed80f 100644 --- a/src/services/figma.ts +++ b/src/services/figma.ts @@ -10,6 +10,7 @@ import { downloadFigmaImage } from "~/utils/common.js"; import { Logger } from "~/utils/logger.js"; import { fetchWithRetry } from "~/utils/fetch-with-retry.js"; import yaml from "js-yaml"; +import path from "path"; export type FigmaAuthOptions = { figmaApiKey: string; @@ -17,7 +18,7 @@ export type FigmaAuthOptions = { useOAuth: boolean; }; -type FetchImageParams = { +export type FetchImageParams = { /** * The Node in Figma that will either be rendered or have its background image downloaded */ @@ -39,18 +40,47 @@ type FetchImageFillParams = Omit & { imageRef: string; }; +type GetImagesParams = { + /** + * Whether text elements are rendered as outlines (vector paths) or as elements in SVGs. + */ + outlineText?: boolean; + + /** + * Whether to include id attributes for all SVG elements. Adds the layer name to the id attribute of an svg element. + */ + includeId?: boolean; + + /** + * Whether to simplify inside/outside strokes and use stroke attribute if possible instead of . + */ + simplifyStroke?: boolean; +}; + export class FigmaService { +// Static property to store the singleton instance + private static instance: FigmaService | null = null; + private readonly apiKey: string; private readonly oauthToken: string; private readonly useOAuth: boolean; private readonly baseUrl = "https://api.figma.com/v1"; - constructor({ figmaApiKey, figmaOAuthToken, useOAuth }: FigmaAuthOptions) { + private constructor({ figmaApiKey, figmaOAuthToken, useOAuth }: FigmaAuthOptions) { this.apiKey = figmaApiKey || ""; this.oauthToken = figmaOAuthToken || ""; this.useOAuth = !!useOAuth && !!this.oauthToken; } +// Static method to retrieve the singleton instance + public static getInstance(options?: FigmaAuthOptions): FigmaService { + if (!FigmaService.instance && options) { + const {figmaApiKey, figmaOAuthToken, useOAuth} = options + FigmaService.instance = new FigmaService({ figmaApiKey, figmaOAuthToken, useOAuth }); + } + return FigmaService.instance; + } + private async request(endpoint: string): Promise { try { Logger.log(`Calling ${this.baseUrl}${endpoint}`); @@ -105,11 +135,7 @@ export class FigmaService { nodes: FetchImageParams[], localPath: string, pngScale: number, - svgOptions: { - outlineText: boolean; - includeId: boolean; - simplifyStroke: boolean; - }, + { outlineText = true, includeId = false, simplifyStroke = true }: GetImagesParams = {}, ): Promise { const pngIds = nodes.filter(({ fileType }) => fileType === "png").map(({ nodeId }) => nodeId); const pngFiles = @@ -123,9 +149,9 @@ export class FigmaService { const svgParams = [ `ids=${svgIds.join(",")}`, "format=svg", - `svg_outline_text=${svgOptions.outlineText}`, - `svg_include_id=${svgOptions.includeId}`, - `svg_simplify_stroke=${svgOptions.simplifyStroke}`, + `svg_outline_text=${outlineText}`, + `svg_include_id=${includeId}`, + `svg_simplify_stroke=${simplifyStroke}`, ].join("&"); const svgFiles = @@ -141,6 +167,17 @@ export class FigmaService { .map(({ nodeId, fileName }) => { const imageUrl = files[nodeId]; if (imageUrl) { + // Build the complete file path + const fullPath = path.join(localPath, fileName); + + if (!fs.existsSync(localPath)) { + fs.mkdirSync(localPath, { recursive: true }); + } + + if (fs.existsSync(fullPath)) { + fs.unlinkSync(fullPath) + } + return downloadFigmaImage(fileName, localPath, imageUrl); } return false; @@ -156,7 +193,7 @@ export class FigmaService { Logger.log(`Retrieving Figma file: ${fileKey} (depth: ${depth ?? "default"})`); const response = await this.request(endpoint); Logger.log("Got response"); - const simplifiedResponse = parseFigmaResponse(response); + const simplifiedResponse = await parseFigmaResponse(fileKey, response); writeLogs("figma-raw.yml", response); writeLogs("figma-simplified.yml", simplifiedResponse); return simplifiedResponse; @@ -171,7 +208,7 @@ export class FigmaService { const response = await this.request(endpoint); Logger.log("Got response from getNode, now parsing."); writeLogs("figma-raw.yml", response); - const simplifiedResponse = parseFigmaResponse(response); + const simplifiedResponse = await parseFigmaResponse(fileKey, response); writeLogs("figma-simplified.yml", simplifiedResponse); return simplifiedResponse; } diff --git a/src/services/simplify-node-response.ts b/src/services/simplify-node-response.ts index b01a6f6a..2e15cf94 100644 --- a/src/services/simplify-node-response.ts +++ b/src/services/simplify-node-response.ts @@ -14,7 +14,7 @@ import type { SimplifiedComponentSetDefinition, } from "~/utils/sanitization.js"; import { sanitizeComponents, sanitizeComponentSets } from "~/utils/sanitization.js"; -import { hasValue, isRectangleCornerRadii, isTruthy } from "~/utils/identity.js"; +import { getNodeCategory, hasValue, isRectangleCornerRadii, isTruthy } from "~/utils/identity.js"; import { removeEmptyKeys, generateVarId, @@ -24,6 +24,8 @@ import { } from "~/utils/common.js"; import { buildSimplifiedStrokes, type SimplifiedStroke } from "~/transformers/style.js"; import { buildSimplifiedEffects, type SimplifiedEffects } from "~/transformers/effects.js"; +import { buildSimplifiedIcon, type SimpledIcon } from "~/transformers/icon.js"; + /** * TODO ITEMS * @@ -61,6 +63,7 @@ type StyleTypes = | string; type GlobalVars = { styles: Record; + icons: Record; }; export interface SimplifiedDesign { @@ -101,6 +104,7 @@ export interface SimplifiedNode { // for rect-specific strokes, etc. componentId?: string; componentProperties?: ComponentProperties[]; + icon?: string; // children children?: SimplifiedNode[]; } @@ -137,7 +141,10 @@ export interface ColorValue { } // ---------------------- PARSING ---------------------- -export function parseFigmaResponse(data: GetFileResponse | GetFileNodesResponse): SimplifiedDesign { +export async function parseFigmaResponse( + fileKey: string, + data: GetFileResponse | GetFileNodesResponse, +): Promise { const aggregatedComponents: Record = {}; const aggregatedComponentSets: Record = {}; let nodesToParse: Array; @@ -166,14 +173,18 @@ export function parseFigmaResponse(data: GetFileResponse | GetFileNodesResponse) const { name, lastModified, thumbnailUrl } = data; - let globalVars: GlobalVars = { + const globalVars: GlobalVars = { styles: {}, + icons: {}, }; - const simplifiedNodes: SimplifiedNode[] = nodesToParse - .filter(isVisible) - .map((n) => parseNode(globalVars, n)) - .filter((child) => child !== null && child !== undefined); + const visibleNodes = nodesToParse.filter(isVisible); + let simplifiedNodes = []; + for (const n of visibleNodes) { + const parsedNode = await parseNode(fileKey, globalVars, n); + simplifiedNodes.push(parsedNode); + } + simplifiedNodes = simplifiedNodes.filter((child) => child !== null && child !== undefined); const simplifiedDesign: SimplifiedDesign = { name, @@ -188,24 +199,6 @@ export function parseFigmaResponse(data: GetFileResponse | GetFileNodesResponse) 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 @@ -230,11 +223,29 @@ function findOrCreateVar(globalVars: GlobalVars, value: any, prefix: string): St return varId; } -function parseNode( +/** + * Find or create a global variable for an icon + * + * @param icon - Icon object containing fileName and other properties + * @param globalVars - Global variables object to store icons + * @returns The fileName of the icon + */ +function findOrCreateVarForIcons(icon: SimpledIcon, globalVars: GlobalVars): string { + const { fileName } = icon; + globalVars.icons = globalVars.icons ?? {}; + const isExist = Object.keys(globalVars.icons).findIndex((key) => key === icon.fileName) !== -1; + if (!isExist) { + globalVars.icons[fileName] = icon; + } + return fileName; +} + +async function parseNode( + fileKey: string, globalVars: GlobalVars, n: FigmaDocumentNode, parent?: FigmaDocumentNode, -): SimplifiedNode | null { +): Promise { const { id, name, type } = n; const simplified: SimplifiedNode = { @@ -243,20 +254,29 @@ function parseNode( type, }; - if (type === "INSTANCE") { - if (hasValue("componentId", n)) { - simplified.componentId = n.componentId; - } + // Determine the category of the node + const category = getNodeCategory(n); + if (category === "component") { + 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, - }), - ); + // 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, + }), + ); + } + } + } else if (category === "icon") { + const icon = await buildSimplifiedIcon(fileKey, n); + if (icon) { + simplified.icon = findOrCreateVarForIcons(icon, globalVars); } } @@ -324,22 +344,38 @@ function parseNode( simplified.borderRadius = `${n.rectangleCornerRadii[0]}px ${n.rectangleCornerRadii[1]}px ${n.rectangleCornerRadii[2]}px ${n.rectangleCornerRadii[3]}px`; } - // Recursively process child nodes. + // 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; + if (hasValue("children", n) && shouldTraverseChildren(n, simplified)) { + const visibleNodes = n.children.filter(isVisible); + let simplifiedNodes = []; + for (const visibleNode of visibleNodes) { + const parsedNode = await parseNode(fileKey, globalVars, visibleNode, n); + simplifiedNodes.push(parsedNode); } - } + simplifiedNodes = simplifiedNodes.filter((child) => child !== null && child !== undefined); - // Convert VECTOR to IMAGE - if (type === "VECTOR") { - simplified.type = "IMAGE-SVG"; + if (simplifiedNodes.length) { + simplified.children = simplifiedNodes; + } } return simplified; } + +/** + * Determines whether to traverse the children of a Figma node + * + * @param n - The Figma node to evaluate + * @param simplifiedParent - The simplified parent node containing metadata like icon classification + * @returns True if the node's children should be traversed, false otherwise + */ +function shouldTraverseChildren(n: FigmaDocumentNode, simplifiedParent: SimplifiedNode): boolean { + // If a node has a determined classification (e.g., icon or component), its type alone + // provides enough information to generate code, and traversing children is unnecessary. + return ( + hasValue("children", n) && + n.children.length > 0 && + !simplifiedParent.icon + ); +} diff --git a/src/transformers/icon.ts b/src/transformers/icon.ts new file mode 100644 index 00000000..987372f6 --- /dev/null +++ b/src/transformers/icon.ts @@ -0,0 +1,133 @@ +import type { Node as FigmaDocumentNode } from "@figma/rest-api-spec"; +import { type FetchImageParams, FigmaService } from "~/services/figma.js"; +import { readFileSync } from "node:fs"; +import { createHash } from "node:crypto"; +import fs from "fs"; +import path from "path"; +import * as os from "node:os"; + +/** + * Represents a simplified icon object with type, filename, and content. + */ +export type SimpledIcon = { + type: "svg" | "png"; + fileName: string; + content: string; +}; + +/** + * Builds a simplified icon object from a Figma document node. + * Generates an SVG icon if all children of the node are of type VECTOR. + * Removes componentId for INSTANCE nodes, fetches the image, and constructs the icon object. + * Uses MD5 file naming to generate unique file names based on file content, ensuring frontend projects + * avoid redundant imports of the same image by reusing identical assets. + * + * @param fileKey - The key identifying the Figma file + * @param n - The Figma document node to process + * @returns A Promise resolving to a SimpledIcon object if successful, or null if the node is invalid or processing fails + */ +export async function buildSimplifiedIcon( + fileKey: string, + n: FigmaDocumentNode, +): Promise { + if ("children" in n) { + const isAllVectorChildren = n.children.every((child) => child.type === "VECTOR"); + if (isAllVectorChildren) { + const figmaService = FigmaService.getInstance(); + const params: FetchImageParams[] = [ + { + nodeId: n.id, + fileName: generateRandomName("svg"), + fileType: "svg", + }, + ]; + const tempDir = getImageTempDirPath("svg"); + const urls = await figmaService.getImages(fileKey, params, tempDir, 1); + const url = urls[0]; + if (url) { + // Generate an MD5-based file name using the file content and node name to ensure uniqueness + const md5Name = generateFileMd5Name("svg", url, n.name); + const result: SimpledIcon = { + type: "svg", + fileName: md5Name, + content: fs.readFileSync(url, "utf8"), + }; + fs.unlinkSync(url); + return result; + } + } + } + + return null; +} + +/** + * Retrieves the temporary directory path for images. + * Returns the appropriate temporary directory path based on the environment and file type. + * + * @param fileType - The image file type, either "svg" or "png". + * @returns The full path to the temporary image directory. + */ +function getImageTempDirPath(fileType: "svg" | "png"): string { + const baseDir = os.tmpdir(); + const result = path.join(baseDir, "figma-mcp", "tmp", fileType === "svg" ? "svg" : "png"); + if (process.env.NODE_ENV === "development") { + console.log(`Base temporary directory: ${result}`); + } + return result; +} + +/** + * Generates a random filename with the specified file type extension. + * + * @param fileType - The file type for the extension, either "svg" or "png". + * @param length - The length of the random filename (excluding extension), defaults to 8. + * @returns A random filename with the specified file type extension. + */ +function generateRandomName(fileType: "svg" | "png", length: number = 8): string { + const characters = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"; + let result = ""; + for (let i = 0; i < length; i++) { + const randomIndex = Math.floor(Math.random() * characters.length); + result += characters[randomIndex]; + } + return `${result}.${fileType}`; +} + +/** + * Sanitizes a filename by replacing non-alphanumeric characters with underscores. + * + * @param str - The string to sanitize. + * @returns The sanitized filename string. + */ +function sanitizeFilename(str: string): string { + return str.replace(/[^a-zA-Z0-9]/g, "_"); +} + +/** + * Generates a filename with an MD5 hash based on the file type, content, and input string. + * Constructs a filename by extracting the last segment of the input string, sanitizing it, + * and appending a 6-character MD5 hash of the file content. + * + * @param fileType - The file type for the extension, either "svg" or "png". + * @param fileUrl - The path to the file to read for MD5 hashing. + * @param inputString - The input string to derive the base filename from. + * @returns The generated filename in the format `_.`. + */ +function generateFileMd5Name( + fileType: "svg" | "png", + fileUrl: string, + inputString: string, +): string { + const parts = inputString.toLowerCase().split(/[/_]/); + let baseName = parts[parts.length - 1]; + + baseName = baseName || "file"; + + baseName = sanitizeFilename(baseName); + + const fileContent = readFileSync(fileUrl); + const md5Hash = createHash("md5").update(fileContent).digest("hex").slice(-6); + + return `${baseName}_${md5Hash}.${fileType}`; +} diff --git a/src/utils/common.ts b/src/utils/common.ts index cc6a258d..3f70b386 100644 --- a/src/utils/common.ts +++ b/src/utils/common.ts @@ -327,5 +327,5 @@ export function pixelRound(num: number): number { if (isNaN(num)) { throw new TypeError(`Input must be a valid number`); } - return Number(Number(num).toFixed(2)); + return Number(Number(num).toFixed(1)); } diff --git a/src/utils/identity.ts b/src/utils/identity.ts index 9b5fb624..23b1cdd4 100644 --- a/src/utils/identity.ts +++ b/src/utils/identity.ts @@ -3,6 +3,7 @@ import type { HasLayoutTrait, StrokeWeights, HasFramePropertiesTrait, + Node as FigmaDocumentNode, } from "@figma/rest-api-spec"; import { isTruthy } from "remeda"; import type { CSSHexColor, CSSRGBAColor } from "~/services/simplify-node-response.js"; @@ -98,3 +99,24 @@ export function isRectangleCornerRadii(val: unknown): val is number[] { export function isCSSColorValue(val: unknown): val is CSSRGBAColor | CSSHexColor { return typeof val === "string" && (val.startsWith("#") || val.startsWith("rgba")); } + +/** + * Determines the category of a Figma node + * + * @param n - The Figma node to evaluate + * @returns The category ("component" or "icon") or null if no category applies + */ +export function getNodeCategory(n: FigmaDocumentNode): "component" | "icon" | null { + if (hasValue("children", n)) { + // If all children are vectors, categorize as an icon + const isAllVectorChildren = n.children.every((child) => child.type === "VECTOR"); + if (isAllVectorChildren) { + return "icon"; + } + } + + if (n.type === "INSTANCE") { + return "component"; + } + return null; +}