pi-mono-figma 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,335 @@
1
+ import { mkdir, mkdtemp, writeFile } from "node:fs/promises";
2
+ import { tmpdir } from "node:os";
3
+ import { isAbsolute, join, resolve } from "node:path";
4
+ import { readAuthToken } from "pi-common/auth";
5
+ import { createHttpClient, type HttpClient } from "pi-common/http-client";
6
+ import { createRateLimiter, type RateLimiter } from "pi-common/rate-limiter";
7
+ import { figmaCache } from "./figma-cache.js";
8
+ import {
9
+ explainNode,
10
+ extractVisibleText,
11
+ getImplementationContext,
12
+ summarizeNode,
13
+ type FigmaImplementationContext,
14
+ type FigmaNodeSummary,
15
+ type FigmaRenderedAsset,
16
+ type FigmaSummarizerOptions,
17
+ type FigmaTextExtractionResult,
18
+ } from "./figma-summarizer.js";
19
+
20
+ export interface FigmaClientOptions {
21
+ baseUrl?: string;
22
+ timeoutMs?: number;
23
+ }
24
+
25
+ export interface RenderNodesOptions {
26
+ format?: "png" | "jpg" | "svg" | "pdf";
27
+ scale?: number;
28
+ outputDir?: string;
29
+ download?: boolean;
30
+ cwd: string;
31
+ }
32
+
33
+ export interface FigmaGetNodesOptions {
34
+ depth?: number;
35
+ }
36
+
37
+ export interface ParsedFigmaUrl {
38
+ fileKey: string;
39
+ nodeId?: string;
40
+ }
41
+
42
+ export interface RenderNodesResult {
43
+ images: Record<string, string | null>;
44
+ savedFiles: Array<{ nodeId: string; path: string }>;
45
+ }
46
+
47
+ export class FigmaClient {
48
+ private readonly http: HttpClient;
49
+ private readonly limiter: RateLimiter;
50
+
51
+ constructor(options: FigmaClientOptions = {}) {
52
+ this.http = createHttpClient({
53
+ baseUrl: options.baseUrl ?? "https://api.figma.com",
54
+ timeoutMs: options.timeoutMs ?? 30_000,
55
+ service: "Figma",
56
+ headers: async () => ({ "X-Figma-Token": await readFigmaToken() }),
57
+ });
58
+ this.limiter = createRateLimiter({ minIntervalMs: 1_000 });
59
+ }
60
+
61
+ getFile(fileKey: string, depth?: number): Promise<unknown> {
62
+ const query = depth ? `?depth=${depth}` : "";
63
+ return this.cached(`file:${fileKey}:${depth ?? "all"}`, () => this.get(`/v1/files/${fileKey}${query}`));
64
+ }
65
+
66
+ getNodes(fileKey: string, nodeIds: readonly string[], options: FigmaGetNodesOptions = {}): Promise<unknown> {
67
+ const ids = normalizeNodeIds(nodeIds).join(",");
68
+ const depth = options.depth ? clampInteger(options.depth, 1, 4) : undefined;
69
+ const depthQuery = depth ? `&depth=${depth}` : "";
70
+ return this.cached(`nodes:${fileKey}:${ids}:${depth ?? "all"}`, () => this.get(`/v1/files/${fileKey}/nodes?ids=${encodeURIComponent(ids)}${depthQuery}`));
71
+ }
72
+
73
+ getStyles(fileKey: string): Promise<unknown> {
74
+ return this.cached(`styles:${fileKey}`, () => this.get(`/v1/files/${fileKey}/styles`));
75
+ }
76
+
77
+ getComponents(fileKey: string): Promise<unknown> {
78
+ return this.cached(`components:${fileKey}`, () => this.get(`/v1/files/${fileKey}/components`));
79
+ }
80
+
81
+ getComponentSets(fileKey: string): Promise<unknown> {
82
+ return this.cached(`componentSets:${fileKey}`, () => this.get(`/v1/files/${fileKey}/component_sets`));
83
+ }
84
+
85
+ getVariables(fileKey: string): Promise<unknown> {
86
+ return this.cached(`variables:${fileKey}`, () => this.get(`/v1/files/${fileKey}/variables/local`));
87
+ }
88
+
89
+ async searchComponents(fileKey: string, query: string): Promise<unknown> {
90
+ const response = await this.getComponents(fileKey);
91
+ const components = getNestedArray(response, ["meta", "components"]);
92
+ const needle = query.toLowerCase();
93
+ return components.filter((component) => {
94
+ const record = component as Record<string, unknown>;
95
+ return String(record.name ?? "").toLowerCase().includes(needle) || String(record.description ?? "").toLowerCase().includes(needle);
96
+ });
97
+ }
98
+
99
+ async getDesignContext(fileKey: string, nodeId?: string): Promise<unknown> {
100
+ if (nodeId) return this.getTargetDesignContext(fileKey, nodeId);
101
+
102
+ const file = await this.getFile(fileKey, 2);
103
+ const fileRecord = asRecord(file);
104
+ return {
105
+ file: {
106
+ name: fileRecord.name,
107
+ lastModified: fileRecord.lastModified,
108
+ version: fileRecord.version,
109
+ },
110
+ document: {
111
+ name: asRecord(fileRecord.document).name,
112
+ children: collectTopLevelStructure(fileRecord.document),
113
+ },
114
+ metadata: {
115
+ truncated: true,
116
+ note: "Only canvases and top-level frames are returned by default. Pass nodeId and use processed node tools for details.",
117
+ nextSteps: ["Call figma_get_node_summary or figma_explain_node for a specific node.", "Use figma_get_file only for raw debugging."],
118
+ },
119
+ };
120
+ }
121
+
122
+ async getNodeSummary(fileKey: string, nodeId: string, options: FigmaSummarizerOptions = {}): Promise<FigmaNodeSummary> {
123
+ return summarizeNode(await this.getSingleNodeDocument(fileKey, nodeId, options.depth ?? 2), options);
124
+ }
125
+
126
+ async extractText(fileKey: string, nodeId: string, options: FigmaSummarizerOptions = {}): Promise<FigmaTextExtractionResult> {
127
+ return extractVisibleText(await this.getSingleNodeDocument(fileKey, nodeId, options.depth ?? 2), options);
128
+ }
129
+
130
+ async explainNode(fileKey: string, nodeId: string, options: FigmaSummarizerOptions & { assets?: FigmaRenderedAsset[] } = {}): Promise<string> {
131
+ return explainNode(await this.getSingleNodeDocument(fileKey, nodeId, options.depth ?? 2), options);
132
+ }
133
+
134
+ async getImplementationContext(fileKey: string, nodeId: string, options: FigmaSummarizerOptions & { assets?: FigmaRenderedAsset[] } = {}): Promise<FigmaImplementationContext> {
135
+ return getImplementationContext(await this.getSingleNodeDocument(fileKey, nodeId, options.depth ?? 2), options);
136
+ }
137
+
138
+ async getNodeMetadata(fileKey: string, nodeIds: readonly string[]): Promise<unknown> {
139
+ const response = await this.getNodes(fileKey, nodeIds, { depth: 2 });
140
+ const nodes = asRecord(response).nodes;
141
+ return Object.entries(asRecord(nodes)).map(([id, value]) => {
142
+ const document = asRecord(asRecord(value).document);
143
+ return {
144
+ id,
145
+ name: document.name,
146
+ type: document.type,
147
+ boundingBox: document.absoluteBoundingBox,
148
+ constraints: document.constraints,
149
+ layout: {
150
+ layoutMode: document.layoutMode,
151
+ itemSpacing: document.itemSpacing,
152
+ paddingLeft: document.paddingLeft,
153
+ paddingRight: document.paddingRight,
154
+ paddingTop: document.paddingTop,
155
+ paddingBottom: document.paddingBottom,
156
+ primaryAxisAlignItems: document.primaryAxisAlignItems,
157
+ counterAxisAlignItems: document.counterAxisAlignItems,
158
+ },
159
+ cornerRadius: document.cornerRadius,
160
+ opacity: document.opacity,
161
+ effects: document.effects ?? [],
162
+ fills: document.fills ?? [],
163
+ strokes: document.strokes ?? [],
164
+ strokeWeight: document.strokeWeight,
165
+ children: getNestedArray(document, ["children"]).map((child) => {
166
+ const childRecord = asRecord(child);
167
+ return {
168
+ id: childRecord.id,
169
+ name: childRecord.name,
170
+ type: childRecord.type,
171
+ boundingBox: childRecord.absoluteBoundingBox,
172
+ visible: childRecord.visible ?? true,
173
+ };
174
+ }),
175
+ };
176
+ });
177
+ }
178
+
179
+ async renderNodes(fileKey: string, nodeIds: readonly string[], options: RenderNodesOptions): Promise<RenderNodesResult> {
180
+ const ids = normalizeNodeIds(nodeIds).join(",");
181
+ const format = options.format ?? "png";
182
+ const scale = options.scale ?? 2;
183
+ const response = await this.get<{ images?: Record<string, string | null>; err?: string }>(
184
+ `/v1/images/${fileKey}?ids=${encodeURIComponent(ids)}&format=${format}&scale=${scale}`,
185
+ );
186
+ if (response.err) throw new Error(response.err);
187
+
188
+ const images = response.images ?? {};
189
+ const savedFiles: Array<{ nodeId: string; path: string }> = [];
190
+ if (options.download ?? true) {
191
+ const outputDir = await resolveOutputDir(options.cwd, options.outputDir);
192
+ await mkdir(outputDir, { recursive: true });
193
+ for (const [nodeId, url] of Object.entries(images)) {
194
+ if (!url) continue;
195
+ const extension = format === "jpg" ? "jpg" : format;
196
+ const safeNodeId = nodeId.replace(/[^a-z0-9_-]/gi, "_");
197
+ const outputPath = resolve(outputDir, `${fileKey}_${safeNodeId}.${extension}`);
198
+ const bytes = await this.http.download(url);
199
+ await writeFile(outputPath, Buffer.from(bytes));
200
+ savedFiles.push({ nodeId, path: outputPath });
201
+ }
202
+ }
203
+
204
+ return { images, savedFiles };
205
+ }
206
+
207
+ private get<T = unknown>(path: string): Promise<T> {
208
+ return this.limiter.schedule(() => this.http.get<T>(path));
209
+ }
210
+
211
+ private async getTargetDesignContext(fileKey: string, nodeId: string): Promise<unknown> {
212
+ const [file, targetSummary] = await Promise.all([this.getFile(fileKey, 2), this.getNodeSummary(fileKey, nodeId, { depth: 2 })]);
213
+ const fileRecord = asRecord(file);
214
+ const normalizedNodeId = normalizeNodeId(nodeId);
215
+ const shallowStructure = collectTopLevelStructure(fileRecord.document);
216
+ const targetLocation = findShallowLocation(fileRecord.document, normalizedNodeId);
217
+
218
+ return {
219
+ file: {
220
+ name: fileRecord.name,
221
+ lastModified: fileRecord.lastModified,
222
+ version: fileRecord.version,
223
+ },
224
+ targetNode: targetSummary,
225
+ location: targetLocation ?? {
226
+ targetNodeId: normalizedNodeId,
227
+ note: "Target node is not present in the shallow file tree, so ancestors/siblings are unavailable without raw debugging.",
228
+ },
229
+ document: {
230
+ name: asRecord(fileRecord.document).name,
231
+ children: shallowStructure,
232
+ },
233
+ metadata: {
234
+ truncated: targetSummary.metadata?.truncated ?? true,
235
+ note: "Design context is compact: target summary plus shallow file structure only.",
236
+ nextSteps: targetSummary.metadata?.nextSteps?.length
237
+ ? targetSummary.metadata.nextSteps
238
+ : ["Call figma_explain_node for a human-readable explanation.", "Call figma_get_implementation_context for coding details."],
239
+ },
240
+ };
241
+ }
242
+
243
+ private async getSingleNodeDocument(fileKey: string, nodeId: string, depth: number): Promise<unknown> {
244
+ const normalizedNodeId = normalizeNodeId(nodeId);
245
+ const response = await this.getNodes(fileKey, [normalizedNodeId], { depth });
246
+ const document = asRecord(asRecord(asRecord(response).nodes)[normalizedNodeId]).document;
247
+ if (!document) throw new Error(`Figma node ${normalizedNodeId} was not found in file ${fileKey}.`);
248
+ return document;
249
+ }
250
+
251
+ private cached<T>(key: string, load: () => Promise<T>): Promise<T> {
252
+ return figmaCache.getOrSet(key, load) as Promise<T>;
253
+ }
254
+ }
255
+
256
+ export function readFigmaToken(): Promise<string> {
257
+ return readAuthToken({ envName: "FIGMA_TOKEN", authPath: ["figma", "token"] });
258
+ }
259
+
260
+ export function parseFigmaUrl(url: string): ParsedFigmaUrl {
261
+ const parsed = new URL(url);
262
+ const parts = parsed.pathname.split("/").filter(Boolean);
263
+ const fileKey = parts[1];
264
+ if (!fileKey || !["design", "file", "proto"].includes(parts[0] ?? "")) {
265
+ throw new Error("Expected a Figma URL like https://www.figma.com/design/<fileKey>/...");
266
+ }
267
+ const nodeId = parsed.searchParams.get("node-id") ?? undefined;
268
+ return { fileKey, nodeId: nodeId ? normalizeNodeId(nodeId) : undefined };
269
+ }
270
+
271
+ export function normalizeNodeId(nodeId: string): string {
272
+ return nodeId.replace(/-/g, ":");
273
+ }
274
+
275
+ export function normalizeNodeIds(nodeIds: readonly string[]): string[] {
276
+ return nodeIds.map(normalizeNodeId);
277
+ }
278
+
279
+ async function resolveOutputDir(cwd: string, outputDir?: string): Promise<string> {
280
+ if (!outputDir) return mkdtemp(join(tmpdir(), "pi-figma-assets-"));
281
+ return isAbsolute(outputDir) ? outputDir : resolve(cwd, outputDir);
282
+ }
283
+
284
+ function asRecord(value: unknown): Record<string, unknown> {
285
+ return value && typeof value === "object" ? (value as Record<string, unknown>) : {};
286
+ }
287
+
288
+ function getChildren(value: unknown): unknown[] {
289
+ const children = asRecord(value).children;
290
+ return Array.isArray(children) ? children : [];
291
+ }
292
+
293
+ function getNestedArray(value: unknown, path: readonly string[]): unknown[] {
294
+ let current = value;
295
+ for (const segment of path) current = asRecord(current)[segment];
296
+ return Array.isArray(current) ? current : [];
297
+ }
298
+
299
+ function collectTopLevelStructure(value: unknown): Array<{ id: unknown; name: unknown; type: unknown; children?: Array<{ id: unknown; name: unknown; type: unknown }> }> {
300
+ const document = asRecord(value);
301
+ return getChildren(document).map((page) => {
302
+ const pageRecord = asRecord(page);
303
+ return {
304
+ id: pageRecord.id,
305
+ name: pageRecord.name,
306
+ type: pageRecord.type,
307
+ children: getChildren(pageRecord).slice(0, 100).map((child) => {
308
+ const childRecord = asRecord(child);
309
+ return { id: childRecord.id, name: childRecord.name, type: childRecord.type };
310
+ }),
311
+ };
312
+ });
313
+ }
314
+
315
+ function findShallowLocation(value: unknown, nodeId: string): unknown {
316
+ for (const page of getChildren(value)) {
317
+ const pageRecord = asRecord(page);
318
+ const pageChildren = getChildren(pageRecord);
319
+ for (const child of pageChildren) {
320
+ const childRecord = asRecord(child);
321
+ if (childRecord.id === nodeId) {
322
+ return {
323
+ page: { id: pageRecord.id, name: pageRecord.name, type: pageRecord.type },
324
+ ancestors: [{ id: pageRecord.id, name: pageRecord.name, type: pageRecord.type }],
325
+ siblingNames: pageChildren.filter((sibling) => asRecord(sibling).id !== nodeId).slice(0, 100).map((sibling) => asRecord(sibling).name),
326
+ };
327
+ }
328
+ }
329
+ }
330
+ return null;
331
+ }
332
+
333
+ function clampInteger(value: number, min: number, max: number): number {
334
+ return Math.max(min, Math.min(max, Math.trunc(value)));
335
+ }
@@ -0,0 +1,84 @@
1
+ import { Type } from "@sinclair/typebox";
2
+
3
+ export const MaxResponseCharsSchema = Type.Optional(
4
+ Type.Number({ description: "Maximum characters returned to the model before truncation", minimum: 1 }),
5
+ );
6
+
7
+ export const FileKeySchema = Type.String({ description: "Figma file key from a Figma URL" });
8
+ export const NodeIdSchema = Type.String({ description: "Figma node ID, either 1:2 API format or 1-2 URL format" });
9
+ export const NodeIdsSchema = Type.Array(NodeIdSchema, {
10
+ description: "One or more Figma node IDs. Batch related nodes in one call.",
11
+ minItems: 1,
12
+ });
13
+
14
+ export const FigmaGetFileParams = Type.Object({
15
+ fileKey: FileKeySchema,
16
+ depth: Type.Optional(Type.Number({ description: "Optional Figma file depth query parameter", minimum: 1 })),
17
+ maxResponseChars: MaxResponseCharsSchema,
18
+ });
19
+
20
+ export const FigmaGetDesignContextParams = Type.Object({
21
+ fileKey: FileKeySchema,
22
+ nodeId: Type.Optional(NodeIdSchema),
23
+ maxResponseChars: MaxResponseCharsSchema,
24
+ });
25
+
26
+ export const FigmaGetNodesParams = Type.Object({
27
+ fileKey: FileKeySchema,
28
+ nodeIds: NodeIdsSchema,
29
+ maxResponseChars: MaxResponseCharsSchema,
30
+ });
31
+
32
+ const FigmaNodeProcessingOptions = {
33
+ depth: Type.Optional(Type.Number({ description: "How many levels of node hierarchy to include. Defaults to 2 and is capped at 4.", minimum: 1, maximum: 4 })),
34
+ includeHidden: Type.Optional(Type.Boolean({ description: "Include nodes where visible=false. Defaults to false." })),
35
+ includeVectors: Type.Optional(Type.Boolean({ description: "Include vector/icon internals. Defaults to false." })),
36
+ includeComponentInternals: Type.Optional(Type.Boolean({ description: "Expand component instance internals. Defaults to false." })),
37
+ };
38
+
39
+ const FigmaOptionalRenderOptions = {
40
+ renderImage: Type.Optional(Type.Boolean({ description: "Render the node and include image URL/local path in the response. Defaults to false." })),
41
+ outputDir: Type.Optional(Type.String({ description: "Optional directory for downloaded rendered image files, relative to cwd unless absolute." })),
42
+ format: Type.Optional(Type.Unsafe<"png" | "jpg" | "svg" | "pdf">({ type: "string", enum: ["png", "jpg", "svg", "pdf"] })),
43
+ scale: Type.Optional(Type.Number({ description: "Render scale for bitmap formats", minimum: 0.01, maximum: 4 })),
44
+ };
45
+
46
+ export const FigmaProcessedNodeParams = Type.Object({
47
+ fileKey: FileKeySchema,
48
+ nodeId: NodeIdSchema,
49
+ ...FigmaNodeProcessingOptions,
50
+ maxResponseChars: MaxResponseCharsSchema,
51
+ });
52
+
53
+ export const FigmaProcessedNodeWithRenderParams = Type.Object({
54
+ fileKey: FileKeySchema,
55
+ nodeId: NodeIdSchema,
56
+ ...FigmaNodeProcessingOptions,
57
+ ...FigmaOptionalRenderOptions,
58
+ maxResponseChars: MaxResponseCharsSchema,
59
+ });
60
+
61
+ export const FigmaSingleFileParams = Type.Object({
62
+ fileKey: FileKeySchema,
63
+ maxResponseChars: MaxResponseCharsSchema,
64
+ });
65
+
66
+ export const FigmaSearchComponentsParams = Type.Object({
67
+ fileKey: FileKeySchema,
68
+ query: Type.String({ description: "Case-insensitive component name/description search term" }),
69
+ maxResponseChars: MaxResponseCharsSchema,
70
+ });
71
+
72
+ export const FigmaRenderNodesParams = Type.Object({
73
+ fileKey: FileKeySchema,
74
+ nodeIds: NodeIdsSchema,
75
+ outputDir: Type.Optional(Type.String({ description: "Optional directory for downloaded image files, relative to cwd unless absolute. If omitted, a temp directory is created." })),
76
+ format: Type.Optional(Type.Unsafe<"png" | "jpg" | "svg" | "pdf">({ type: "string", enum: ["png", "jpg", "svg", "pdf"] })),
77
+ scale: Type.Optional(Type.Number({ description: "Render scale for bitmap formats", minimum: 0.01, maximum: 4 })),
78
+ download: Type.Optional(Type.Boolean({ description: "Download rendered assets locally. Defaults to true." })),
79
+ maxResponseChars: MaxResponseCharsSchema,
80
+ });
81
+
82
+ export const FigmaParseUrlParams = Type.Object({
83
+ url: Type.String({ description: "Figma design/file URL" }),
84
+ });