Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions src/cli/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down
241 changes: 241 additions & 0 deletions src/core/adapters/component-resolver.test.ts
Original file line number Diff line number Diff line change
@@ -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<AnalysisNode> & { 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);
});
});
90 changes: 90 additions & 0 deletions src/core/adapters/component-resolver.ts
Original file line number Diff line number Diff line change
@@ -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<string> {
const ids = new Set<string>();

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<Record<string, AnalysisNode>> {
const allDefinitions: Record<string, AnalysisNode> = {};
const resolvedIds = new Set<string>();

// 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<string>();
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;
}
Loading
Loading