import type { FigmaAssetFormat, FigmaRef } from "./types"; /** Typed capability/transport failures per design spec §4.4. */ export type FigmaClientErrorCode = | "NO_TOKEN" | "BAD_TOKEN" | "FORBIDDEN" | "REQUIRES_ENTERPRISE" | "RATE_LIMITED" | "RENDER_FAILED" | "NODE_NOT_FOUND" | "HTTP_ERROR"; export class FigmaClientError extends Error { readonly code: FigmaClientErrorCode; readonly status?: number; constructor(code: FigmaClientErrorCode, message: string, status?: number) { super(message); this.name = "FigmaClientError"; this.code = code; this.status = status; } } /** Injectable fetch so tests never touch the network. */ export type FigmaFetch = ( url: string, init?: { headers?: Record }, ) => Promise; export interface RenderNodeOptions { format: FigmaAssetFormat; scale?: number; } export interface RenderedNode { /** short-lived figma CDN url — freeze it immediately */ url: string; ext: FigmaAssetFormat; } export interface FigmaVariablePayload { name: string; key?: string; resolvedType?: string; valuesByMode?: Record; variableCollectionId?: string; } export interface FigmaVariablesResult { variables: Record; variableCollections: Record; } export interface FigmaStyleMeta { key: string; name: string; style_type: string; node_id?: string; description?: string; } /** Raw figma node document from GET /v1/files/:key/nodes. Field-level shape * is consumed by nodeToHtml; kept loose here on purpose — consumers narrow * children/fills/etc themselves. */ export interface FigmaNodeDocument { id: string; name: string; type: string; [field: string]: unknown; } export interface FigmaFileVersion { version: string; lastModified: string; } export interface FigmaClient { renderNode(ref: FigmaRef, opts: RenderNodeOptions): Promise; imageFills(fileKey: string): Promise>; variables(fileKey: string): Promise; styles(fileKey: string): Promise; nodeTree(ref: FigmaRef): Promise; fileVersion(fileKey: string): Promise; } export interface FigmaClientOptions { token: string; fetch?: FigmaFetch; baseUrl?: string; } function requireNodeId(ref: FigmaRef): string { if (!ref.nodeId) throw new Error(`figma ref ${ref.fileKey} has no nodeId`); return ref.nodeId; } function isRecord(value: unknown): value is Record { return typeof value === "object" && value !== null; } function optionalString(value: unknown): string | undefined { return typeof value === "string" ? value : undefined; } function toVariablePayload(payload: unknown): FigmaVariablePayload | null { if (!isRecord(payload) || typeof payload.name !== "string") return null; return { name: payload.name, key: optionalString(payload.key), resolvedType: optionalString(payload.resolvedType), valuesByMode: isRecord(payload.valuesByMode) ? payload.valuesByMode : undefined, variableCollectionId: optionalString(payload.variableCollectionId), }; } export function createFigmaClient(options: FigmaClientOptions): FigmaClient { const token = options.token.trim(); if (token === "") { throw new FigmaClientError( "NO_TOKEN", [ "FIGMA_TOKEN is missing. One-time setup:", " 1. figma.com/settings → Security → Personal access tokens → Generate new token", " 2. Scopes (read-only is all this integration ever needs — it never writes to figma):", " File content: Read-only · File metadata: Read-only", " Variables: Read-only (optional — brand variables, requires figma Enterprise;", " without it `tokens` falls back to published styles)", ' 3. export FIGMA_TOKEN="figd_…" — add it to your shell profile or the project .env', " so future sessions skip this step", "Then re-run this command.", ].join("\n"), ); } const doFetch: FigmaFetch = options.fetch ?? ((url, init) => fetch(url, init)); const base = options.baseUrl ?? "https://api.figma.com"; async function get(path: string, enterpriseGated = false): Promise { const res = await doFetch(`${base}${path}`, { headers: { "X-Figma-Token": token }, }); if (res.status === 401) throw new FigmaClientError( "BAD_TOKEN", "figma rejected the token (401) — it is expired or revoked. Re-mint at figma.com/settings → Security, then update FIGMA_TOKEN.", 401, ); if (res.status === 403 && enterpriseGated) throw new FigmaClientError( "REQUIRES_ENTERPRISE", "figma variables require an Enterprise plan (403) — fall back to styles", 403, ); if (res.status === 403) throw new FigmaClientError( "FORBIDDEN", "figma denied access (403) — the token is missing a read scope, or your account can't view this file. Check the token has File content: Read-only + File metadata: Read-only (figma.com/settings → Security) and that the file is visible to your account.", 403, ); if (res.status === 429) throw new FigmaClientError( "RATE_LIMITED", "figma rate limit hit (429) — back off and retry", 429, ); if (!res.ok) throw new FigmaClientError( "HTTP_ERROR", `figma request failed: HTTP ${res.status} ${path}`, res.status, ); return res.json(); } return { async renderNode(ref, opts) { const nodeId = requireNodeId(ref); const params = new URLSearchParams({ ids: nodeId, format: opts.format }); if (opts.scale !== undefined) params.set("scale", String(opts.scale)); const body = await get(`/v1/images/${ref.fileKey}?${params}`); const images = isRecord(body) && isRecord(body.images) ? body.images : {}; const url = images[nodeId]; if (typeof url !== "string" || url === "") throw new FigmaClientError( "RENDER_FAILED", `figma could not render node ${nodeId} as ${opts.format}`, ); return { url, ext: opts.format }; }, async imageFills(fileKey) { const body = await get(`/v1/files/${fileKey}/images`); const meta = isRecord(body) && isRecord(body.meta) ? body.meta : {}; const images = isRecord(meta.images) ? meta.images : {}; const out = new Map(); for (const [ref, url] of Object.entries(images)) { if (typeof url === "string") out.set(ref, url); } return out; }, async variables(fileKey) { const body = await get(`/v1/files/${fileKey}/variables/local`, true); const meta = isRecord(body) && isRecord(body.meta) ? body.meta : {}; const variables = isRecord(meta.variables) ? meta.variables : {}; const collections = isRecord(meta.variableCollections) ? meta.variableCollections : {}; const typed: Record = {}; for (const [id, payload] of Object.entries(variables)) { const v = toVariablePayload(payload); if (v) typed[id] = v; } return { variables: typed, variableCollections: collections }; }, async styles(fileKey) { const body = await get(`/v1/files/${fileKey}/styles`); const meta = isRecord(body) && isRecord(body.meta) ? body.meta : {}; const styles = Array.isArray(meta.styles) ? meta.styles : []; return styles.filter( (s): s is FigmaStyleMeta => isRecord(s) && typeof s.key === "string" && typeof s.name === "string" && typeof s.style_type === "string", ); }, async nodeTree(ref) { const nodeId = requireNodeId(ref); const params = new URLSearchParams({ ids: nodeId, geometry: "paths" }); const body = await get(`/v1/files/${ref.fileKey}/nodes?${params}`); const nodes = isRecord(body) && isRecord(body.nodes) ? body.nodes : {}; const entry = nodes[nodeId]; const doc = isRecord(entry) ? entry.document : undefined; if ( !isRecord(doc) || typeof doc.id !== "string" || typeof doc.name !== "string" || typeof doc.type !== "string" ) throw new FigmaClientError("NODE_NOT_FOUND", `node ${nodeId} not found in ${ref.fileKey}`); return { ...doc, id: doc.id, name: doc.name, type: doc.type }; }, async fileVersion(fileKey) { const body = await get(`/v1/files/${fileKey}?depth=1`); const version = isRecord(body) && typeof body.version === "string" ? body.version : ""; const lastModified = isRecord(body) && typeof body.lastModified === "string" ? body.lastModified : ""; return { version, lastModified }; }, }; }