From 8489791442690b2855fb857ce6185a9712d127f4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=BC=A0=E4=B8=9A?= Date: Thu, 22 May 2025 15:50:12 +0800 Subject: [PATCH 01/10] feat: Enhance logging and data handling in Figma service - Introduced size calculations for YAML and JSON outputs. - Updated logging to include data sizes for generated results. - Refactored log writing to support JSON format and improved directory handling for logs. - Added a utility function to streamline JSON to YAML logging. --- src/mcp.ts | 13 +++++++++++-- src/services/figma.ts | 25 ++++++++++++++++++------- src/utils/calc-string-size.ts | 12 ++++++++++++ 3 files changed, 41 insertions(+), 9 deletions(-) create mode 100644 src/utils/calc-string-size.ts diff --git a/src/mcp.ts b/src/mcp.ts index bb856951..2fdb1642 100644 --- a/src/mcp.ts +++ b/src/mcp.ts @@ -4,6 +4,7 @@ import { FigmaService, type FigmaAuthOptions } from "./services/figma.js"; import type { SimplifiedDesign } from "./services/simplify-node-response.js"; import yaml from "js-yaml"; import { Logger } from "./utils/logger.js"; +import { calcStringSize } from './utils/calc-string-size.js'; const serverInfo = { name: "Figma MCP Server", @@ -72,12 +73,20 @@ function registerTools(server: McpServer, figmaService: FigmaService): void { globalVars, }; - Logger.log("Generating YAML result from file"); const yamlResult = yaml.dump(result); + const yamlResultSize = calcStringSize(yamlResult); + const jsonResult = JSON.stringify(result); + const jsonResultSize = calcStringSize(jsonResult); + + Logger.log(`Data size: + YAML: ${yamlResultSize} KB + JSON: ${jsonResultSize} KB + `); + Logger.log("Sending result to client"); return { - content: [{ type: "text", text: yamlResult }], + content: [{ type: "text", text: jsonResult }], }; } catch (error) { const message = error instanceof Error ? error.message : JSON.stringify(error); diff --git a/src/services/figma.ts b/src/services/figma.ts index 00b943ed..6c8e3e63 100644 --- a/src/services/figma.ts +++ b/src/services/figma.ts @@ -9,6 +9,7 @@ import type { import { downloadFigmaImage } from "~/utils/common.js"; import { Logger } from "~/utils/logger.js"; import yaml from "js-yaml"; +import path from 'path'; export type FigmaAuthOptions = { figmaApiKey: string; @@ -165,8 +166,8 @@ export class FigmaService { const response = await this.request(endpoint); Logger.log("Got response"); const simplifiedResponse = parseFigmaResponse(response); - writeLogs("figma-raw.yml", response); - writeLogs("figma-simplified.yml", simplifiedResponse); + writeJSON2YamlLogs("figma-raw.yml", response); + writeJSON2YamlLogs("figma-simplified.yml", simplifiedResponse); return simplifiedResponse; } catch (e) { console.error("Failed to get file:", e); @@ -178,21 +179,29 @@ export class FigmaService { const endpoint = `/files/${fileKey}/nodes?ids=${nodeId}${depth ? `&depth=${depth}` : ""}`; const response = await this.request(endpoint); Logger.log("Got response from getNode, now parsing."); - writeLogs("figma-raw.yml", response); + writeJSON2YamlLogs("figma-raw.yml", response); const simplifiedResponse = parseFigmaResponse(response); - writeLogs("figma-simplified.yml", simplifiedResponse); + writeJSON2YamlLogs("figma-simplified.yml", simplifiedResponse); + writeLogs("figma-raw.json", JSON.stringify(response)); + writeLogs("figma-simplified.json", JSON.stringify(simplifiedResponse)); return simplifiedResponse; } } +function writeJSON2YamlLogs(name: string, value: any) { + const result = yaml.dump(value); + writeLogs(name, result); +} + function writeLogs(name: string, value: any) { try { if (process.env.NODE_ENV !== "development") return; - const logsDir = "logs"; + const logsCWD = process.env.LOG_DIR || process.cwd(); + const logsDir = path.resolve(logsCWD, "logs"); try { - fs.accessSync(process.cwd(), fs.constants.W_OK); + fs.accessSync(logsCWD, fs.constants.W_OK); } catch (error) { Logger.log("Failed to write logs:", error); return; @@ -201,7 +210,9 @@ function writeLogs(name: string, value: any) { if (!fs.existsSync(logsDir)) { fs.mkdirSync(logsDir); } - fs.writeFileSync(`${logsDir}/${name}`, yaml.dump(value)); + const filePath = path.resolve(logsDir, `${name}`); + fs.writeFileSync(filePath, value); + console.log(`Wrote ${name} to ${filePath}`); } catch (error) { console.debug("Failed to write logs:", error); } diff --git a/src/utils/calc-string-size.ts b/src/utils/calc-string-size.ts new file mode 100644 index 00000000..f843e695 --- /dev/null +++ b/src/utils/calc-string-size.ts @@ -0,0 +1,12 @@ +import { round } from "remeda"; + +/** + * Calculate the size of a string in kilobytes + * @param text - The string to calculate the size of + * @returns The size of the string in kilobytes + */ +export function calcStringSize(text: string) { + const encoder = new TextEncoder(); + const utf8Bytes = encoder.encode(text); + return round(utf8Bytes.length / 1024, 2); +} \ No newline at end of file From 6171a5882757ef5def9103f09e3b4de5cf930303 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=BC=A0=E4=B8=9A?= Date: Thu, 22 May 2025 15:59:04 +0800 Subject: [PATCH 02/10] refactor: Simplify logging and remove YAML handling in MCP - Removed YAML size calculations and logging from the MCP server. - Updated logging to only include JSON data size. - Added environment check to conditionally log JSON to YAML in Figma service. --- src/mcp.ts | 14 +++----------- src/services/figma.ts | 1 + 2 files changed, 4 insertions(+), 11 deletions(-) diff --git a/src/mcp.ts b/src/mcp.ts index 2fdb1642..e2e671a1 100644 --- a/src/mcp.ts +++ b/src/mcp.ts @@ -2,7 +2,6 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { z } from "zod"; import { FigmaService, type FigmaAuthOptions } from "./services/figma.js"; import type { SimplifiedDesign } from "./services/simplify-node-response.js"; -import yaml from "js-yaml"; import { Logger } from "./utils/logger.js"; import { calcStringSize } from './utils/calc-string-size.js'; @@ -52,8 +51,7 @@ function registerTools(server: McpServer, figmaService: FigmaService): void { async ({ fileKey, nodeId, depth }) => { try { Logger.log( - `Fetching ${ - depth ? `${depth} layers deep` : "all layers" + `Fetching ${depth ? `${depth} layers deep` : "all layers" } of ${nodeId ? `node ${nodeId} from file` : `full file`} ${fileKey}`, ); @@ -73,17 +71,11 @@ function registerTools(server: McpServer, figmaService: FigmaService): void { globalVars, }; - const yamlResult = yaml.dump(result); - const yamlResultSize = calcStringSize(yamlResult); - const jsonResult = JSON.stringify(result); const jsonResultSize = calcStringSize(jsonResult); - Logger.log(`Data size: - YAML: ${yamlResultSize} KB - JSON: ${jsonResultSize} KB - `); - + Logger.log(`Data size: ${jsonResultSize} KB `); + Logger.log("Sending result to client"); return { content: [{ type: "text", text: jsonResult }], diff --git a/src/services/figma.ts b/src/services/figma.ts index 6c8e3e63..3598e6b9 100644 --- a/src/services/figma.ts +++ b/src/services/figma.ts @@ -189,6 +189,7 @@ export class FigmaService { } function writeJSON2YamlLogs(name: string, value: any) { + if (process.env.NODE_ENV !== "development") return; const result = yaml.dump(value); writeLogs(name, result); } From ad803caaf4af463139694ffbcf3cddbaf73383e0 Mon Sep 17 00:00:00 2001 From: zhangye <781313769@qq.com> Date: Mon, 2 Jun 2025 23:07:56 +0800 Subject: [PATCH 03/10] feat: Add node size calculation - Introduced a new tool to calculate the memory size of Figma nodes, allowing multiple node IDs to be processed simultaneously. - Updated the existing tool to fetch Figma data, incorporating size limits for data retrieval based on YAML output. - Refactored the Figma service to accept multiple node IDs for fetching node data. --- src/mcp.ts | 122 +++++++++++++++++++++++++++++++++++++----- src/services/figma.ts | 4 +- 2 files changed, 110 insertions(+), 16 deletions(-) diff --git a/src/mcp.ts b/src/mcp.ts index e2e671a1..651594df 100644 --- a/src/mcp.ts +++ b/src/mcp.ts @@ -4,6 +4,7 @@ import { FigmaService, type FigmaAuthOptions } from "./services/figma.js"; import type { SimplifiedDesign } from "./services/simplify-node-response.js"; import { Logger } from "./utils/logger.js"; import { calcStringSize } from './utils/calc-string-size.js'; +import yaml from 'js-yaml'; const serverInfo = { name: "Figma MCP Server", @@ -24,29 +25,114 @@ function createServer( return server; } +const fileKeyDescription = "The key of the Figma file to fetch, often found in a provided URL like figma.com/(file|design)//..."; +const nodeIdDescription = "The ID of the node to fetch, often found as URL parameter node-id=nodeId, always use if provided. If there are multiple node IDs that need to be obtained simultaneously, they can be combined and passed in with commas as separators."; +const depthDescription = "Optional parameter, Controls how many levels deep to traverse the node tree"; + + function registerTools(server: McpServer, figmaService: FigmaService): void { + + const sizeLimit = process.env.GET_NODE_SIZE_LIMIT ? parseInt(process.env.GET_NODE_SIZE_LIMIT) : undefined; + + // Tool to get node size + server.tool( + "get_figma_data_size", + `Get the memory size of a figma data, return the nodeId and size in KB, e.g +- nodeId: '1234:5678' + size: 1024 KB + +Allow multiple nodeIds to be passed in at the same time. +`, + { + fileKey: z + .string() + .describe(fileKeyDescription), + nodeId: z + .string() + .optional() + .describe(nodeIdDescription), + depth: z + .number() + .optional() + .describe(depthDescription), + }, + async ({ fileKey, nodeId, depth }) => { + try { + Logger.log(`Getting size for ${nodeId ? `node ${nodeId}` : 'full file'} from file ${fileKey}`); + + let file: SimplifiedDesign; + if (nodeId) { + file = await figmaService.getNode(fileKey, nodeId, depth); + } else { + file = await figmaService.getFile(fileKey, depth); + } + + const { nodes, globalVars, ...metadata } = file; + + const results = nodes.map((node) => { + const result = { + metadata, + nodes: [node], + // TODO: globalVars is not very accurate here + globalVars, + }; + const yamlResult = yaml.dump(result) + const sizeInKB = calcStringSize(yamlResult); + return { + nodeId: node.id, + size: `${sizeInKB} KB`, + }; + }); + + return { + content: [{ type: "text", text: yaml.dump(results) }], + }; + } catch (error) { + const message = error instanceof Error ? error.message : JSON.stringify(error); + Logger.error(`Error getting node size for ${fileKey}:`, message); + return { + isError: true, + content: [{ type: "text", text: `Error getting node size: ${message}` }], + }; + } + }, + ); + + const needLimitPrompt = sizeLimit && sizeLimit > 0; + // Tool to get file information server.tool( "get_figma_data", - "When the nodeId cannot be obtained, obtain the layout information about the entire Figma file", + `When the nodeId cannot be obtained, obtain the layout information about the entire Figma file. + +${needLimitPrompt ? `## Call Process: + +Goal: Read Figma data while respecting size limits by selectively fetching node details. + +### Overall Strategy: + For any given Figma node (whether it's the initial node you're asked to fetch, or a child node encountered during recursion): + 1. **Determine Node Size**: Use the \`get_figma_data_size\` tool to get the size of the current node. + 2. **Apply Reading Strategy Based on Size**: + * **If Node Size > ${sizeLimit} KB (Node Exceeds Limit):** + a. Fetch the current node's data using \`get_figma_data\` with the \`depth: 1\` parameter. This retrieves the immediate properties of this node and a list of its direct child nodes (basic info only). + b. For each child node obtained in step 2.a: + i. **Recursively Process Child**: Treat this child node as a new current node and go back to Step 1 (Determine Node Size) to decide how to read it. + * **If Node Size <= ${sizeLimit} KB (Node Within Limit):** + a. Fetch the node's data using \`get_figma_data\` *without* specifying the \`depth\` parameter. This performs a full read of this node and all its descendants. +` : ""} + `, { fileKey: z .string() - .describe( - "The key of the Figma file to fetch, often found in a provided URL like figma.com/(file|design)//...", - ), + .describe(fileKeyDescription), nodeId: z .string() .optional() - .describe( - "The ID of the node to fetch, often found as URL parameter node-id=, always use if provided", - ), + .describe(nodeIdDescription), depth: z .number() .optional() - .describe( - "OPTIONAL. Do NOT use unless explicitly requested by the user. Controls how many levels deep to traverse the node tree,", - ), + .describe(depthDescription + ',Do NOT use unless explicitly requested.') }, async ({ fileKey, nodeId, depth }) => { try { @@ -71,14 +157,22 @@ function registerTools(server: McpServer, figmaService: FigmaService): void { globalVars, }; - const jsonResult = JSON.stringify(result); - const jsonResultSize = calcStringSize(jsonResult); + const yamlResult = yaml.dump(result); + const yamlResultSize = calcStringSize(yamlResult); + + Logger.log(`Data size: ${yamlResultSize} KB (YAML)`); - Logger.log(`Data size: ${jsonResultSize} KB `); + if (sizeLimit && yamlResultSize > sizeLimit) { + Logger.log(`Data size exceeds ${sizeLimit} KB, performing truncated reading`); + return { + isError: true, + content: [{ type: "text", text: `Data size exceeds ${sizeLimit} KB, performing truncated reading` }], + }; + } Logger.log("Sending result to client"); return { - content: [{ type: "text", text: jsonResult }], + content: [{ type: "text", text: yamlResult }], }; } catch (error) { const message = error instanceof Error ? error.message : JSON.stringify(error); diff --git a/src/services/figma.ts b/src/services/figma.ts index 3598e6b9..d157b7d7 100644 --- a/src/services/figma.ts +++ b/src/services/figma.ts @@ -175,8 +175,8 @@ export class FigmaService { } } - async getNode(fileKey: string, nodeId: string, depth?: number | null): Promise { - const endpoint = `/files/${fileKey}/nodes?ids=${nodeId}${depth ? `&depth=${depth}` : ""}`; + async getNode(fileKey: string, nodeIds: string, depth?: number | null): Promise { + const endpoint = `/files/${fileKey}/nodes?ids=${nodeIds}${depth ? `&depth=${depth}` : ""}`; const response = await this.request(endpoint); Logger.log("Got response from getNode, now parsing."); writeJSON2YamlLogs("figma-raw.yml", response); From 9dce2cb0028ece94a295fc07c921457ffccb4818 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=BC=A0=E4=B8=9A?= Date: Tue, 3 Jun 2025 17:07:50 +0800 Subject: [PATCH 04/10] feat: optimize the prompt for pruning-based obtain --- src/mcp.ts | 42 +++++++++++++++++++++++++----------------- 1 file changed, 25 insertions(+), 17 deletions(-) diff --git a/src/mcp.ts b/src/mcp.ts index 651594df..b9f3773b 100644 --- a/src/mcp.ts +++ b/src/mcp.ts @@ -37,11 +37,11 @@ function registerTools(server: McpServer, figmaService: FigmaService): void { // Tool to get node size server.tool( "get_figma_data_size", - `Get the memory size of a figma data, return the nodeId and size in KB, e.g + `Obtain the memory size of a figma data, return the nodeId and size in KB, e.g - nodeId: '1234:5678' size: 1024 KB -Allow multiple nodeIds to be passed in at the same time. +Allowed to pass in multiple node IDs to batch obtain the sizes of multiple nodes at one time. `, { fileKey: z @@ -105,22 +105,30 @@ Allow multiple nodeIds to be passed in at the same time. "get_figma_data", `When the nodeId cannot be obtained, obtain the layout information about the entire Figma file. -${needLimitPrompt ? `## Call Process: - -Goal: Read Figma data while respecting size limits by selectively fetching node details. - -### Overall Strategy: - For any given Figma node (whether it's the initial node you're asked to fetch, or a child node encountered during recursion): - 1. **Determine Node Size**: Use the \`get_figma_data_size\` tool to get the size of the current node. - 2. **Apply Reading Strategy Based on Size**: - * **If Node Size > ${sizeLimit} KB (Node Exceeds Limit):** - a. Fetch the current node's data using \`get_figma_data\` with the \`depth: 1\` parameter. This retrieves the immediate properties of this node and a list of its direct child nodes (basic info only). - b. For each child node obtained in step 2.a: - i. **Recursively Process Child**: Treat this child node as a new current node and go back to Step 1 (Determine Node Size) to decide how to read it. - * **If Node Size <= ${sizeLimit} KB (Node Within Limit):** - a. Fetch the node's data using \`get_figma_data\` *without* specifying the \`depth\` parameter. This performs a full read of this node and all its descendants. +Allowed to pass in multiple node IDs to batch obtain data of multiple nodes at one time. + +${needLimitPrompt ? ` +## Data Obtained Strategy + +For target Figma node (initial or recursive child), follow these steps: + +1. **Assess Node Data Size** + * Use \`get-figma-node-size\` tool to estimate current node size + +2. **Determine Method Based on Data Size** + + * **Scenario 1: Node exceeds \`${sizeLimit}\` KB** + a. **Shallow Obtain**: Call \`get_figma_data\` with \`depth: 1\` + * Gets direct properties and immediate child nodes list only + b. **Process Children Recursively**: For each child, repeat from Step 1 + + * **Scenario 2: Node under \`${sizeLimit}\` KB** + a. **Full Obtain**: Call \`get_figma_data\` without depth parameter + * Gets complete data of current node and all descendants + +**Core Idea**: Uses "pruning" concept. For large nodes, get shallow info first, then process children individually. Avoids single large requests while ensuring all data is obtained. ` : ""} - `, + `, { fileKey: z .string() From 23eaadbc87bb5107cd87ebfc56bf753c9cac64c6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=BC=A0=E4=B8=9A?= Date: Wed, 4 Jun 2025 10:26:23 +0800 Subject: [PATCH 05/10] fix: optimize Jest configuration to ensure normal operation - Optimize the `jest.config.cjs` file to configure the Jest test environment and TypeScript support. - Update `tsconfig.json` to exclude the test folder. - Add `tsconfig.test.json`, a dedicated TypeScript configuration for testing. --- jest.config.js => jest.config.cjs | 10 +++++++--- src/tests/integration.test.ts | 16 +++++++++++++--- tsconfig.json | 3 ++- tsconfig.test.json | 7 +++++++ 4 files changed, 29 insertions(+), 7 deletions(-) rename jest.config.js => jest.config.cjs (52%) create mode 100644 tsconfig.test.json diff --git a/jest.config.js b/jest.config.cjs similarity index 52% rename from jest.config.js rename to jest.config.cjs index b53e1e4e..c16f94d4 100644 --- a/jest.config.js +++ b/jest.config.cjs @@ -1,13 +1,17 @@ -module.exports = { +/** @type {import('jest').Config} */ +const config = { preset: 'ts-jest', testEnvironment: 'node', transform: { '^.+\\.tsx?$': ['ts-jest', { - tsconfig: 'tsconfig.json', + tsconfig: 'tsconfig.test.json', }], }, moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'json', 'node'], moduleNameMapper: { - '^~/(.*)$': '/src/$1' + '^~/(.*).js$': '/src/$1', + '^(\\.{1,2}/.*)\\.js$': '$1', } }; + +module.exports = config; \ No newline at end of file diff --git a/src/tests/integration.test.ts b/src/tests/integration.test.ts index fc0afa2f..fa35352a 100644 --- a/src/tests/integration.test.ts +++ b/src/tests/integration.test.ts @@ -1,13 +1,18 @@ -import { createServer } from "../mcp.js"; +import { createServer } from "../mcp" import { config } from "dotenv"; import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js"; import { Client } from "@modelcontextprotocol/sdk/client/index.js"; import { CallToolResultSchema } from "@modelcontextprotocol/sdk/types.js"; import yaml from "js-yaml"; import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { Logger } from '../utils/logger'; -config(); - +// read test args from .env.local (For the first run, need to refer to .env.example to create.) +config({ + path: ".env.local", +}); +Logger.isHTTP = true; + describe("Figma MCP Server Tests", () => { let server: McpServer; let client: Client; @@ -56,8 +61,13 @@ describe("Figma MCP Server Tests", () => { it("should be able to get Figma file data", async () => { const args: any = { fileKey: figmaFileKey, + depth: 1, }; + if (process.env.FIGMA_NODE_ID) { + args.nodeId = process.env.FIGMA_NODE_ID; + } + const result = await client.request( { method: "tools/call", diff --git a/tsconfig.json b/tsconfig.json index a56c31a8..b9264fa5 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -27,5 +27,6 @@ "skipLibCheck": true, "forceConsistentCasingInFileNames": true }, - "include": ["src/**/*"] + "include": ["src/**/*"], + "exclude": ["src/tests/**/*"] } diff --git a/tsconfig.test.json b/tsconfig.test.json new file mode 100644 index 00000000..618803fe --- /dev/null +++ b/tsconfig.test.json @@ -0,0 +1,7 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "verbatimModuleSyntax": false + }, + "include": ["src/tests/**/*"] +} From 59113488e7eeb1d70f337b06d723f611a923179b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=BC=A0=E4=B8=9A?= Date: Wed, 4 Jun 2025 10:29:56 +0800 Subject: [PATCH 06/10] fix: improve error message for data size limit exceeded - Updated the error message to provide more detailed information when the data size exceeds the specified limit, including the file key and node ID. --- src/mcp.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/mcp.ts b/src/mcp.ts index caa69725..9082d74f 100644 --- a/src/mcp.ts +++ b/src/mcp.ts @@ -174,7 +174,7 @@ For target Figma node (initial or recursive child), follow these steps: Logger.log(`Data size exceeds ${sizeLimit} KB, performing truncated reading`); return { isError: true, - content: [{ type: "text", text: `Data size exceeds ${sizeLimit} KB, performing truncated reading` }], + content: [{ type: "text", text: `The data size of file ${fileKey} ${nodeId ? `node ${nodeId}` : ''} is ${yamlResultSize} KB, exceeded the limit of ${sizeLimit} KB, please performing truncated reading` }], }; } From 074da72be914f7e8e046e78e3d4578420f849927 Mon Sep 17 00:00:00 2001 From: Graham Lipsman Date: Thu, 5 Jun 2025 15:26:53 -0700 Subject: [PATCH 07/10] Update pruning prompt to (try) to get AI to use it more intelligently. --- src/mcp.ts | 97 ++++++++++++++++++++++++++++-------------------------- 1 file changed, 50 insertions(+), 47 deletions(-) diff --git a/src/mcp.ts b/src/mcp.ts index 9082d74f..96798d0b 100644 --- a/src/mcp.ts +++ b/src/mcp.ts @@ -3,8 +3,8 @@ import { z } from "zod"; import { FigmaService, type FigmaAuthOptions } from "./services/figma.js"; import type { SimplifiedDesign } from "./services/simplify-node-response.js"; import { Logger } from "./utils/logger.js"; -import { calcStringSize } from './utils/calc-string-size.js'; -import yaml from 'js-yaml'; +import { calcStringSize } from "./utils/calc-string-size.js"; +import yaml from "js-yaml"; const serverInfo = { name: "Figma MCP Server", @@ -25,14 +25,17 @@ function createServer( return server; } -const fileKeyDescription = "The key of the Figma file to fetch, often found in a provided URL like figma.com/(file|design)//..."; -const nodeIdDescription = "The ID of the node to fetch, often found as URL parameter node-id=nodeId, always use if provided. If there are multiple node IDs that need to be obtained simultaneously, they can be combined and passed in with commas as separators."; -const depthDescription = "Optional parameter, Controls how many levels deep to traverse the node tree"; - +const fileKeyDescription = + "The key of the Figma file to fetch, often found in a provided URL like figma.com/(file|design)//..."; +const nodeIdDescription = + "The ID of the node to fetch, often found as URL parameter node-id=nodeId, always use if provided. If there are multiple node IDs that need to be obtained simultaneously, they can be combined and passed in with commas as separators."; +const depthDescription = + "Optional parameter, Controls how many levels deep to traverse the node tree"; function registerTools(server: McpServer, figmaService: FigmaService): void { - - const sizeLimit = process.env.GET_NODE_SIZE_LIMIT ? parseInt(process.env.GET_NODE_SIZE_LIMIT) : undefined; + const sizeLimit = process.env.GET_NODE_SIZE_LIMIT + ? parseInt(process.env.GET_NODE_SIZE_LIMIT) + : undefined; // Tool to get node size server.tool( @@ -44,21 +47,15 @@ function registerTools(server: McpServer, figmaService: FigmaService): void { Allowed to pass in multiple node IDs to batch obtain the sizes of multiple nodes at one time. `, { - fileKey: z - .string() - .describe(fileKeyDescription), - nodeId: z - .string() - .optional() - .describe(nodeIdDescription), - depth: z - .number() - .optional() - .describe(depthDescription), + fileKey: z.string().describe(fileKeyDescription), + nodeId: z.string().optional().describe(nodeIdDescription), + depth: z.number().optional().describe(depthDescription), }, async ({ fileKey, nodeId, depth }) => { try { - Logger.log(`Getting size for ${nodeId ? `node ${nodeId}` : 'full file'} from file ${fileKey}`); + Logger.log( + `Getting size for ${nodeId ? `node ${nodeId}` : "full file"} from file ${fileKey}`, + ); let file: SimplifiedDesign; if (nodeId) { @@ -76,7 +73,7 @@ Allowed to pass in multiple node IDs to batch obtain the sizes of multiple nodes // TODO: globalVars is not very accurate here globalVars, }; - const yamlResult = yaml.dump(result) + const yamlResult = yaml.dump(result); const sizeInKB = calcStringSize(yamlResult); return { nodeId: node.id, @@ -107,45 +104,46 @@ Allowed to pass in multiple node IDs to batch obtain the sizes of multiple nodes Allowed to pass in multiple node IDs to batch obtain data of multiple nodes at one time. -${needLimitPrompt ? ` -## Data Obtained Strategy +${ + needLimitPrompt + ? ` +## Figma Data Retrieval Strategy -For target Figma node (initial or recursive child), follow these steps: +**IMPORTANT: Work incrementally, not comprehensively.** -1. **Assess Node Data Size** - * Use \`get-figma-node-size\` tool to estimate current node size +### Core Principle +Retrieve and implement ONE screen/component at a time. Don't try to understand the entire design upfront. -2. **Determine Method Based on Data Size** +### Process +1. **Start Small**: Get shallow data (depth: 1) of the main node to see available screens/components +2. **Pick One**: Choose a single screen to implement completely +3. **Get Full Data**: Retrieve complete data for that one screen only +4. **Implement**: Build the HTML/CSS for that screen before moving on +5. **Repeat**: Move to the next screen only after the current one is done - * **Scenario 1: Node exceeds \`${sizeLimit}\` KB** - a. **Shallow Obtain**: Call \`get_figma_data\` with \`depth: 1\` - * Gets direct properties and immediate child nodes list only - b. **Process Children Recursively**: For each child, repeat from Step 1 +### Data Size Guidelines +- **Over ${sizeLimit}KB**: Use \`depth: 1\` to get structure only +- **Under ${sizeLimit}KB**: Get full data without depth parameter - * **Scenario 2: Node under \`${sizeLimit}\` KB** - a. **Full Obtain**: Call \`get_figma_data\` without depth parameter - * Gets complete data of current node and all descendants - -**Core Idea**: Uses "pruning" concept. For large nodes, get shallow info first, then process children individually. Avoids single large requests while ensuring all data is obtained. -` : ""} +### Key Point +**Don't analyze multiple screens in parallel.** Focus on implementing one complete, working screen at a time. This avoids context overload and produces better results. +` + : "" +} `, { - fileKey: z - .string() - .describe(fileKeyDescription), - nodeId: z - .string() - .optional() - .describe(nodeIdDescription), + fileKey: z.string().describe(fileKeyDescription), + nodeId: z.string().optional().describe(nodeIdDescription), depth: z .number() .optional() - .describe(depthDescription + ',Do NOT use unless explicitly requested.') + .describe(depthDescription + ",Do NOT use unless explicitly requested."), }, async ({ fileKey, nodeId, depth }) => { try { Logger.log( - `Fetching ${depth ? `${depth} layers deep` : "all layers" + `Fetching ${ + depth ? `${depth} layers deep` : "all layers" } of ${nodeId ? `node ${nodeId} from file` : `full file`} ${fileKey}`, ); @@ -174,7 +172,12 @@ For target Figma node (initial or recursive child), follow these steps: Logger.log(`Data size exceeds ${sizeLimit} KB, performing truncated reading`); return { isError: true, - content: [{ type: "text", text: `The data size of file ${fileKey} ${nodeId ? `node ${nodeId}` : ''} is ${yamlResultSize} KB, exceeded the limit of ${sizeLimit} KB, please performing truncated reading` }], + content: [ + { + type: "text", + text: `The data size of file ${fileKey} ${nodeId ? `node ${nodeId}` : ""} is ${yamlResultSize} KB, exceeded the limit of ${sizeLimit} KB, please performing truncated reading`, + }, + ], }; } From 6c933aea23be72b7706648e98584894b1ac923cf Mon Sep 17 00:00:00 2001 From: zhangye <781313769@qq.com> Date: Sun, 22 Jun 2025 14:36:45 +0800 Subject: [PATCH 08/10] feat: update @figma/rest-api-spec dependency to version 0.31.0 and enhance effects handling in transformers - Updated the version of @figma/rest-api-spec in package.json. - Added support for TextureEffect and NoiseEffect in effects.ts, including functions to simplify and generate styles for these effects. - Improved the buildSimplifiedEffects function to handle visibility checks more robustly. - Enhanced parsePaint function in common.ts to support PATTERN type for Figma paints. - Todo: Test the newly added figma types. --- package.json | 2 +- pnpm-lock.yaml | 10 +- src/transformers/effects.ts | 205 +++++++++++++++++++++++++++++++++++- src/utils/common.ts | 103 +++++++++++++++++- 4 files changed, 310 insertions(+), 10 deletions(-) diff --git a/package.json b/package.json index 6880323d..3aa21696 100644 --- a/package.json +++ b/package.json @@ -50,7 +50,7 @@ "author": "", "license": "MIT", "dependencies": { - "@figma/rest-api-spec": "^0.24.0", + "@figma/rest-api-spec": "^0.31.0", "@modelcontextprotocol/sdk": "^1.10.2", "@types/yargs": "^17.0.33", "cross-env": "^7.0.3", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index fdb3cb06..46f65410 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -9,8 +9,8 @@ importers: .: dependencies: '@figma/rest-api-spec': - specifier: ^0.24.0 - version: 0.24.0 + specifier: ^0.31.0 + version: 0.31.0 '@modelcontextprotocol/sdk': specifier: ^1.10.2 version: 1.10.2 @@ -656,8 +656,8 @@ packages: resolution: {integrity: sha512-lB05FkqEdUg2AA0xEbUz0SnkXT1LcCTa438W4IWTUh4hdOnVbQyOJ81OrDXsJk/LSiJHubgGEFoR5EHq1NsH1A==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@figma/rest-api-spec@0.24.0': - resolution: {integrity: sha512-c/LHQNzfn8HSuo608TnfHJS8K3Ps61MvDbqTTL+qVx2FCIui7dI3RC2bG2/kSHmQXXKTbgbcAADyU6Rf8YkZbQ==} + '@figma/rest-api-spec@0.31.0': + resolution: {integrity: sha512-Mk9hAZjA71etfnydoj6J3f6VL1nYWII50547o0ybz3YqMT61Q/GWsPmZNulPtR6+EYngiZ+Jq2LvJuB5MSaYPA==} '@humanfs/core@0.19.1': resolution: {integrity: sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==} @@ -3481,7 +3481,7 @@ snapshots: '@eslint/core': 0.10.0 levn: 0.4.1 - '@figma/rest-api-spec@0.24.0': {} + '@figma/rest-api-spec@0.31.0': {} '@humanfs/core@0.19.1': {} diff --git a/src/transformers/effects.ts b/src/transformers/effects.ts index e5948ea3..55cfe86e 100644 --- a/src/transformers/effects.ts +++ b/src/transformers/effects.ts @@ -2,6 +2,9 @@ import type { DropShadowEffect, InnerShadowEffect, BlurEffect, + TextureEffect, + NoiseEffect, + BlendMode, Node as FigmaDocumentNode, } from "@figma/rest-api-spec"; import { formatRGBAColor } from "~/utils/common.js"; @@ -12,11 +15,14 @@ export type SimplifiedEffects = { filter?: string; backdropFilter?: string; textShadow?: string; + // textureEffect?: string; + // noiseEffect?: string; + // additionalStyles?: Record; }; export function buildSimplifiedEffects(n: FigmaDocumentNode): SimplifiedEffects { if (!hasValue("effects", n)) return {}; - const effects = n.effects.filter((e) => e.visible); + const effects = n.effects.filter((e) => "visible" in e ? e.visible : true); // Handle drop and inner shadows (both go into CSS box-shadow) const dropShadows = effects @@ -42,6 +48,16 @@ export function buildSimplifiedEffects(n: FigmaDocumentNode): SimplifiedEffects .map(simplifyBlur) .join(" "); + // TODO: handle texture and noise effects + + // // Handle texture effects + // const textureEffects = effects + // .filter((e): e is TextureEffect => e.type === "TEXTURE"); + + // // Handle noise effects + // const noiseEffects = effects + // .filter((e): e is NoiseEffect => e.type === "NOISE"); + const result: SimplifiedEffects = {}; if (boxShadow) { @@ -53,6 +69,24 @@ export function buildSimplifiedEffects(n: FigmaDocumentNode): SimplifiedEffects } if (filterBlurValues) result.filter = filterBlurValues; if (backdropFilterValues) result.backdropFilter = backdropFilterValues; + + // // handle texture effects + // if (textureEffects.length > 0) { + // result.textureEffect = textureEffects.map(simplifyTextureEffect).join(", "); + // result.additionalStyles = { + // ...result.additionalStyles, + // ...generateTextureEffectStyles(textureEffects), + // }; + // } + + // // handle noise effects + // if (noiseEffects.length > 0) { + // result.noiseEffect = noiseEffects.map(simplifyNoiseEffect).join(", "); + // result.additionalStyles = { + // ...result.additionalStyles, + // ...generateNoiseEffectStyles(noiseEffects), + // }; + // } return result; } @@ -68,3 +102,172 @@ function simplifyInnerShadow(effect: InnerShadowEffect) { function simplifyBlur(effect: BlurEffect) { return `blur(${effect.radius}px)`; } + +function simplifyTextureEffect(effect: TextureEffect): string { + return `texture(size: ${effect.noiseSize}px, radius: ${effect.radius}px, clip: ${effect.clipToShape})`; +} + +function simplifyNoiseEffect(effect: NoiseEffect): string { + const baseInfo = `noise(type: ${effect.noiseType}, size: ${effect.noiseSize}px, density: ${effect.density})`; + + if (effect.noiseType === "MULTITONE" && "opacity" in effect) { + return `${baseInfo}, opacity: ${effect.opacity}`; + } + + if (effect.noiseType === "DUOTONE" && "secondaryColor" in effect) { + return `${baseInfo}, secondary: ${formatRGBAColor(effect.secondaryColor)}`; + } + + return baseInfo; +} + +function generateTextureEffectStyles(effects: TextureEffect[]): Record { + const styles: Record = {}; + + effects.forEach((effect, index) => { + const className = `texture-effect-${index}`; + + styles[className] = ` + position: relative; + ${effect.clipToShape ? 'overflow: hidden;' : ''} + + &::after { + content: ''; + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + background-image: + radial-gradient(circle at 25% 25%, rgba(0,0,0,0.1) 1px, transparent 1px), + radial-gradient(circle at 75% 75%, rgba(0,0,0,0.1) 1px, transparent 1px), + radial-gradient(circle at 50% 15%, rgba(0,0,0,0.08) 1px, transparent 1px), + radial-gradient(circle at 85% 50%, rgba(0,0,0,0.12) 1px, transparent 1px); + background-size: ${effect.noiseSize}px ${effect.noiseSize}px; + ${effect.radius > 0 ? `filter: blur(${effect.radius}px);` : ''} + pointer-events: none; + mix-blend-mode: multiply; + } + `; + }); + + return styles; +} + +function generateNoiseEffectStyles(effects: NoiseEffect[]): Record { + const styles: Record = {}; + + effects.forEach((effect, index) => { + const className = `noise-effect-${index}`; + + let backgroundImage = ''; + let finalOpacity = effect.density; + let blendMode = 'multiply'; + + // 获取混合模式字符串(需要将 BlendMode 枚举转换为 CSS 值) + const cssBlendMode = getBlendModeCSS(effect.blendMode); + + switch (effect.noiseType) { + case 'MONOTONE': + // 单色噪点效果 + backgroundImage = ` + radial-gradient(circle at 20% 50%, rgba(0,0,0,0.15) 1px, transparent 1px), + radial-gradient(circle at 60% 20%, rgba(0,0,0,0.12) 1px, transparent 1px), + radial-gradient(circle at 80% 80%, rgba(0,0,0,0.18) 1px, transparent 1px), + radial-gradient(circle at 40% 70%, rgba(0,0,0,0.1) 1px, transparent 1px)`; + blendMode = cssBlendMode || 'multiply'; + break; + + case 'MULTITONE': + // 多色噪点效果 + backgroundImage = ` + radial-gradient(circle at 25% 25%, rgba(255,0,0,0.1) 1px, transparent 1px), + radial-gradient(circle at 75% 25%, rgba(0,255,0,0.1) 1px, transparent 1px), + radial-gradient(circle at 25% 75%, rgba(0,0,255,0.1) 1px, transparent 1px), + radial-gradient(circle at 75% 75%, rgba(255,255,0,0.1) 1px, transparent 1px), + radial-gradient(circle at 50% 50%, rgba(255,0,255,0.1) 1px, transparent 1px)`; + if ("opacity" in effect) { + finalOpacity = effect.density * effect.opacity; + } + blendMode = cssBlendMode || 'overlay'; + break; + + case 'DUOTONE': + // 双色噪点效果 + const secondaryColor = ("secondaryColor" in effect) + ? formatRGBAColor(effect.secondaryColor) + : 'rgba(255,255,255,0.1)'; + const secondaryColorDimmed = secondaryColor.replace(/[\d.]+\)$/, '0.08)'); + + backgroundImage = ` + radial-gradient(circle at 30% 30%, rgba(0,0,0,0.15) 1px, transparent 1px), + radial-gradient(circle at 70% 30%, rgba(0,0,0,0.12) 1px, transparent 1px), + radial-gradient(circle at 30% 70%, ${secondaryColor} 1px, transparent 1px), + radial-gradient(circle at 70% 70%, ${secondaryColorDimmed} 1px, transparent 1px)`; + blendMode = cssBlendMode || 'soft-light'; + break; + + default: + // 默认单色效果 + backgroundImage = ` + radial-gradient(circle at 25% 25%, rgba(0,0,0,0.1) 1px, transparent 1px), + radial-gradient(circle at 75% 75%, rgba(0,0,0,0.1) 1px, transparent 1px)`; + blendMode = cssBlendMode || 'multiply'; + } + + const backgroundSizes = [ + `${effect.noiseSize}px ${effect.noiseSize}px`, + `${Math.round(effect.noiseSize * 1.5)}px ${Math.round(effect.noiseSize * 1.5)}px`, + `${Math.round(effect.noiseSize * 0.8)}px ${Math.round(effect.noiseSize * 0.8)}px`, + `${Math.round(effect.noiseSize * 1.2)}px ${Math.round(effect.noiseSize * 1.2)}px` + ]; + + if (effect.noiseType === 'MULTITONE') { + backgroundSizes.push(`${Math.round(effect.noiseSize * 0.6)}px ${Math.round(effect.noiseSize * 0.6)}px`); + } + + styles[className] = ` + position: relative; + + &::after { + content: ''; + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + background-image: ${backgroundImage}; + background-size: ${backgroundSizes.join(', ')}; + opacity: ${finalOpacity}; + mix-blend-mode: ${blendMode}; + pointer-events: none; + } + `; + }); + + return styles; +} + +// 将 Figma BlendMode 转换为 CSS mix-blend-mode 值 +function getBlendModeCSS(blendMode: BlendMode): string { + const blendModeMap: Record = { + 'NORMAL': 'normal', + 'MULTIPLY': 'multiply', + 'SCREEN': 'screen', + 'OVERLAY': 'overlay', + 'SOFT_LIGHT': 'soft-light', + 'HARD_LIGHT': 'hard-light', + 'COLOR_DODGE': 'color-dodge', + 'COLOR_BURN': 'color-burn', + 'DARKEN': 'darken', + 'LIGHTEN': 'lighten', + 'DIFFERENCE': 'difference', + 'EXCLUSION': 'exclusion', + 'HUE': 'hue', + 'SATURATION': 'saturation', + 'COLOR': 'color', + 'LUMINOSITY': 'luminosity', + }; + + return blendModeMap[blendMode] || 'normal'; +} diff --git a/src/utils/common.ts b/src/utils/common.ts index cc6a258d..8132810c 100644 --- a/src/utils/common.ts +++ b/src/utils/common.ts @@ -5,9 +5,15 @@ import type { Paint, RGBA } from "@figma/rest-api-spec"; import type { CSSHexColor, CSSRGBAColor, + GlobalVars, + SimplifiedDesign, SimplifiedFill, + SimplifiedNode, } from "~/services/simplify-node-response.js"; - +import type { + SimplifiedComponentDefinition, + SimplifiedComponentSetDefinition, +} from "~/utils/sanitization.js"; export type StyleId = `${string}_${string}` & { __brand: "StyleId" }; export interface ColorValue { @@ -75,7 +81,7 @@ export async function downloadFigmaImage( }; // Resolve only when the stream is fully written - writer.on('finish', () => { + writer.on("finish", () => { resolve(fullPath); }); @@ -270,7 +276,7 @@ export function generateCSSShorthand( } /** - * Convert a Figma paint (solid, image, gradient) to a SimplifiedFill + * Convert a Figma paint (solid, image, gradient, pattern) to a SimplifiedFill * @param raw - The Figma paint to convert * @returns The converted SimplifiedFill */ @@ -289,6 +295,21 @@ export function parsePaint(raw: Paint): SimplifiedFill { } else { return formatRGBAColor(raw.color!, opacity); } + } else if (raw.type === "PATTERN") { + // TODO: test this + // try to get the image url from the sourceNodeId + const backgroundRepeat = raw.tileType === "RECTANGULAR" ? "repeat" : "repeat"; + const backgroundPosition = `${raw.horizontalAlignment?.toLowerCase() || "center"} ${raw.verticalAlignment?.toLowerCase() || "center"}`; + const backgroundSize = raw.scalingFactor !== 1 ? `${raw.scalingFactor * 100}%` : "auto"; + + // return the image url + return { + backgroundImage: `url(figma-pattern:${raw.sourceNodeId})`, // placeholder, will be replaced with the actual image url + backgroundRepeat, + backgroundPosition, + backgroundSize, + opacity: raw.opacity, + }; } else if ( ["GRADIENT_LINEAR", "GRADIENT_RADIAL", "GRADIENT_ANGULAR", "GRADIENT_DIAMOND"].includes( raw.type, @@ -329,3 +350,79 @@ export function pixelRound(num: number): number { } return Number(Number(num).toFixed(2)); } + +/** + * Normalize a Figma node ID to + * example: + * 123-14507 -> 123:14507 + * @param nodeId - The Figma node ID to normalize + * @returns The normalized Figma node ID + */ +export function normalizeFigmaNodeId(nodeId: string): string { + return nodeId.replace(/-/g, ":"); +} + +/** + * Remove unused components and component sets from a design + * @param design - The design to remove unused components and component sets from + * @returns The design with unused components and component sets removed + */ +export function removeUnusedComponentsAndStyles(design: SimplifiedDesign): SimplifiedDesign { + const usedComponentIds = new Set(); + const usedComponentSets = new Set(); + const usedStyles = new Set(); + + const findUsedComponents = (node: SimplifiedNode) => { + if (node.type === "INSTANCE") { + if (node.componentId) { + usedComponentIds.add(node.componentId); + } + if (node.textStyle) { + usedStyles.add(node.textStyle); + } + } + if (node.children) { + node.children.forEach(findUsedComponents); + } + }; + + design.nodes.forEach(findUsedComponents); + + const newComponents: Record = {}; + + for (const componentId in design.components) { + if (usedComponentIds.has(componentId)) { + newComponents[componentId] = design.components[componentId]; + if (design.components[componentId].componentSetId) { + usedComponentSets.add(design.components[componentId].componentSetId); + } + } + } + + const newComponentSets: Record = {}; + + for (const componentSetId in design.componentSets) { + if (usedComponentSets.has(componentSetId)) { + newComponentSets[componentSetId] = design.componentSets[componentSetId]; + } + } + + const newGlobalVars: GlobalVars = { + styles: {}, + }; + + for (const styleId in design.globalVars.styles) { + if (usedStyles.has(styleId)) { + newGlobalVars.styles[styleId as StyleId] = design.globalVars.styles[styleId as StyleId]; + } + } + + const newDesign: SimplifiedDesign = { + ...design, + components: newComponents, + componentSets: newComponentSets, + globalVars: newGlobalVars, + }; + + return newDesign; +} From 595bf75feda6fdf133f99eb8d758e9fce0161d3e Mon Sep 17 00:00:00 2001 From: zhangye <781313769@qq.com> Date: Sun, 22 Jun 2025 14:46:44 +0800 Subject: [PATCH 09/10] feat: implement caching mechanism for Figma node data retrieval - Added a caching layer using LRUCache to optimize retrieval of Figma node data. - Introduced ParseDataCache class to manage cache operations, including validation of cache freshness based on file metadata. - Enhanced FigmaService to utilize caching for node requests, improving performance and reducing API calls. - Updated config to include a useCache option for enabling/disabling caching. - Added tests for cache functionality, ensuring correct behavior for cache hits, misses, and freshness validation. --- src/config.ts | 1 + src/mcp.ts | 76 ++- src/services/figma.ts | 159 ++++-- src/services/simplify-node-response.ts | 22 +- src/tests/data-cache.test.ts | 736 +++++++++++++++++++++++++ src/utils/lru.ts | 118 ++++ src/utils/parse-data-cache.ts | 417 ++++++++++++++ src/utils/write-log.ts | 35 ++ 8 files changed, 1504 insertions(+), 60 deletions(-) create mode 100644 src/tests/data-cache.test.ts create mode 100644 src/utils/lru.ts create mode 100644 src/utils/parse-data-cache.ts create mode 100644 src/utils/write-log.ts diff --git a/src/config.ts b/src/config.ts index 280abccd..22996f9b 100644 --- a/src/config.ts +++ b/src/config.ts @@ -60,6 +60,7 @@ export function getServerConfig(isStdioMode: boolean): ServerConfig { figmaApiKey: "", figmaOAuthToken: "", useOAuth: false, + useCache: process.env.USE_CACHE ? process.env.USE_CACHE === "true" : false, }; const config: Omit = { diff --git a/src/mcp.ts b/src/mcp.ts index f100d185..af2a3293 100644 --- a/src/mcp.ts +++ b/src/mcp.ts @@ -1,10 +1,11 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { z } from "zod"; import { FigmaService, type FigmaAuthOptions } from "./services/figma.js"; -import type { SimplifiedDesign } from "./services/simplify-node-response.js"; +import type { SimplifiedDesign, SimplifiedNode } from "./services/simplify-node-response.js"; import { Logger } from "./utils/logger.js"; import { calcStringSize } from "./utils/calc-string-size.js"; import yaml from "js-yaml"; +import { removeUnusedComponentsAndStyles } from "./utils/common.js"; const serverInfo = { name: "Figma MCP Server", @@ -37,6 +38,18 @@ const nodeIdDescription = const depthDescription = "Optional parameter, Controls how many levels deep to traverse the node tree"; +function transformDesign(design: SimplifiedDesign): { + nodes: SimplifiedNode[]; + globalVars: SimplifiedDesign["globalVars"]; + metadata: Pick; +} { + const { nodes, globalVars, ...metadata } = design; + return { + nodes, + globalVars, + metadata, + }; +} function registerTools( server: McpServer, figmaService: FigmaService, @@ -163,32 +176,53 @@ Retrieve and implement ONE screen/component at a time. Don't try to understand t } Logger.log(`Successfully fetched file: ${file.name}`); - const { nodes, globalVars, ...metadata } = file; - const result = { - metadata, - nodes, - globalVars, - }; + const result = transformDesign(file); Logger.log(`Generating ${outputFormat.toUpperCase()} result from file`); - const formattedResult = - outputFormat === "json" ? JSON.stringify(result, null, 2) : yaml.dump(result); - const formattedResultSize = calcStringSize(formattedResult); + + const getFormattedResult = (result: ReturnType) => { + return outputFormat === "json" ? JSON.stringify(result, null, 2) : yaml.dump(result); + }; + + let formattedResult = getFormattedResult(result); + let formattedResultSize = calcStringSize(formattedResult); Logger.log(`Data size: ${formattedResultSize} KB (${outputFormat.toUpperCase()})`); - if (sizeLimit && formattedResultSize > sizeLimit) { - Logger.log(`Data size exceeds ${sizeLimit} KB, performing truncated reading`); - return { - isError: true, - content: [ - { - type: "text", - text: `The data size of file ${fileKey} ${nodeId ? `node ${nodeId}` : ""} is ${formattedResultSize} KB, exceeded the limit of ${sizeLimit} KB, please performing truncated reading`, - }, - ], - }; + const overSizeLimit = sizeLimit && formattedResultSize > sizeLimit; + const depthLimit = !depth || depth > 1; + + if (overSizeLimit) { + Logger.log(`Data size ${formattedResultSize} KB exceeds limit ${sizeLimit} KB`); + if (depthLimit) { + Logger.log(`returning truncated reading`); + return { + isError: true, + content: [ + { + type: "text", + text: `The data size of file ${fileKey} ${nodeId ? `node ${nodeId}` : ""} is ${formattedResultSize} KB, exceeded the limit of ${sizeLimit} KB, please performing truncated reading`, + }, + ], + }; + } else { + const newFile = removeUnusedComponentsAndStyles(file); + const result = transformDesign(newFile); + formattedResult = getFormattedResult(result); + formattedResultSize = calcStringSize(formattedResult); + Logger.log( + `Data size exceeds ${sizeLimit} KB, but depth has been set to 1, removed unused components and component sets, size: ${formattedResultSize} KB`, + ); + + if (formattedResultSize > sizeLimit) { + formattedResult = JSON.stringify(result); + formattedResultSize = calcStringSize(formattedResult); + Logger.log( + `Data size still exceeds ${sizeLimit} KB, returning compressed JSON, size: ${formattedResultSize} KB`, + ); + } + } } Logger.log("Sending result to client"); diff --git a/src/services/figma.ts b/src/services/figma.ts index 9888ad45..29896c2f 100644 --- a/src/services/figma.ts +++ b/src/services/figma.ts @@ -1,21 +1,27 @@ import fs from "fs"; -import { parseFigmaResponse, type SimplifiedDesign } from "./simplify-node-response.js"; +import { + parseFigmaResponse, + type SimplifiedDesign, + type SimplifiedNode, +} from "./simplify-node-response.js"; import type { GetImagesResponse, GetFileResponse, GetFileNodesResponse, GetImageFillsResponse, + GetFileMetaResponse, } from "@figma/rest-api-spec"; -import { downloadFigmaImage } from "~/utils/common.js"; +import { downloadFigmaImage, normalizeFigmaNodeId } 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'; +import { ParseDataCache } from "~/utils/parse-data-cache.js"; +import { writeJSON2YamlLogs, writeLogs } from "../utils/write-log.js"; export type FigmaAuthOptions = { figmaApiKey: string; figmaOAuthToken: string; useOAuth: boolean; + useCache: boolean; }; type FetchImageParams = { @@ -45,11 +51,16 @@ export class FigmaService { private readonly oauthToken: string; private readonly useOAuth: boolean; private readonly baseUrl = "https://api.figma.com/v1"; + private readonly cache: ParseDataCache; + private readonly useCache: boolean; - constructor({ figmaApiKey, figmaOAuthToken, useOAuth }: FigmaAuthOptions) { + constructor({ figmaApiKey, figmaOAuthToken, useOAuth, useCache }: FigmaAuthOptions) { this.apiKey = figmaApiKey || ""; this.oauthToken = figmaOAuthToken || ""; this.useOAuth = !!useOAuth && !!this.oauthToken; + // Create cache with capacity for 10 file node data entries + this.cache = new ParseDataCache(10); + this.useCache = useCache; } private async request(endpoint: string): Promise { @@ -167,46 +178,126 @@ export class FigmaService { } } - async getNode(fileKey: string, nodeIds: string, depth?: number | null): Promise { + async getFileMeta(fileKey: string): Promise { + const endpoint = `/files/${fileKey}/meta`; + const response = await this.request(endpoint); + return response; + } + + /** + * normal nodes handle + * @param fileKey Figma file ID + * @param nodeIds node ID string (comma separated) + * @param depth depth parameter + * @returns processed simplified design data + */ + private async fetchAndProcessNodes( + fileKey: string, + nodeIds: string, + depth?: number | null, + ): Promise { const endpoint = `/files/${fileKey}/nodes?ids=${nodeIds}${depth ? `&depth=${depth}` : ""}`; const response = await this.request(endpoint); Logger.log("Got response from getNode, now parsing."); + const apiResponse = parseFigmaResponse(response); + writeJSON2YamlLogs("figma-raw.yml", response); - const simplifiedResponse = parseFigmaResponse(response); - writeJSON2YamlLogs("figma-simplified.yml", simplifiedResponse); + writeJSON2YamlLogs("figma-simplified.yml", apiResponse); writeLogs("figma-raw.json", JSON.stringify(response)); - writeLogs("figma-simplified.json", JSON.stringify(simplifiedResponse)); - return simplifiedResponse; + writeLogs("figma-simplified.json", JSON.stringify(apiResponse)); + + return apiResponse; } -} -function writeJSON2YamlLogs(name: string, value: any) { - if (process.env.NODE_ENV !== "development") return; - const result = yaml.dump(value); - writeLogs(name, result); -} + async getNode( + fileKey: string, + _nodeIds: string, + depth?: number | null, + ): Promise { + const startTime = performance.now(); + const nodeIds = normalizeFigmaNodeId(_nodeIds); -function writeLogs(name: string, value: any) { - try { - if (process.env.NODE_ENV !== "development") return; + let result: SimplifiedDesign; + if (!this.useCache) { + Logger.log(`Cache disabled, making direct API call for nodes: ${nodeIds}`); + result = await this.fetchAndProcessNodes(fileKey, nodeIds, depth); + } else { + const cacheKey = `${fileKey}:${nodeIds}:${depth || "default"}`; - const logsCWD = process.env.LOG_DIR || process.cwd(); - const logsDir = path.resolve(logsCWD, "logs"); + // Create validation params for cache freshness validation + const validationParams = { + fileKey, + getFileMeta: () => this.getFileMeta(fileKey), + }; - try { - fs.accessSync(logsCWD, fs.constants.W_OK); - } catch (error) { - Logger.log("Failed to write logs:", error); - return; - } + // First check for exact cache match + const cachedResult = await this.cache.get(cacheKey, validationParams); + if (cachedResult) { + Logger.log(`Cache hit: ${cacheKey}`); + result = cachedResult; + } else { + // Parse requested node IDs array + const nodeIdArray = nodeIds.split(",").map((id) => id.trim()); + + // Check cache status for multiple nodes with depth consideration + const { cachedNodes, missingNodeIds, sourceDesign } = await this.cache.findMultipleNodes( + fileKey, + nodeIdArray, + validationParams, + depth, + ); - if (!fs.existsSync(logsDir)) { - fs.mkdirSync(logsDir); + // If all nodes are cached, return merged result directly + if (missingNodeIds.length === 0 && sourceDesign) { + Logger.log(`All nodes are cached: ${nodeIds}`); + const mergedDesign = this.cache.mergeNodesAsDesign(sourceDesign, cachedNodes); + // Cache merged result + this.cache.put(cacheKey, mergedDesign, sourceDesign.lastModified); + result = mergedDesign; + } else { + // If some nodes are missing, request only the missing nodes + let apiResponse: SimplifiedDesign | null = null; + if (missingNodeIds.length > 0) { + const missingNodeIdsStr = missingNodeIds.join(","); + Logger.log(`Partial cache miss, requesting missing nodes: ${missingNodeIdsStr}`); + apiResponse = await this.fetchAndProcessNodes(fileKey, missingNodeIdsStr, depth); + } + + // Merge cached nodes and API response nodes + const allNodes: SimplifiedNode[] = [...cachedNodes]; + if (apiResponse) { + allNodes.push(...apiResponse.nodes); + } + + // Create final merged result + const finalDesign = this.cache.mergeNodesAsDesign(sourceDesign || apiResponse!, allNodes); + + this.cache.put(cacheKey, finalDesign, finalDesign.lastModified); + + Logger.log( + `Cached merged result: ${cacheKey} (cached nodes: ${cachedNodes.length}, API nodes: ${apiResponse?.nodes.length || 0})`, + ); + + result = finalDesign; + } + } } - const filePath = path.resolve(logsDir, `${name}`); - fs.writeFileSync(filePath, value); - console.log(`Wrote ${name} to ${filePath}`); - } catch (error) { - console.debug("Failed to write logs:", error); + const endTime = performance.now(); + Logger.log(`Figma Call Node Time taken: ${((endTime - startTime) / 1000).toFixed(2)} seconds`); + return result; + } + + /** + * Clear all cache entries for a specific file + */ + clearFileCache(fileKey: string): void { + this.cache.clearFileCache(fileKey); + } + + /** + * Clear all cache entries + */ + clearAllCache(): void { + this.cache.clearAllCache(); } } diff --git a/src/services/simplify-node-response.ts b/src/services/simplify-node-response.ts index b01a6f6a..bffcbb91 100644 --- a/src/services/simplify-node-response.ts +++ b/src/services/simplify-node-response.ts @@ -59,7 +59,7 @@ type StyleTypes = | SimplifiedStroke | SimplifiedEffects | string; -type GlobalVars = { +export type GlobalVars = { styles: Record; }; @@ -114,7 +114,20 @@ export interface BoundingBox { export type CSSRGBAColor = `rgba(${number}, ${number}, ${number}, ${number})`; export type CSSHexColor = `#${string}`; + +// CSS background-image +export type CSSBackgroundImage = { + backgroundImage: string; + backgroundRepeat?: "repeat" | "repeat-x" | "repeat-y" | "no-repeat" | "space" | "round"; + backgroundPosition?: string; + backgroundSize?: string; + opacity?: number; +}; + export type SimplifiedFill = + | CSSRGBAColor + | CSSHexColor + | CSSBackgroundImage | { type?: Paint["type"]; hex?: string; @@ -127,9 +140,7 @@ export type SimplifiedFill = position: number; color: ColorValue | string; }[]; - } - | CSSRGBAColor - | CSSHexColor; + }; export interface ColorValue { hex: string; @@ -262,13 +273,14 @@ function parseNode( // text if (hasValue("style", n) && Object.keys(n.style).length) { + // TODO: how to handle text path? const style = n.style; const textStyle: TextStyle = { fontFamily: style.fontFamily, fontWeight: style.fontWeight, fontSize: style.fontSize, lineHeight: - style.lineHeightPx && style.fontSize + "lineHeightPx" in style && style.lineHeightPx && style.fontSize ? `${style.lineHeightPx / style.fontSize}em` : undefined, letterSpacing: diff --git a/src/tests/data-cache.test.ts b/src/tests/data-cache.test.ts new file mode 100644 index 00000000..7824cca4 --- /dev/null +++ b/src/tests/data-cache.test.ts @@ -0,0 +1,736 @@ +import type { SimplifiedDesign, SimplifiedNode } from "../services/simplify-node-response.js"; +import type { GetFileMetaResponse } from "@figma/rest-api-spec"; +import { ParseDataCache } from "../utils/parse-data-cache.js"; +import { Logger } from "../utils/logger.js"; + +// Mock data for testing +const mockNode1: SimplifiedNode = { + id: "node-1", + name: "Test Node 1", + type: "FRAME", + children: [ + { + id: "child-1", + name: "Child Node 1", + type: "RECTANGLE", + children: [ + { + id: "grandchild-1", + name: "Grandchild Node 1", + type: "TEXT", + }, + { + id: "grandchild-2", + name: "Grandchild Node 2", + type: "VECTOR", + }, + ], + }, + { + id: "child-2", + name: "Child Node 2", + type: "TEXT", + }, + ], +}; + +const mockNode2: SimplifiedNode = { + id: "node-2", + name: "Test Node 2", + type: "COMPONENT", + children: [ + { + id: "child-3", + name: "Child Node 3", + type: "RECTANGLE", + }, + ], +}; + +const mockNode3: SimplifiedNode = { + id: "node-3", + name: "Test Node 3", + type: "INSTANCE", +}; + +const mockShallowNode1: SimplifiedNode = { + id: "node-1", + name: "Test Node 1", + type: "FRAME", + children: [ + { + id: "child-1", + name: "Child Node 1", + type: "RECTANGLE", + }, + { + id: "child-2", + name: "Child Node 2", + type: "TEXT", + }, + ], +}; + +const mockDesign1: SimplifiedDesign = { + nodes: [mockNode1], + name: "Test Design 1", + lastModified: "2023-01-01T10:00:00Z", + thumbnailUrl: "https://example.com/thumb1.png", + components: {}, + componentSets: {}, + globalVars: { + styles: {}, + }, +}; + +const mockDesign2: SimplifiedDesign = { + nodes: [mockNode2], + name: "Test Design 2", + lastModified: "2023-01-01T11:00:00Z", + thumbnailUrl: "https://example.com/thumb2.png", + components: {}, + componentSets: {}, + globalVars: { + styles: {}, + }, +}; + +const mockDesign3: SimplifiedDesign = { + nodes: [mockNode3], + name: "Test Design 3", + lastModified: "2023-01-01T12:00:00Z", + thumbnailUrl: "https://example.com/thumb3.png", + components: {}, + componentSets: {}, + globalVars: { + styles: {}, + }, +}; + +const mockShallowDesign1: SimplifiedDesign = { + nodes: [mockShallowNode1], + name: "Test Design 1 Shallow", + lastModified: "2023-01-01T10:00:00Z", + thumbnailUrl: "https://example.com/thumb1.png", + components: {}, + componentSets: {}, + globalVars: { + styles: {}, + }, +}; + +const mockMultiNodeDesign: SimplifiedDesign = { + nodes: [mockNode1, mockNode2, mockNode3], + name: "Multi Node Design", + lastModified: "2023-01-01T13:00:00Z", + thumbnailUrl: "https://example.com/multi-thumb.png", + components: {}, + componentSets: {}, + globalVars: { + styles: {}, + }, +}; + +// Mock file meta response +const mockFileMeta = { + name: "Test File", + last_modified: "2023-01-01T10:00:00Z", +} as unknown as GetFileMetaResponse; + +const mockUpdatedFileMeta = { + name: "Test File", + last_modified: "2023-01-01T14:00:00Z", +} as unknown as GetFileMetaResponse; + +Logger.isHTTP = true; + +describe("ParseDataCache", () => { + let cache: ParseDataCache; + + beforeEach(() => { + cache = new ParseDataCache(10); + }); + + describe("Basic Cache Operations", () => { + it("should store and retrieve cache items", async () => { + const cacheKey = "file1:node-1:default"; + cache.put(cacheKey, mockDesign1, "2023-01-01T10:00:00Z"); + + const result = await cache.get(cacheKey); + expect(result).toEqual(mockDesign1); + }); + + it("should return null for non-existent cache keys", async () => { + const result = await cache.get("non-existent-key"); + expect(result).toBeNull(); + }); + + it("should check if cache has a specific key", () => { + const cacheKey = "file1:node-1:default"; + expect(cache.has(cacheKey)).toBe(false); + + cache.put(cacheKey, mockDesign1, "2023-01-01T10:00:00Z"); + expect(cache.has(cacheKey)).toBe(true); + }); + + it("should store cache with timestamp", async () => { + const cacheKey = "file1:node-1:default"; + const timestamp = "2023-01-01T10:00:00Z"; + + cache.put(cacheKey, mockDesign1, timestamp); + const result = await cache.get(cacheKey); + + expect(result).toEqual(mockDesign1); + }); + }); + + describe("Cache Freshness Validation", () => { + it("should validate fresh cache with matching timestamps", async () => { + const cacheKey = "file1:node-1:default"; + const timestamp = "2023-01-01T10:00:00Z"; + + cache.put(cacheKey, mockDesign1, timestamp); + + const validationParams = { + fileKey: "file1", + getFileMeta: jest.fn().mockResolvedValue(mockFileMeta), + }; + + const result = await cache.get(cacheKey, validationParams); + expect(result).toEqual(mockDesign1); + expect(validationParams.getFileMeta).toHaveBeenCalled(); + }); + + it("should invalidate cache with mismatched timestamps", async () => { + const cacheKey = "file1:node-1:default"; + const oldTimestamp = "2023-01-01T10:00:00Z"; + + cache.put(cacheKey, mockDesign1, oldTimestamp); + + const validationParams = { + fileKey: "file1", + getFileMeta: jest.fn().mockResolvedValue(mockUpdatedFileMeta), + }; + + const result = await cache.get(cacheKey, validationParams); + expect(result).toBeNull(); + expect(cache.has(cacheKey)).toBe(false); + }); + + it("should treat cache without timestamp as valid", async () => { + const cacheKey = "file1:node-1:default"; + + cache.put(cacheKey, mockDesign1, ""); // No timestamp (empty string) + + const validationParams = { + fileKey: "file1", + getFileMeta: jest.fn().mockResolvedValue(mockUpdatedFileMeta), + }; + + const result = await cache.get(cacheKey, validationParams); + expect(result).toEqual(mockDesign1); + }); + + it("should handle validation errors gracefully", async () => { + const cacheKey = "file1:node-1:default"; + const timestamp = "2023-01-01T10:00:00Z"; + + cache.put(cacheKey, mockDesign1, timestamp); + + const validationParams = { + fileKey: "file1", + getFileMeta: jest.fn().mockRejectedValue(new Error("API Error")), + }; + + const result = await cache.get(cacheKey, validationParams); + expect(result).toEqual(mockDesign1); // Should return cached data despite error + }); + }); + + describe("Node Search Operations", () => { + beforeEach(() => { + cache.put("file1:node-1:default", mockDesign1, "2023-01-01T10:00:00Z"); + cache.put("file1:node-2:default", mockDesign2, "2023-01-01T10:00:00Z"); + cache.put("file2:node-3:default", mockDesign3, "2023-01-01T10:00:00Z"); + }); + + it("should find node data in cache", async () => { + const result = await cache.findNodeData("file1", "node-1"); + expect(result).toEqual(mockNode1); + }); + + it("should find child node data in cache", async () => { + const result = await cache.findNodeData("file1", "child-1"); + expect(result).toEqual(mockNode1.children?.[0]); + }); + + it("should find grandchild node data in cache", async () => { + const result = await cache.findNodeData("file1", "grandchild-1"); + expect(result).toEqual(mockNode1.children?.[0]?.children?.[0]); + }); + + it("should return null for non-existent nodes", async () => { + const result = await cache.findNodeData("file1", "non-existent"); + expect(result).toBeNull(); + }); + + it("should return null for nodes in different files", async () => { + const result = await cache.findNodeData("file1", "node-3"); + expect(result).toBeNull(); + }); + + it("should validate node data freshness", async () => { + const validationParams = { + fileKey: "file1", + getFileMeta: jest.fn().mockResolvedValue(mockUpdatedFileMeta), + }; + + // Put cache with old timestamp + cache.put("file1:node-1:default", mockDesign1, "2023-01-01T10:00:00Z"); + + const result = await cache.findNodeData("file1", "node-1", validationParams); + expect(result).toBeNull(); // Should be null due to expired cache + }); + }); + + describe("Depth Parameter Tests", () => { + beforeEach(() => { + // Cache deep design data + cache.put("file1:node-1:default", mockDesign1, "2023-01-01T10:00:00Z"); + // Cache shallow design data + cache.put("file1:node-1:1", mockShallowDesign1, "2023-01-01T10:00:00Z"); + }); + + it("should return cached node without depth requirement", async () => { + const result = await cache.findNodeData("file1", "node-1"); + expect(result).toEqual(mockNode1); + expect(result?.children).toHaveLength(2); + }); + + it("should return cached node with depth requirement satisfied", async () => { + const result = await cache.findNodeData("file1", "node-1", undefined, 2); + expect(result).toEqual(mockNode1); + expect(result?.children).toHaveLength(2); + expect(result?.children?.[0]?.children).toHaveLength(2); + }); + + it("should limit node depth when cached data exceeds requirement", async () => { + const result = await cache.findNodeData("file1", "node-1", undefined, 1); + expect(result).toBeDefined(); + expect(result?.id).toBe("node-1"); + expect(result?.children).toHaveLength(2); + // Children should not have grandchildren due to depth limit + expect(result?.children?.[0]?.children).toBeUndefined(); + }); + + it("should return null when cached node does not satisfy depth requirement", async () => { + // Cache only shallow data + cache.clearAllCache(); + cache.put("file1:node-1:1", mockShallowDesign1, "2023-01-01T10:00:00Z"); + + // Request deeper data than cached + const result = await cache.findNodeData("file1", "node-1", undefined, 2); + expect(result).toBeNull(); + }); + + it("should find grandchild nodes when depth allows", async () => { + const result = await cache.findNodeData("file1", "grandchild-1", undefined, 2); + expect(result).toBeDefined(); + expect(result?.id).toBe("grandchild-1"); + }); + + it("should not find grandchild nodes when depth is limited", async () => { + // Cache only shallow data + cache.clearAllCache(); + cache.put("file1:node-1:1", mockShallowDesign1, "2023-01-01T10:00:00Z"); + + const result = await cache.findNodeData("file1", "grandchild-1", undefined, 1); + expect(result).toBeNull(); + }); + }); + + describe("Multiple Node Operations with Depth", () => { + beforeEach(() => { + cache.put("file1:node-1:default", mockDesign1, "2023-01-01T10:00:00Z"); + cache.put("file1:node-2:default", mockDesign2, "2023-01-01T10:00:00Z"); + cache.put("file1:multi:default", mockMultiNodeDesign, "2023-01-01T10:00:00Z"); + }); + + it("should find multiple nodes with all cached and no depth requirement", async () => { + const result = await cache.findMultipleNodes("file1", ["node-1", "node-2"]); + + expect(result.cachedNodes).toHaveLength(2); + expect(result.missingNodeIds).toHaveLength(0); + expect(result.sourceDesign).toBeTruthy(); + }); + + it("should find multiple nodes with depth requirement", async () => { + const result = await cache.findMultipleNodes("file1", ["node-1", "node-2"], undefined, 1); + + expect(result.cachedNodes).toHaveLength(2); + expect(result.missingNodeIds).toHaveLength(0); + expect(result.sourceDesign).toBeTruthy(); + + // Check that depth is limited + const node1 = result.cachedNodes.find(n => n.id === "node-1"); + expect(node1?.children?.[0]?.children).toBeUndefined(); + }); + + it("should find multiple nodes with some missing due to depth", async () => { + // Clear and add only shallow cache for one node + cache.clearAllCache(); + cache.put("file1:node-1:1", mockShallowDesign1, "2023-01-01T10:00:00Z"); + cache.put("file1:node-2:default", mockDesign1, "2023-01-01T10:00:00Z"); + + const result = await cache.findMultipleNodes("file1", ["node-1", "node-2"], undefined, 2); + + expect(result.cachedNodes).toHaveLength(1); // Only node-2 satisfies depth requirement + expect(result.missingNodeIds).toEqual(["node-2"]); + }); + + it("should validate multiple nodes freshness with depth", async () => { + const validationParams = { + fileKey: "file1", + getFileMeta: jest.fn().mockResolvedValue(mockUpdatedFileMeta), + }; + + // Put cache with old timestamp + cache.put("file1:node-1:default", mockDesign1, "2023-01-01T10:00:00Z"); + + const result = await cache.findMultipleNodes("file1", ["node-1"], validationParams, 1); + + expect(result.cachedNodes).toHaveLength(0); + expect(result.missingNodeIds).toEqual(["node-1"]); + }); + }); + + describe("Cache Management Operations", () => { + beforeEach(() => { + cache.put("file1:node-1:default", mockDesign1, "2023-01-01T10:00:00Z"); + cache.put("file1:node-2:default", mockDesign2, "2023-01-01T10:00:00Z"); + cache.put("file2:node-3:default", mockDesign3, "2023-01-01T10:00:00Z"); + }); + + it("should clear cache for specific file", () => { + cache.clearFileCache("file1"); + + expect(cache.has("file1:node-1:default")).toBe(false); + expect(cache.has("file1:node-2:default")).toBe(false); + expect(cache.has("file2:node-3:default")).toBe(true); + }); + + it("should clear all cache", () => { + cache.clearAllCache(); + + expect(cache.has("file1:node-1:default")).toBe(false); + expect(cache.has("file1:node-2:default")).toBe(false); + expect(cache.has("file2:node-3:default")).toBe(false); + }); + }); + + describe("LRU Cache Behavior", () => { + it("should evict least recently used items when capacity is exceeded", async () => { + const smallCache = new ParseDataCache(2); + + smallCache.put("key1", mockDesign1, "2023-01-01T10:00:00Z"); + smallCache.put("key2", mockDesign2, "2023-01-01T10:00:00Z"); + + expect(smallCache.has("key1")).toBe(true); + expect(smallCache.has("key2")).toBe(true); + + // This should evict key1 + smallCache.put("key3", mockDesign3, "2023-01-01T10:00:00Z"); + + expect(smallCache.has("key1")).toBe(false); + expect(smallCache.has("key2")).toBe(true); + expect(smallCache.has("key3")).toBe(true); + }); + + it("should update access order on get operations", async () => { + const smallCache = new ParseDataCache(2); + + smallCache.put("key1", mockDesign1, "2023-01-01T10:00:00Z"); + smallCache.put("key2", mockDesign2, "2023-01-01T10:00:00Z"); + + // Access key1 to make it recently used + await smallCache.get("key1"); + + // This should evict key2 instead of key1 + smallCache.put("key3", mockDesign3, "2023-01-01T10:00:00Z"); + + expect(smallCache.has("key1")).toBe(true); + expect(smallCache.has("key2")).toBe(false); + expect(smallCache.has("key3")).toBe(true); + }); + }); + + describe("Edge Cases", () => { + it("should handle empty node arrays", async () => { + const result = await cache.findMultipleNodes("file1", []); + + expect(result.cachedNodes).toHaveLength(0); + expect(result.missingNodeIds).toHaveLength(0); + expect(result.sourceDesign).toBeNull(); + }); + + it("should handle deep nested node search", async () => { + const deepNestedDesign: SimplifiedDesign = { + ...mockDesign1, + nodes: [ + { + id: "parent", + name: "Parent", + type: "FRAME", + children: [ + { + id: "deep-child", + name: "Deep Child", + type: "RECTANGLE", + children: [ + { + id: "deeper-child", + name: "Deeper Child", + type: "TEXT", + children: [ + { + id: "deepest-child", + name: "Deepest Child", + type: "VECTOR", + }, + ], + }, + ], + }, + ], + }, + ], + }; + + cache.put("file1:deep:default", deepNestedDesign, "2023-01-01T10:00:00Z"); + + const result = await cache.findNodeData("file1", "deepest-child"); + expect(result?.id).toBe("deepest-child"); + }); + + it("should handle depth limit on deeply nested nodes", async () => { + const deepNestedDesign: SimplifiedDesign = { + ...mockDesign1, + nodes: [ + { + id: "parent", + name: "Parent", + type: "FRAME", + children: [ + { + id: "deep-child", + name: "Deep Child", + type: "RECTANGLE", + children: [ + { + id: "deeper-child", + name: "Deeper Child", + type: "TEXT", + children: [ + { + id: "deepest-child", + name: "Deepest Child", + type: "VECTOR", + }, + ], + }, + ], + }, + ], + }, + ], + }; + + cache.put("file1:deep:default", deepNestedDesign, "2023-01-01T10:00:00Z"); + + // Test with depth limit of 2 + const result = await cache.findNodeData("file1", "parent", undefined, 2); + expect(result?.id).toBe("parent"); + expect(result?.children?.[0]?.children?.[0]?.children).toBeUndefined(); + }); + + it("should handle zero depth limit", async () => { + cache.put("file1:node-1:default", mockDesign1, "2023-01-01T10:00:00Z"); + + const result = await cache.findNodeData("file1", "node-1", undefined, 0); + expect(result?.id).toBe("node-1"); + expect(result?.children).toBeUndefined(); + }); + + it("should handle malformed file meta response", async () => { + const cacheKey = "file1:node-1:default"; + const timestamp = "2023-01-01T10:00:00Z"; + + cache.put(cacheKey, mockDesign1, timestamp); + + const validationParams = { + fileKey: "file1", + getFileMeta: jest.fn().mockResolvedValue({} as unknown as GetFileMetaResponse), // Empty response + }; + + const result = await cache.get(cacheKey, validationParams); + expect(result).toEqual(mockDesign1); // Should handle gracefully + }); + + it("should return null when requesting full child node data from depth-limited cached parent", async () => { + // Create a design with nested children for testing depth limits + const parentWithLimitedDepth: SimplifiedNode = { + id: "parent-node", + name: "Parent Node", + type: "FRAME", + children: [ + { + id: "child-node", + name: "Child Node", + type: "RECTANGLE", + children: [ + { + id: "grandchild-node", + name: "Grandchild Node", + type: "TEXT", + }, + ], + }, + ], + }; + + const designWithLimitedDepth: SimplifiedDesign = { + nodes: [parentWithLimitedDepth], + name: "Test Design with Limited Depth", + lastModified: "2023-01-01T10:00:00Z", + thumbnailUrl: "https://example.com/thumb.png", + components: {}, + componentSets: {}, + globalVars: { + styles: {}, + }, + }; + + // Cache the parent node with depth:3 (limited depth) + cache.put("file1:parent-node:2", designWithLimitedDepth, "2023-01-01T10:00:00Z"); + + // Try to get child node without depth parameter (requesting full data) + // This should return null because we can't guarantee the cached data contains + // the complete child node structure + const result = await cache.findNodeData("file1", "child-node"); + expect(result).toBeNull(); + }); + + it("should return null when requesting full grandchild node data from depth-limited cached ancestor", async () => { + // Create a design with deep nesting + const deepParentWithLimitedDepth: SimplifiedNode = { + id: "deep-parent", + name: "Deep Parent", + type: "FRAME", + children: [ + { + id: "deep-child", + name: "Deep Child", + type: "RECTANGLE", + children: [ + { + id: "deep-grandchild", + name: "Deep Grandchild", + type: "TEXT", + children: [ + { + id: "deep-great-grandchild", + name: "Deep Great Grandchild", + type: "VECTOR", + }, + ], + }, + ], + }, + ], + }; + + const deepDesignWithLimitedDepth: SimplifiedDesign = { + nodes: [deepParentWithLimitedDepth], + name: "Deep Test Design with Limited Depth", + lastModified: "2023-01-01T10:00:00Z", + thumbnailUrl: "https://example.com/deep-thumb.png", + components: {}, + componentSets: {}, + globalVars: { + styles: {}, + }, + }; + + // Cache the parent node with depth:2 (limited depth) + cache.put("file1:deep-parent:2", deepDesignWithLimitedDepth, "2023-01-01T10:00:00Z"); + + // Try to get deep grandchild without depth parameter (requesting full data) + // This should return null because the cached data with depth:2 might not contain + // the complete deep-grandchild structure (which could have children beyond depth 2) + const result = await cache.findNodeData("file1", "deep-grandchild"); + expect(result).toBeNull(); + }); + + it("should return child node when cached parent has sufficient depth for the request", async () => { + const parentWithSufficientDepth: SimplifiedNode = { + id: "sufficient-parent", + name: "Sufficient Parent", + type: "FRAME", + children: [ + { + id: "sufficient-child-deep", + name: "Sufficient Child Deep", + type: "RECTANGLE", + children: [ + { + id: "deep-grandchild", + name: "Deep Grandchild", + type: "TEXT", + }, + ], + }, + { + id: "sufficient-child-at-limit", + name: "Sufficient Child At Limit", + type: "ELLIPSE", + children: [ + { + id: "deep-grandchild-1", + name: "Deep Grandchild 1", + type: 'RECTANGLE', + children: [ + { + id: "deep-grandchild-2", + name: "Deep Grandchild 2", + type: "RECTANGLE", + }, + ], + }, + ], + }, + ], + }; + + const designWithSufficientDepth: SimplifiedDesign = { + nodes: [parentWithSufficientDepth], + name: "Test Design with Sufficient Depth", + lastModified: "2023-01-01T10:00:00Z", + thumbnailUrl: "https://example.com/sufficient-thumb.png", + components: {}, + componentSets: {}, + globalVars: { + styles: {}, + }, + }; + + cache.put("file1:sufficient-parent:3", designWithSufficientDepth, "2023-01-01T10:00:00Z"); + + const result = await cache.findNodeData("file1", "deep-grandchild"); + expect(result).toBeDefined(); + expect(result?.id).toBe("deep-grandchild"); + + const unCompleteResult = await cache.findNodeData("file1", "deep-grandchild-2"); + expect(unCompleteResult).toBeNull(); + }); + }); +}); diff --git a/src/utils/lru.ts b/src/utils/lru.ts new file mode 100644 index 00000000..55fc48f9 --- /dev/null +++ b/src/utils/lru.ts @@ -0,0 +1,118 @@ +interface LRUCacheNode { + key: K; + value: V; + prev: LRUCacheNode | null; + next: LRUCacheNode | null; +} + +export class LRUCache { + private capacity: number; + private cache: Map>; + private head: LRUCacheNode | null; + private tail: LRUCacheNode | null; + + constructor(capacity: number) { + this.capacity = capacity; + this.cache = new Map(); + this.head = null; + this.tail = null; + } + + private removeNode(node: LRUCacheNode): void { + if (node.prev) { + node.prev.next = node.next; + } else { + this.head = node.next; + } + + if (node.next) { + node.next.prev = node.prev; + } else { + this.tail = node.prev; + } + } + + private addToFront(node: LRUCacheNode): void { + node.next = this.head; + node.prev = null; + + if (this.head) { + this.head.prev = node; + } + this.head = node; + + if (!this.tail) { + this.tail = node; + } + } + + private moveToFront(node: LRUCacheNode): void { + if (node === this.head) return; + this.removeNode(node); + this.addToFront(node); + } + + get(key: K): V | undefined { + const node = this.cache.get(key); + if (!node) return undefined; + + this.moveToFront(node); + return node.value; + } + + put(key: K, value: V): void { + const existingNode = this.cache.get(key); + + if (existingNode) { + existingNode.value = value; + this.moveToFront(existingNode); + return; + } + + const newNode: LRUCacheNode = { + key, + value, + prev: null, + next: null, + }; + + if (this.cache.size >= this.capacity) { + if (this.tail) { + this.cache.delete(this.tail.key); + this.removeNode(this.tail); + } + } + + this.cache.set(key, newNode); + this.addToFront(newNode); + } + + delete(key: K): boolean { + const node = this.cache.get(key); + if (!node) return false; + + this.removeNode(node); + this.cache.delete(key); + return true; + } + + clear(): void { + this.cache.clear(); + this.head = null; + this.tail = null; + } + + size(): number { + return this.cache.size; + } + + has(key: K): boolean { + return this.cache.has(key); + } + + forEach(callback: (value: V, key: K) => void): void { + this.cache.forEach((node, key) => { + callback(node.value, key); + }); + } +} diff --git a/src/utils/parse-data-cache.ts b/src/utils/parse-data-cache.ts new file mode 100644 index 00000000..371e1e87 --- /dev/null +++ b/src/utils/parse-data-cache.ts @@ -0,0 +1,417 @@ +import { LRUCache } from "./lru.js"; +import { Logger } from "./logger.js"; +import type { SimplifiedDesign, SimplifiedNode } from "../services/simplify-node-response.js"; +import type { GetFileMetaResponse } from "@figma/rest-api-spec"; +import { writeLogs } from "./write-log.js"; + +interface CacheItem { + data: SimplifiedDesign; + lastTouchedAt: string; +} + +interface CacheValidationParams { + fileKey: string; + getFileMeta: () => Promise; +} + +export class ParseDataCache { + private readonly cache: LRUCache; + debug: boolean = false; + + constructor(capacity: number = 10) { + this.cache = new LRUCache(capacity); + } + + /** + * Check if cached node data satisfies the required depth + * The logic is based on whether the cached data was fetched with sufficient depth + * to provide the target node with the requested depth capabilities + */ + private checkNodeDepthSatisfiesRequirement( + cacheData: SimplifiedDesign, + targetNodeId: string, + requiredDepth: number | null, + ): boolean { + + if (requiredDepth === null || requiredDepth === undefined) { + return true; // No depth requirement, any cached data is acceptable + } + // Find the target node in the cache data + const targetNode = this.findNodeInTree(cacheData.nodes, targetNodeId); + if (!targetNode) { + Logger.log(`targetNode not found: ${targetNodeId} ${cacheData.nodes.map((node) => node.id).join(",")}`); + return false; // Node not found in cache + } + + // The key insight: check if the cached design has sufficient overall depth + // to indicate it was fetched with enough depth to support the query + const overallCacheDepth = Math.max(...cacheData.nodes.map((node) => this.getNodeDepth(node))); + + // For individual node queries, we're more lenient: + // 1. If overall cache depth >= required depth, accept it + // 2. If the target node has children or can provide the required depth from itself, accept it + const targetNodeDepth = this.getNodeDepth(targetNode); + + return overallCacheDepth >= requiredDepth || targetNodeDepth >= requiredDepth; + } + + /** + * Get the actual depth of a node tree (how many levels of children it has) + * Depth 0 = no children, Depth 1 = has children, Depth 2 = has grandchildren, etc. + */ + private getNodeDepth(node: SimplifiedNode): number { + if (!node.children || node.children.length === 0) { + return 0; + } + + const childDepths = node.children.map((child) => this.getNodeDepth(child)); + return 1 + Math.max(...childDepths); + } + + /** + * Get the depth of a specific node from the root of the tree + * Returns the number of levels from root to the target node + * Root nodes have depth 0, their children have depth 1, etc. + */ + private getNodeDepthFromRoot(nodes: SimplifiedNode[], targetId: string, currentDepth: number = 0): number { + for (const node of nodes) { + if (node.id === targetId) { + return currentDepth; + } + if (node.children) { + const found = this.getNodeDepthFromRoot(node.children, targetId, currentDepth + 1); + if (found !== -1) { + return found; + } + } + } + return -1; // Not found + } + + /** + * Limit node tree to specified depth + * maxDepth 0 = no children, maxDepth 1 = 1 level of children, etc. + */ + private limitNodeDepth(node: SimplifiedNode, maxDepth: number): SimplifiedNode { + if (maxDepth <= 0) { + // Remove children if depth limit is reached + const { children, ...nodeWithoutChildren } = node; + return nodeWithoutChildren; + } + + if (!node.children || node.children.length === 0) { + return node; + } + + return { + ...node, + children: node.children.map((child) => this.limitNodeDepth(child, maxDepth - 1)), + }; + } + + /** + * Recursively find a node by ID in the node tree + */ + private findNodeInTree(nodes: SimplifiedNode[], targetId: string): SimplifiedNode | null { + for (const node of nodes) { + if (node.id === targetId) { + return node; + } + if (node.children) { + const found = this.findNodeInTree(node.children, targetId); + if (found) { + return found; + } + } + } + return null; + } + + /** + * Extract depth limit from cache key + * Cache keys with depth limits end with ":number", e.g., "file1:node-1:3" + * Cache keys without depth limits end with ":default", e.g., "file1:node-1:default" + */ + private getCacheKeyDepthLimit(cacheKey: string): number | null { + const parts = cacheKey.split(':'); + const lastPart = parts[parts.length - 1]; + + // If the last part is a number, it's a depth limit + const depthLimit = parseInt(lastPart, 10); + if (!isNaN(depthLimit)) { + return depthLimit; + } + + // If it's "default" or any other string, no depth limit + return null; + } + + /** + * Search for a node in cache and return both the node and cache item + * Now considers depth parameter when searching for compatible cache entries + */ + private findNodeInCache( + fileKey: string, + nodeId: string, + requiredDepth?: number | null, + ): { node: SimplifiedNode; cacheItem: CacheItem } | null { + let result: { node: SimplifiedNode; cacheItem: CacheItem } | null = null; + + // Iterate through all cache entries for the file + this.cache.forEach((cacheItem, cacheKey) => { + if (result) return; // Already found, no need to continue + + if (cacheKey.startsWith(`${fileKey}:`)) { + const cacheDepthLimit = this.getCacheKeyDepthLimit(cacheKey); + + // If requesting full data (requiredDepth is null/undefined) but cache has depth limit, + // we need to ensure the target node's data is complete within the cached depth + if ((requiredDepth === null || requiredDepth === undefined) && cacheDepthLimit !== null) { + const targetNode = this.findNodeInTree(cacheItem.data.nodes, nodeId); + if (targetNode) { + // Calculate the depth of the target node from the root + const nodeDepthFromRoot = this.getNodeDepthFromRoot(cacheItem.data.nodes, nodeId); + // Calculate the actual depth of the target node's subtree + const nodeSubtreeDepth = this.getNodeDepth(targetNode); + + const totalDepthNeeded = nodeDepthFromRoot + nodeSubtreeDepth; + + if (totalDepthNeeded >= cacheDepthLimit) { + Logger.log( + `Found cached data for ${nodeId} (from cache key: ${cacheKey}) but cache has depth limit ${cacheDepthLimit} while requesting full data. Node depth from root: ${nodeDepthFromRoot}, subtree depth: ${nodeSubtreeDepth}, total depth needed: ${totalDepthNeeded} >= cache limit`, + ); + return; // Continue to next cache entry + } + + // If we reach here, the cached data should be complete + Logger.log( + `Found cached data for ${nodeId} (from cache key: ${cacheKey}) with depth limit ${cacheDepthLimit}. Node depth from root: ${nodeDepthFromRoot}, subtree depth: ${nodeSubtreeDepth}, total depth needed: ${totalDepthNeeded} < cache limit. Data should be complete.`, + ); + } else { + Logger.log( + `Found cached data for ${nodeId} (from cache key: ${cacheKey}) but cache has depth limit ${cacheDepthLimit} while requesting full data`, + ); + return; // Continue to next cache entry + } + } + + // Check if this cache data satisfies the depth requirement for the target node + if ( + this.checkNodeDepthSatisfiesRequirement(cacheItem.data, nodeId, requiredDepth ?? null) + ) { + const node = this.findNodeInTree(cacheItem.data.nodes, nodeId); + if (node) { + Logger.log( + `Found cached node: ${nodeId} (from cache key: ${cacheKey}) satisfies depth requirement: ${requiredDepth}`, + ); + + // If we need to limit the depth, create a limited version + let finalNode = node; + if (requiredDepth !== null && requiredDepth !== undefined && requiredDepth >= 0) { + finalNode = this.limitNodeDepth(node, requiredDepth); + } + + result = { node: finalNode, cacheItem }; + } + } else { + Logger.log( + `Found cached data for ${nodeId} (from cache key: ${cacheKey}) but does not satisfy depth requirement: ${requiredDepth}`, + ); + } + } + }); + + return result; + } + + /** + * Create a SimplifiedDesign object containing multiple nodes + */ + private createDesignFromNodes( + originalDesign: SimplifiedDesign, + nodes: SimplifiedNode[], + ): SimplifiedDesign { + return { + name: originalDesign.name, + lastModified: originalDesign.lastModified, + thumbnailUrl: originalDesign.thumbnailUrl, + nodes: nodes, + components: originalDesign.components, + componentSets: originalDesign.componentSets, + globalVars: originalDesign.globalVars, + }; + } + + /** + * Validate cache item freshness by comparing timestamps + */ + private async validateCacheItem( + cacheItem: CacheItem, + validationParams: CacheValidationParams, + ): Promise { + if (!cacheItem.lastTouchedAt) { + // Cache items without timestamp are considered valid (backward compatibility) + return true; + } + + try { + const startTime = performance.now(); + const fileMeta = await validationParams.getFileMeta(); + Logger.log(`Figma Call Meta Time taken: ${((performance.now() - startTime) / 1000).toFixed(2)}s`); + const currentLastTouchedAt = fileMeta.last_touched_at; + + if (currentLastTouchedAt && currentLastTouchedAt !== cacheItem.lastTouchedAt) { + Logger.log( + `Cache expired: ${validationParams.fileKey} (cached: ${cacheItem.lastTouchedAt}, current: ${currentLastTouchedAt})`, + ); + return false; + } + + return true; + } catch (error) { + Logger.log(`Error validating cache freshness: ${error}, assuming cache is valid`); + return true; // Assume cache is valid when validation fails + } + } + + /** + * Get cache item with optional freshness validation + */ + async get( + cacheKey: string, + validationParams?: CacheValidationParams, + ): Promise { + const cacheItem = this.cache.get(cacheKey); + if (!cacheItem) { + return null; + } + + // Validate freshness if validation params are provided + if (validationParams) { + const isValid = await this.validateCacheItem(cacheItem, validationParams); + if (!isValid) { + // Cache expired, delete and return null + this.cache.delete(cacheKey); + return null; + } + } + + Logger.log(`Using exact cache match: ${cacheKey}`); + return cacheItem.data; + } + + /** + * Find and validate cached node data + */ + async findNodeData( + fileKey: string, + nodeId: string, + validationParams?: CacheValidationParams, + requiredDepth?: number | null, + ): Promise { + const result = this.findNodeInCache(fileKey, nodeId, requiredDepth); + if (!result) { + return null; + } + + // Validate freshness if validation params are provided + if (validationParams) { + const isValid = await this.validateCacheItem(result.cacheItem, validationParams); + if (!isValid) { + // Cache expired, clear related cache + this.clearFileCache(fileKey); + return null; + } + } + + return result.node; + } + + /** + * Handle cache lookup for multiple nodes + */ + async findMultipleNodes( + fileKey: string, + nodeIds: string[], + validationParams?: CacheValidationParams, + requiredDepth?: number | null, + ): Promise<{ + cachedNodes: SimplifiedNode[]; + missingNodeIds: string[]; + sourceDesign: SimplifiedDesign | null; + }> { + const cachedNodes: SimplifiedNode[] = []; + const missingNodeIds: string[] = []; + let sourceDesign: SimplifiedDesign | null = null; + + for (const nodeId of nodeIds) { + const cachedNode = await this.findNodeData(fileKey, nodeId, validationParams, requiredDepth); + if (cachedNode) { + cachedNodes.push(cachedNode); + // Record source design data for later merging + if (!sourceDesign) { + this.cache.forEach((cacheItem, cacheKey) => { + if ( + cacheKey.startsWith(`${fileKey}:`) && + this.findNodeInTree(cacheItem.data.nodes, nodeId) + ) { + sourceDesign = cacheItem.data; + } + }); + } + } else { + missingNodeIds.push(nodeId); + } + } + + return { cachedNodes, missingNodeIds, sourceDesign }; + } + + /** + * Cache data with optional timestamp + */ + put(cacheKey: string, data: SimplifiedDesign, lastTouchedAt: string): void { + const cacheItem: CacheItem = { + data, + lastTouchedAt, + }; + this.cache.put(cacheKey, cacheItem); + Logger.log(`Cached data: ${cacheKey}${lastTouchedAt ? ` (timestamp: ${lastTouchedAt})` : ""}`); + } + + /** + * Merge node data and create new design object + */ + mergeNodesAsDesign(sourceDesign: SimplifiedDesign, nodes: SimplifiedNode[]): SimplifiedDesign { + return this.createDesignFromNodes(sourceDesign, nodes); + } + + /** + * Clear all cache entries for a specific file + */ + clearFileCache(fileKey: string): void { + const keysToDelete: string[] = []; + this.cache.forEach((_, cacheKey) => { + if (cacheKey.startsWith(`${fileKey}:`)) { + keysToDelete.push(cacheKey); + } + }); + keysToDelete.forEach((key) => this.cache.delete(key)); + Logger.log(`Cleared ${keysToDelete.length} cache entries for file: ${fileKey}`); + } + + /** + * Clear all cache entries + */ + clearAllCache(): void { + this.cache.clear(); + Logger.log("Cleared all node cache"); + } + + /** + * Check if cache contains a specific key + */ + has(cacheKey: string): boolean { + return this.cache.has(cacheKey); + } +} diff --git a/src/utils/write-log.ts b/src/utils/write-log.ts new file mode 100644 index 00000000..9073c31b --- /dev/null +++ b/src/utils/write-log.ts @@ -0,0 +1,35 @@ +import { Logger } from "./logger.js"; +import path from "path"; +import fs from "fs"; +import yaml from "js-yaml"; + +export function writeJSON2YamlLogs(name: string, value: Record) { + if (process.env.NODE_ENV !== "development") return; + const result = yaml.dump(value); + writeLogs(name, result); +} + +export function writeLogs(name: string, value: any) { + try { + if (process.env.NODE_ENV !== "development") return; + + const logsCWD = process.env.LOG_DIR || process.cwd(); + const logsDir = path.resolve(logsCWD, "logs"); + + try { + fs.accessSync(logsCWD, fs.constants.W_OK); + } catch (error) { + Logger.log("Failed to write logs:", error); + return; + } + + if (!fs.existsSync(logsDir)) { + fs.mkdirSync(logsDir); + } + const filePath = path.resolve(logsDir, `${name}`); + fs.writeFileSync(filePath, value); + console.log(`Wrote ${name} to ${filePath}`); + } catch (error) { + console.debug("Failed to write logs:", error); + } +} From 4769376cdba2ae5a081592110a40fed2e14b8676 Mon Sep 17 00:00:00 2001 From: zhangye <781313769@qq.com> Date: Fri, 25 Jul 2025 21:43:40 +0800 Subject: [PATCH 10/10] feat: Add get_figma_data_size tool and integrate into server - Introduced a new tool, get_figma_data_size, to fetch the memory size of Figma data. - Updated index.ts to register the new tool with the server. - Enhanced get_figma_data tool to include size limit handling and guidelines for data retrieval. - Updated relevant types and imports across multiple files for consistency. --- src/mcp/index.ts | 11 +++ src/mcp/tools/get-figma-data-size-tool.ts | 107 ++++++++++++++++++++++ src/mcp/tools/get-figma-data-tool.ts | 63 ++++++++++++- src/mcp/tools/index.ts | 2 + src/utils/common.ts | 2 + src/utils/parse-data-cache.ts | 3 +- 6 files changed, 181 insertions(+), 7 deletions(-) create mode 100644 src/mcp/tools/get-figma-data-size-tool.ts diff --git a/src/mcp/index.ts b/src/mcp/index.ts index 1f0790ff..cbcddd87 100644 --- a/src/mcp/index.ts +++ b/src/mcp/index.ts @@ -3,9 +3,11 @@ import { FigmaService, type FigmaAuthOptions } from "../services/figma.js"; import { Logger } from "../utils/logger.js"; import { downloadFigmaImagesTool, + getFigmaDataSizeTool, getFigmaDataTool, type DownloadImagesParams, type GetFigmaDataParams, + type GetFigmaDataSizeParams, } from "./tools/index.js"; const serverInfo = { @@ -58,6 +60,15 @@ function registerTools( (params: DownloadImagesParams) => downloadFigmaImagesTool.handler(params, figmaService), ); } + + // Register get_figma_data_size tool + server.tool( + getFigmaDataSizeTool.name, + getFigmaDataSizeTool.description, + getFigmaDataSizeTool.parameters, + (params: GetFigmaDataSizeParams) => + getFigmaDataSizeTool.handler(params, figmaService, options.outputFormat), + ); } export { createServer }; diff --git a/src/mcp/tools/get-figma-data-size-tool.ts b/src/mcp/tools/get-figma-data-size-tool.ts new file mode 100644 index 00000000..048153df --- /dev/null +++ b/src/mcp/tools/get-figma-data-size-tool.ts @@ -0,0 +1,107 @@ +import { z } from "zod"; +import type { GetFileResponse, GetFileNodesResponse } from "@figma/rest-api-spec"; +import { FigmaService } from "~/services/figma.js"; +import { simplifyRawFigmaObject, allExtractors } from "~/extractors/index.js"; +import yaml from "js-yaml"; +import { Logger, writeLogs } from "~/utils/logger.js"; +import { calcStringSize } from "~/utils/calc-string-size.js"; +import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js"; + +const parameters = { + fileKey: z + .string() + .describe( + "The key of the Figma file to fetch, often found in a provided URL like figma.com/(file|design)//...", + ), + nodeId: z + .string() + .optional() + .describe( + "The ID of the node to fetch, often found as URL parameter node-id=, always use if provided", + ), + depth: z + .number() + .optional() + .describe( + "OPTIONAL. Do NOT use unless explicitly requested by the user. Controls how many levels deep to traverse the node tree.", + ), +}; + +const parametersSchema = z.object(parameters); +export type GetFigmaDataSizeParams = z.infer; + +// Simplified handler function +async function getFigmaDataSize( + params: GetFigmaDataSizeParams, + figmaService: FigmaService, + outputFormat: "yaml" | "json", +): Promise { + try { + const { fileKey, nodeId, depth } = params; + + Logger.log( + `Fetching ${depth ? `${depth} layers deep` : "all layers"} of ${ + nodeId ? `node ${nodeId} from file` : `full file` + } ${fileKey}`, + ); + + // Get raw Figma API response + let rawApiResponse: GetFileResponse | GetFileNodesResponse; + if (nodeId) { + rawApiResponse = await figmaService.getRawNode(fileKey, nodeId, depth); + } else { + rawApiResponse = await figmaService.getRawFile(fileKey, depth); + } + + // Use unified design extraction (handles nodes + components consistently) + const simplifiedDesign = simplifyRawFigmaObject(rawApiResponse, allExtractors, { + maxDepth: depth, + }); + + writeLogs("figma-simplified.json", simplifiedDesign); + + Logger.log( + `Successfully extracted data: ${simplifiedDesign.nodes.length} nodes, ${Object.keys(simplifiedDesign.globalVars.styles).length} styles`, + ); + + const { nodes, globalVars, ...metadata } = simplifiedDesign; + const result = { + metadata, + nodes, + globalVars, + }; + + Logger.log(`Generating ${outputFormat.toUpperCase()} result from extracted data`); + const formattedResult = + outputFormat === "json" ? JSON.stringify(result, null, 2) : yaml.dump(result); + + + const size = calcStringSize(formattedResult); + const sizeResult = [{ + nodeId: nodeId, + size: size, + }]; + + Logger.log("Sending result to client"); + + return { + content: [{ type: "text", text: yaml.dump(sizeResult) }], + }; + } catch (error) { + const message = error instanceof Error ? error.message : JSON.stringify(error); + Logger.error(`Error fetching file ${params.fileKey}:`, message); + return { + isError: true, + content: [{ type: "text", text: `Error fetching file: ${message}` }], + }; + } +} + +// Export tool configuration +export const getFigmaDataSizeTool = { + name: "get_figma_data_size", + description: + "Obtain the memory size of a figma data, return the nodeId and size in KB, e.g \n- nodeId: '1234:5678'\n size: 1024 KB", + parameters, + handler: getFigmaDataSize, +} as const; diff --git a/src/mcp/tools/get-figma-data-tool.ts b/src/mcp/tools/get-figma-data-tool.ts index c647ec5f..7b501f4a 100644 --- a/src/mcp/tools/get-figma-data-tool.ts +++ b/src/mcp/tools/get-figma-data-tool.ts @@ -4,6 +4,8 @@ import { FigmaService } from "~/services/figma.js"; import { simplifyRawFigmaObject, allExtractors } from "~/extractors/index.js"; import yaml from "js-yaml"; import { Logger, writeLogs } from "~/utils/logger.js"; +import { calcStringSize } from "~/utils/calc-string-size.js"; +import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js"; const parameters = { fileKey: z @@ -28,12 +30,17 @@ const parameters = { const parametersSchema = z.object(parameters); export type GetFigmaDataParams = z.infer; +const sizeLimit = process.env.GET_NODE_SIZE_LIMIT + ? parseInt(process.env.GET_NODE_SIZE_LIMIT) + : undefined; +const needLimitPrompt = sizeLimit && sizeLimit > 0; + // Simplified handler function async function getFigmaData( params: GetFigmaDataParams, figmaService: FigmaService, outputFormat: "yaml" | "json", -) { +): Promise { try { const { fileKey, nodeId, depth } = params; @@ -73,16 +80,39 @@ async function getFigmaData( const formattedResult = outputFormat === "json" ? JSON.stringify(result, null, 2) : yaml.dump(result); + const size = calcStringSize(formattedResult); + + const overSizeLimit = sizeLimit && size > sizeLimit; + const depthLimit = !depth || depth > 1; + + Logger.log(`Data Size: ${size} KB`); + + if (overSizeLimit) { + Logger.log(`Data Size over size limit: ${sizeLimit} KB`); + if (depthLimit) { + Logger.log(`send error to client, need to perform truncated reading`); + return { + isError: true, + content: [ + { + type: "text", + text: `The data size of file ${fileKey} ${nodeId ? `node ${nodeId}` : ""} is ${size} KB, exceeded the limit of ${sizeLimit} KB, please performing truncated reading`, + }, + ], + }; + } + } + Logger.log("Sending result to client"); return { - content: [{ type: "text" as const, text: formattedResult }], + content: [{ type: "text", text: formattedResult }], }; } catch (error) { const message = error instanceof Error ? error.message : JSON.stringify(error); Logger.error(`Error fetching file ${params.fileKey}:`, message); return { isError: true, - content: [{ type: "text" as const, text: `Error fetching file: ${message}` }], + content: [{ type: "text", text: `Error fetching file: ${message}` }], }; } } @@ -90,8 +120,31 @@ async function getFigmaData( // Export tool configuration export const getFigmaDataTool = { name: "get_figma_data", - description: - "Get comprehensive Figma file data including layout, content, visuals, and component information", + description: `Get comprehensive Figma file data including layout, content, visuals, and component information +${ + needLimitPrompt + ? `## Figma Data Size Guidelines +- **Over ${sizeLimit}KB**: with \`depth: 1\` to get structure only, enter the *Pruning Reading Strategy* +- **Under ${sizeLimit}KB**: Get full data without depth parameter + +## Figma Data Pruning Reading Strategy + +**IMPORTANT: Work incrementally, not comprehensively.** + +### Core Principle +Retrieve and implement ONE node at a time. Don't try to understand the entire design upfront. + +### Pruning Reading Process +1. **Start Small**: Get shallow data (depth: 1) of the main node to see basic information of itself and children nodes +2. **Pick One**: Choose one from the child nodes to implement completely +3. **Get Full Data**: Retrieve complete data for that one node only +4. **Implement**: Implement specifically according to user needs based on the content of the node +5. **Repeat**: Move to the next node only after the current one is done + +### Key Point +**Don't analyze multiple nodes in parallel.** Focus on implementing one complete, working node at a time. This avoids context overload and produces better results.` + : "" +}`, parameters, handler: getFigmaData, } as const; diff --git a/src/mcp/tools/index.ts b/src/mcp/tools/index.ts index a312b24a..3d2c5326 100644 --- a/src/mcp/tools/index.ts +++ b/src/mcp/tools/index.ts @@ -1,4 +1,6 @@ export { getFigmaDataTool } from "./get-figma-data-tool.js"; export { downloadFigmaImagesTool } from "./download-figma-images-tool.js"; +export { getFigmaDataSizeTool } from "./get-figma-data-size-tool.js"; export type { DownloadImagesParams } from "./download-figma-images-tool.js"; export type { GetFigmaDataParams } from "./get-figma-data-tool.js"; +export type { GetFigmaDataSizeParams } from "./get-figma-data-size-tool.js"; diff --git a/src/utils/common.ts b/src/utils/common.ts index cc3bfdb9..b68ddf25 100644 --- a/src/utils/common.ts +++ b/src/utils/common.ts @@ -1,5 +1,7 @@ import fs from "fs"; import path from "path"; +import type { GlobalVars, SimplifiedDesign, SimplifiedNode } from "~/extractors/types.js"; +import type { SimplifiedComponentDefinition, SimplifiedComponentSetDefinition } from "~/transformers/component.js"; export type StyleId = `${string}_${string}` & { __brand: "StyleId" }; diff --git a/src/utils/parse-data-cache.ts b/src/utils/parse-data-cache.ts index 371e1e87..46a1e6e9 100644 --- a/src/utils/parse-data-cache.ts +++ b/src/utils/parse-data-cache.ts @@ -1,8 +1,7 @@ import { LRUCache } from "./lru.js"; import { Logger } from "./logger.js"; -import type { SimplifiedDesign, SimplifiedNode } from "../services/simplify-node-response.js"; import type { GetFileMetaResponse } from "@figma/rest-api-spec"; -import { writeLogs } from "./write-log.js"; +import type { SimplifiedDesign, SimplifiedNode } from "~/extractors/types.js"; interface CacheItem { data: SimplifiedDesign;