diff --git a/src/cli/index.ts b/src/cli/index.ts index fc5f8b8b..4c8e8ea6 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -755,6 +755,24 @@ cli const fixtureDir = resolve(options.output ?? `fixtures/${fixtureName}`); mkdirSync(fixtureDir, { recursive: true }); + // 0. Resolve component master node trees + const figmaTokenForComponents = options.token ?? getFigmaToken(); + if (figmaTokenForComponents) { + const { FigmaClient: FC } = await import("../core/adapters/figma-client.js"); + const { resolveComponentDefinitions } = await import("../core/adapters/component-resolver.js"); + const componentClient = new FC({ token: figmaTokenForComponents }); + try { + const definitions = await resolveComponentDefinitions(componentClient, file.fileKey, file.document); + const count = Object.keys(definitions).length; + if (count > 0) { + file.componentDefinitions = definitions; + console.log(`Resolved ${count} component master node tree(s)`); + } + } catch { + console.warn("Warning: failed to resolve component definitions (continuing)"); + } + } + // 1. Save data.json const dataPath = resolve(fixtureDir, "data.json"); await writeFile(dataPath, JSON.stringify(file, null, 2), "utf-8"); diff --git a/src/core/adapters/component-resolver.test.ts b/src/core/adapters/component-resolver.test.ts new file mode 100644 index 00000000..38caff2c --- /dev/null +++ b/src/core/adapters/component-resolver.test.ts @@ -0,0 +1,241 @@ +import type { AnalysisNode } from "../contracts/figma-node.js"; +import type { FigmaClient } from "./figma-client.js"; +import type { GetFileNodesResponse } from "./figma-client.js"; +import { collectComponentIds, resolveComponentDefinitions } from "./component-resolver.js"; + +function makeNode(overrides: Partial & { id: string; name: string; type: AnalysisNode["type"] }): AnalysisNode { + return { visible: true, ...overrides }; +} + +describe("collectComponentIds", () => { + it("gathers unique componentIds from nested INSTANCE nodes", () => { + const tree = makeNode({ + id: "root", + name: "Frame", + type: "FRAME", + children: [ + makeNode({ id: "1", name: "Btn", type: "INSTANCE", componentId: "comp-a" }), + makeNode({ + id: "2", + name: "Group", + type: "GROUP", + children: [ + makeNode({ id: "3", name: "Btn", type: "INSTANCE", componentId: "comp-b" }), + makeNode({ id: "4", name: "Btn", type: "INSTANCE", componentId: "comp-a" }), + ], + }), + ], + }); + + const ids = collectComponentIds(tree); + + expect(ids).toEqual(new Set(["comp-a", "comp-b"])); + }); + + it("ignores non-INSTANCE nodes even if they have componentId", () => { + const tree = makeNode({ + id: "root", + name: "Frame", + type: "FRAME", + componentId: "should-ignore", + children: [ + makeNode({ id: "1", name: "Rect", type: "RECTANGLE" }), + ], + }); + + const ids = collectComponentIds(tree); + + expect(ids.size).toBe(0); + }); + + it("handles nodes with no children", () => { + const tree = makeNode({ + id: "root", + name: "Single", + type: "INSTANCE", + componentId: "comp-x", + }); + + const ids = collectComponentIds(tree); + + expect(ids).toEqual(new Set(["comp-x"])); + }); + + it("handles empty children array", () => { + const tree = makeNode({ + id: "root", + name: "Frame", + type: "FRAME", + children: [], + }); + + const ids = collectComponentIds(tree); + + expect(ids.size).toBe(0); + }); + + it("skips INSTANCE nodes without componentId", () => { + const tree = makeNode({ + id: "root", + name: "Frame", + type: "FRAME", + children: [ + makeNode({ id: "1", name: "Broken", type: "INSTANCE" }), + ], + }); + + const ids = collectComponentIds(tree); + + expect(ids.size).toBe(0); + }); +}); + +describe("resolveComponentDefinitions", () => { + it("resolves component masters in two passes", async () => { + // Document has INSTANCE referencing comp-a + const document = makeNode({ + id: "root", + name: "Frame", + type: "FRAME", + children: [ + makeNode({ id: "1", name: "Btn", type: "INSTANCE", componentId: "comp-a" }), + ], + }); + + // comp-a's master contains another INSTANCE referencing comp-b + const compANode: AnalysisNode = makeNode({ + id: "comp-a", + name: "Button", + type: "COMPONENT", + children: [ + makeNode({ id: "inner", name: "Icon", type: "INSTANCE", componentId: "comp-b" }), + ], + }); + + const compBNode: AnalysisNode = makeNode({ + id: "comp-b", + name: "Icon", + type: "COMPONENT", + }); + + const mockClient = { + getFileNodes: vi.fn().mockImplementation((_fileKey: string, nodeIds: string[]) => { + const nodes: GetFileNodesResponse["nodes"] = {}; + for (const id of nodeIds) { + if (id === "comp-a") { + nodes[id] = { + document: compANode as unknown as import("@figma/rest-api-spec").Node, + components: {}, + styles: {}, + }; + } else if (id === "comp-b") { + nodes[id] = { + document: compBNode as unknown as import("@figma/rest-api-spec").Node, + components: {}, + styles: {}, + }; + } + } + return Promise.resolve({ + name: "Test", + lastModified: "2024-01-01", + version: "1", + nodes, + } satisfies GetFileNodesResponse); + }), + } as unknown as FigmaClient; + + const result = await resolveComponentDefinitions(mockClient, "file-key", document); + + expect(Object.keys(result)).toEqual(expect.arrayContaining(["comp-a", "comp-b"])); + expect(result["comp-a"]?.name).toBe("Button"); + expect(result["comp-b"]?.name).toBe("Icon"); + // Pass 1 fetches comp-a, pass 2 fetches comp-b + expect(mockClient.getFileNodes).toHaveBeenCalledTimes(2); + }); + + it("handles empty document with no instances", async () => { + const document = makeNode({ + id: "root", + name: "Frame", + type: "FRAME", + }); + + const mockClient = { + getFileNodes: vi.fn(), + } as unknown as FigmaClient; + + const result = await resolveComponentDefinitions(mockClient, "file-key", document); + + expect(Object.keys(result)).toHaveLength(0); + expect(mockClient.getFileNodes).not.toHaveBeenCalled(); + }); + + it("skips IDs that fail to fetch", async () => { + const document = makeNode({ + id: "root", + name: "Frame", + type: "FRAME", + children: [ + makeNode({ id: "1", name: "Btn", type: "INSTANCE", componentId: "external-comp" }), + ], + }); + + const mockClient = { + getFileNodes: vi.fn().mockRejectedValue(new Error("Not found")), + } as unknown as FigmaClient; + + const result = await resolveComponentDefinitions(mockClient, "file-key", document); + + expect(Object.keys(result)).toHaveLength(0); + }); + + it("respects maxPasses limit", async () => { + // Deep nesting: doc → comp-a → comp-b → comp-c + // With maxPasses=1, should only resolve comp-a + const document = makeNode({ + id: "root", + name: "Frame", + type: "FRAME", + children: [ + makeNode({ id: "1", name: "Btn", type: "INSTANCE", componentId: "comp-a" }), + ], + }); + + const compANode: AnalysisNode = makeNode({ + id: "comp-a", + name: "A", + type: "COMPONENT", + children: [ + makeNode({ id: "inner-a", name: "Nested", type: "INSTANCE", componentId: "comp-b" }), + ], + }); + + const mockClient = { + getFileNodes: vi.fn().mockImplementation((_fileKey: string, nodeIds: string[]) => { + const nodes: GetFileNodesResponse["nodes"] = {}; + for (const id of nodeIds) { + if (id === "comp-a") { + nodes[id] = { + document: compANode as unknown as import("@figma/rest-api-spec").Node, + components: {}, + styles: {}, + }; + } + } + return Promise.resolve({ + name: "Test", + lastModified: "2024-01-01", + version: "1", + nodes, + } satisfies GetFileNodesResponse); + }), + } as unknown as FigmaClient; + + const result = await resolveComponentDefinitions(mockClient, "file-key", document, 1); + + // Only comp-a resolved, comp-b not fetched due to maxPasses=1 + expect(Object.keys(result)).toEqual(["comp-a"]); + expect(mockClient.getFileNodes).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/core/adapters/component-resolver.ts b/src/core/adapters/component-resolver.ts new file mode 100644 index 00000000..4d97aedb --- /dev/null +++ b/src/core/adapters/component-resolver.ts @@ -0,0 +1,90 @@ +import type { FigmaClient } from "./figma-client.js"; +import type { AnalysisNode } from "../contracts/figma-node.js"; +import { transformComponentMasterNodes } from "./figma-transformer.js"; + +const BATCH_SIZE = 50; + +/** + * Recursively collect all unique componentId values from INSTANCE nodes. + */ +export function collectComponentIds(node: AnalysisNode): Set { + const ids = new Set(); + + function walk(n: AnalysisNode): void { + if (n.type === "INSTANCE" && n.componentId) { + ids.add(n.componentId); + } + if (n.children) { + for (const child of n.children) { + walk(child); + } + } + } + + walk(node); + return ids; +} + +/** + * Resolve component master node trees via multi-pass fetching. + * + * Pass 1: collect component IDs from the document tree, fetch their masters. + * Pass 2+: collect component IDs from fetched masters that were not in previous passes. + * Repeats up to maxPasses (default 2). + * + * Batches API calls at BATCH_SIZE IDs per request. + * Skips IDs that return null (e.g. external library components). + */ +export async function resolveComponentDefinitions( + client: FigmaClient, + fileKey: string, + document: AnalysisNode, + maxPasses = 2 +): Promise> { + const allDefinitions: Record = {}; + const resolvedIds = new Set(); + + // Pass 1: collect from the original document + let pendingIds = collectComponentIds(document); + + for (let pass = 0; pass < maxPasses; pass++) { + // Filter out already-resolved IDs + const idsToFetch = [...pendingIds].filter((id) => !resolvedIds.has(id)); + if (idsToFetch.length === 0) break; + + // Fetch in batches + for (let i = 0; i < idsToFetch.length; i += BATCH_SIZE) { + const batch = idsToFetch.slice(i, i + BATCH_SIZE); + try { + const response = await client.getFileNodes(fileKey, batch); + const transformed = transformComponentMasterNodes(response, batch); + for (const [id, node] of Object.entries(transformed)) { + allDefinitions[id] = node; + } + } catch (err) { + // Skip failed batches (e.g. external library components) + console.debug(`[component-resolver] batch fetch failed (${batch.length} ids):`, err); + } + } + + // Mark all attempted IDs as resolved (even if they failed/returned null) + for (const id of idsToFetch) { + resolvedIds.add(id); + } + + // Collect new IDs only from masters fetched in this pass (not all accumulated) + pendingIds = new Set(); + for (const id of idsToFetch) { + const node = allDefinitions[id]; + if (node) { + for (const nestedId of collectComponentIds(node)) { + if (!resolvedIds.has(nestedId)) { + pendingIds.add(nestedId); + } + } + } + } + } + + return allDefinitions; +} diff --git a/src/core/adapters/figma-file-loader.ts b/src/core/adapters/figma-file-loader.ts index 7193d0d7..d5fe30d4 100644 --- a/src/core/adapters/figma-file-loader.ts +++ b/src/core/adapters/figma-file-loader.ts @@ -1,7 +1,8 @@ import { readFile } from "node:fs/promises"; import { basename, dirname } from "node:path"; import type { GetFileResponse } from "@figma/rest-api-spec"; -import type { AnalysisFile } from "../contracts/figma-node.js"; +import type { AnalysisFile, AnalysisNode } from "../contracts/figma-node.js"; +import { AnalysisNodeSchema } from "../contracts/figma-node.js"; import { transformFigmaResponse } from "./figma-transformer.js"; /** @@ -26,11 +27,31 @@ export async function loadFigmaFileFromJson( filePath: string ): Promise { const content = await readFile(filePath, "utf-8"); - const data = JSON.parse(content) as GetFileResponse; + const data = JSON.parse(content) as GetFileResponse & { + componentDefinitions?: Record; + }; const fileKey = extractFileKey(filePath); - return transformFigmaResponse(fileKey, data); + const file = transformFigmaResponse(fileKey, data); + + // Preserve componentDefinitions from previously-saved fixtures + if (data.componentDefinitions) { + const parsed: Record = {}; + for (const [id, raw] of Object.entries(data.componentDefinitions)) { + const result = AnalysisNodeSchema.safeParse(raw); + if (result.success) { + parsed[id] = result.data; + } else { + console.debug(`[figma-file-loader] componentDefinitions[${id}] failed validation:`, result.error.issues); + } + } + if (Object.keys(parsed).length > 0) { + file.componentDefinitions = parsed; + } + } + + return file; } /** diff --git a/src/core/adapters/figma-transformer.ts b/src/core/adapters/figma-transformer.ts index cb353d02..52b724e3 100644 --- a/src/core/adapters/figma-transformer.ts +++ b/src/core/adapters/figma-transformer.ts @@ -155,6 +155,24 @@ export function transformFileNodesResponse( }; } +/** + * Transform component master nodes from a /v1/files/{key}/nodes response. + * Each requested node ID is transformed into an AnalysisNode if present. + */ +export function transformComponentMasterNodes( + response: GetFileNodesResponse, + requestedIds: string[] +): Record { + const result: Record = {}; + for (const id of requestedIds) { + const entry = response.nodes[id]; + if (entry?.document) { + result[id] = transformNode(entry.document); + } + } + return result; +} + function transformComponents( components: GetFileResponse["components"] ): AnalysisFile["components"] { diff --git a/src/core/adapters/index.ts b/src/core/adapters/index.ts index 14d92ca8..2d5e2aa0 100644 --- a/src/core/adapters/index.ts +++ b/src/core/adapters/index.ts @@ -5,3 +5,4 @@ export * from "./figma-client.js"; export * from "./figma-transformer.js"; export * from "./figma-file-loader.js"; export * from "./figma-mcp-adapter.js"; +export * from "./component-resolver.js"; diff --git a/src/core/contracts/figma-node.ts b/src/core/contracts/figma-node.ts index f2f0c17c..37823e38 100644 --- a/src/core/contracts/figma-node.ts +++ b/src/core/contracts/figma-node.ts @@ -138,6 +138,7 @@ export const AnalysisFileSchema = z.object({ description: z.string(), }) ), + componentDefinitions: z.record(z.string(), AnalysisNodeSchema).optional(), styles: z.record( z.string(), z.object({ diff --git a/src/core/engine/loader.ts b/src/core/engine/loader.ts index e5568b3f..4ee4eccc 100644 --- a/src/core/engine/loader.ts +++ b/src/core/engine/loader.ts @@ -1,6 +1,7 @@ import { existsSync, statSync } from "node:fs"; import { resolve, join } from "node:path"; import { FigmaClient } from "../adapters/figma-client.js"; +import { resolveComponentDefinitions } from "../adapters/component-resolver.js"; import { loadFigmaFileFromJson } from "../adapters/figma-file-loader.js"; import { transformFigmaResponse, transformFileNodesResponse } from "../adapters/figma-transformer.js"; import { parseFigmaUrl } from "../adapters/figma-url-parser.js"; @@ -12,10 +13,12 @@ export interface LoadResult { nodeId?: string | undefined; } +/** Check if input string is a Figma URL. */ export function isFigmaUrl(input: string): boolean { return input.includes("figma.com/"); } +/** Check if input string is a JSON file path. */ export function isJsonFile(input: string): boolean { return input.endsWith(".json"); } @@ -43,6 +46,10 @@ export function resolveFixturePath(input: string): string { return resolve(join(input, "data.json")); } +/** + * Load a Figma file from a URL, JSON file, or fixture directory. + * Resolves component master node trees for accurate analysis. + */ export async function loadFile( input: string, token?: string, @@ -88,15 +95,25 @@ async function loadFromApi( if (nodeId) { // Fetch only the target node subtree — faster, less rate limit impact const response = await client.getFileNodes(fileKey, [nodeId.replace(/-/g, ":")]); - return { - file: transformFileNodesResponse(fileKey, response), - nodeId, - }; + const file = transformFileNodesResponse(fileKey, response); + + // Resolve component master node trees for accurate analysis + const componentDefs = await resolveComponentDefinitions(client, fileKey, file.document); + if (Object.keys(componentDefs).length > 0) { + file.componentDefinitions = componentDefs; + } + + return { file, nodeId }; } const response = await client.getFile(fileKey); - return { - file: transformFigmaResponse(fileKey, response), - nodeId, - }; + const file = transformFigmaResponse(fileKey, response); + + // Full file fetch may still miss component masters from external libraries + const componentDefs = await resolveComponentDefinitions(client, fileKey, file.document); + if (Object.keys(componentDefs).length > 0) { + file.componentDefinitions = componentDefs; + } + + return { file, nodeId }; }