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/config.ts b/src/config.ts index 3e0c030c..9539f933 100644 --- a/src/config.ts +++ b/src/config.ts @@ -87,6 +87,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/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/services/figma.ts b/src/services/figma.ts index ec5b49ea..30f9b403 100644 --- a/src/services/figma.ts +++ b/src/services/figma.ts @@ -3,8 +3,8 @@ import type { GetFileResponse, GetFileNodesResponse, GetImageFillsResponse, + GetFileMetaResponse, } from "@figma/rest-api-spec"; -import { downloadFigmaImage } from "~/utils/common.js"; import { downloadAndProcessImage, type ImageProcessingResult } from "~/utils/image-processing.js"; import { Logger, writeLogs } from "~/utils/logger.js"; import { fetchWithRetry } from "~/utils/fetch-with-retry.js"; @@ -13,6 +13,7 @@ export type FigmaAuthOptions = { figmaApiKey: string; figmaOAuthToken: string; useOAuth: boolean; + useCache: boolean; }; type SvgOptions = { @@ -26,11 +27,13 @@ export class FigmaService { private readonly oauthToken: string; private readonly useOAuth: boolean; private readonly baseUrl = "https://api.figma.com/v1"; + 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; + this.useCache = useCache; } private getAuthHeaders(): Record { @@ -255,6 +258,16 @@ export class FigmaService { return results.flat(); } + /** + * Get file meta data + */ + async getFileMeta(fileKey: string): Promise { + const endpoint = `/files/${fileKey}/meta`; + const response = await this.request(endpoint); + return response; + } + + /** * Get raw Figma API response for a file (for use with flexible extractors) */ 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/tests/integration.test.ts b/src/tests/integration.test.ts index 2e0ad10d..915b99bd 100644 --- a/src/tests/integration.test.ts +++ b/src/tests/integration.test.ts @@ -5,9 +5,14 @@ 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/src/transformers/effects.ts b/src/transformers/effects.ts index b901634a..2410a04e 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 "~/transformers/style.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/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 diff --git a/src/utils/common.ts b/src/utils/common.ts index 74fed8e5..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" }; @@ -214,3 +216,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; +} 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..46a1e6e9 --- /dev/null +++ b/src/utils/parse-data-cache.ts @@ -0,0 +1,416 @@ +import { LRUCache } from "./lru.js"; +import { Logger } from "./logger.js"; +import type { GetFileMetaResponse } from "@figma/rest-api-spec"; +import type { SimplifiedDesign, SimplifiedNode } from "~/extractors/types.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); + } +} 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/**/*"] +}