From 0e0b131de433ae76792f079a8f84d1dcd7bc31fb Mon Sep 17 00:00:00 2001 From: Vance Ingalls Date: Thu, 2 Jul 2026 02:24:18 -0700 Subject: [PATCH] feat(core,cli): figma REST client, asset import command, binding index (M0+M1) M0: renderNode/imageFills/variables/styles/nodeTree/fileVersion over api.figma.com with injectable fetch and typed capability errors (NO_TOKEN/BAD_TOKEN/REQUIRES_ENTERPRISE/RATE_LIMITED/RENDER_FAILED/ NODE_NOT_FOUND/HTTP_ERROR) per design spec 4.4. M1: svg sanitizer (scripts/foreignObject/handlers/external hrefs) + hyperframes figma asset: render -> sanitize -> freeze under .media/ -> manifest provenance -> snippet. Idempotent on fileKey:nodeId:format:scale:version; re-imports when the version moves. Plus the 7.1 binding index store (.media/figma-bindings.jsonl): exact-ID lookup incl. alias chains, per-project library-file answers, shared jsonl reader with the asset manifest. Co-Authored-By: Claude Fable 5 --- packages/cli/src/cli.ts | 1 + packages/cli/src/commands/figma.ts | 47 ++++ packages/cli/src/commands/figma/asset.test.ts | 104 ++++++++ packages/cli/src/commands/figma/asset.ts | 146 +++++++++++ packages/core/src/figma/bindings.test.ts | 82 ++++++ packages/core/src/figma/bindings.ts | 124 ++++++++++ packages/core/src/figma/client.test.ts | 185 ++++++++++++++ packages/core/src/figma/client.ts | 233 ++++++++++++++++++ packages/core/src/figma/index.ts | 15 ++ packages/core/src/figma/jsonl.ts | 19 ++ packages/core/src/figma/manifest.ts | 16 +- packages/core/src/figma/sanitizeSvg.test.ts | 98 ++++++++ packages/core/src/figma/sanitizeSvg.ts | 58 +++++ 13 files changed, 1114 insertions(+), 14 deletions(-) create mode 100644 packages/cli/src/commands/figma.ts create mode 100644 packages/cli/src/commands/figma/asset.test.ts create mode 100644 packages/cli/src/commands/figma/asset.ts create mode 100644 packages/core/src/figma/bindings.test.ts create mode 100644 packages/core/src/figma/bindings.ts create mode 100644 packages/core/src/figma/client.test.ts create mode 100644 packages/core/src/figma/client.ts create mode 100644 packages/core/src/figma/jsonl.ts create mode 100644 packages/core/src/figma/sanitizeSvg.test.ts create mode 100644 packages/core/src/figma/sanitizeSvg.ts diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts index 20ec2a427d..2c2726e79a 100644 --- a/packages/cli/src/cli.ts +++ b/packages/cli/src/cli.ts @@ -144,6 +144,7 @@ const commandLoaders = { cloudrun: () => import("./commands/cloudrun.js").then((m) => m.default), cloud: () => import("./commands/cloud.js").then((m) => m.default), auth: () => import("./commands/auth.js").then((m) => m.default), + figma: () => import("./commands/figma.js").then((m) => m.default), }; // Wrap each command's run() so a thrown failure reports its reason to telemetry diff --git a/packages/cli/src/commands/figma.ts b/packages/cli/src/commands/figma.ts new file mode 100644 index 0000000000..7152c2d8a1 --- /dev/null +++ b/packages/cli/src/commands/figma.ts @@ -0,0 +1,47 @@ +/** + * `hyperframes figma` — import figma assets, tokens, and components over + * the REST API (design spec: phases 1–3 run on REST; motion/shaders are + * MCP-only and live in the /figma skill, not this command). + * + * Requires FIGMA_TOKEN (personal access token from figma.com/settings). + */ + +import { defineCommand } from "citty"; +import type { Example } from "./_examples.js"; +import { c } from "../ui/colors.js"; + +export const examples: Example[] = [ + [ + "Import a frame as a frozen SVG asset", + "hyperframes figma asset 'https://www.figma.com/design/KEY/T?node-id=1-2'", + ], + ["Import as PNG at 2x", "hyperframes figma asset KEY:1-2 --format png --scale 2"], + ["Pull brand tokens into the composition", "hyperframes figma tokens KEY"], + ["Import a frame as an editable HTML component", "hyperframes figma component KEY:10-20"], +]; + +const HELP = ` +${c.bold("hyperframes figma")} ${c.dim(" [args]")} + +Import figma content over the REST API. Requires ${c.accent("FIGMA_TOKEN")}. + +${c.bold("SUBCOMMANDS:")} + ${c.accent("asset")} ${c.dim("Render a node (png/svg/jpg/pdf), freeze under .media/, print a snippet.")} + ${c.accent("tokens")} ${c.dim("Import variables/styles as composition brand variables.")} + +${c.bold("ENV VARS:")} + ${c.accent("FIGMA_TOKEN")} Personal access token (figma.com/settings → security). + +${c.dim("Motion and shader import are agent-only (figma exposes no REST endpoint for")} +${c.dim("either) — use the /figma skill in a Claude session for those.")} +`; + +export default defineCommand({ + meta: { name: "figma", description: "Import figma assets, tokens, and components (REST)" }, + subCommands: { + asset: () => import("./figma/asset.js").then((m) => m.default), + }, + async run({ args }) { + if (!args._?.[0]) console.log(HELP); + }, +}); diff --git a/packages/cli/src/commands/figma/asset.test.ts b/packages/cli/src/commands/figma/asset.test.ts new file mode 100644 index 0000000000..680866673d --- /dev/null +++ b/packages/cli/src/commands/figma/asset.test.ts @@ -0,0 +1,104 @@ +// @vitest-environment node +import { describe, expect, it, afterEach } from "vitest"; +import { mkdtempSync, readFileSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { runAssetImport, type AssetImportDeps } from "./asset.js"; +import type { FigmaClient } from "@hyperframes/core/figma"; + +const dirs: string[] = []; +function scratch(): string { + const d = mkdtempSync(join(tmpdir(), "hf-figma-asset-")); + dirs.push(d); + return d; +} +afterEach(() => { + for (const d of dirs.splice(0)) rmSync(d, { recursive: true, force: true }); +}); + +function fakeClient(overrides: Partial = {}): FigmaClient { + return { + renderNode: () => Promise.resolve({ url: "https://cdn.example/a", ext: "png" }), + imageFills: () => Promise.resolve(new Map()), + variables: () => Promise.resolve({ variables: {}, variableCollections: {} }), + styles: () => Promise.resolve([]), + nodeTree: () => Promise.resolve({ id: "1:2", name: "n", type: "FRAME" }), + fileVersion: () => Promise.resolve({ version: "7", lastModified: "2026-07-01" }), + ...overrides, + }; +} + +const PNG_BYTES = new Uint8Array([0x89, 0x50, 0x4e, 0x47]); + +function deps(projectDir: string, overrides: Partial = {}): AssetImportDeps { + return { + projectDir, + client: fakeClient(), + download: () => Promise.resolve(PNG_BYTES), + ...overrides, + }; +} + +describe("runAssetImport", () => { + it("freezes the render, appends a manifest record with provenance, returns a snippet", async () => { + const dir = scratch(); + const out = await runAssetImport( + "https://www.figma.com/design/FILEKEY/T?node-id=1-2", + { format: "png" }, + deps(dir), + ); + expect(out.record.provenance).toMatchObject({ + source: "figma", + fileKey: "FILEKEY", + nodeId: "1:2", + version: "7", + format: "png", + }); + expect(out.snippet.html).toContain(" { + const dir = scratch(); + const dirty = ``; + const out = await runAssetImport( + "FILEKEY:1-2", + { format: "svg" }, + deps(dir, { + client: fakeClient({ + renderNode: () => Promise.resolve({ url: "https://cdn.example/a", ext: "svg" }), + }), + download: () => Promise.resolve(new TextEncoder().encode(dirty)), + }), + ); + const frozen = readFileSync(join(dir, out.record.path), "utf8"); + expect(frozen).not.toContain("script"); + expect(frozen).toContain(" { + const dir = scratch(); + const importPng = (over?: Partial) => + runAssetImport("FILEKEY:1-2", { format: "png" }, deps(dir, over)); + const first = await importPng(); + const sameVersion = await importPng(); + expect(sameVersion.record.id).toBe(first.record.id); + expect(sameVersion.reused).toBe(true); + const bumped = await importPng({ + client: fakeClient({ + fileVersion: () => Promise.resolve({ version: "8", lastModified: "2026-07-02" }), + }), + }); + expect(bumped.reused).toBe(false); + expect(bumped.record.id).not.toBe(first.record.id); + }); + + it("rejects a ref without a node id", async () => { + await expect(runAssetImport("FILEKEYONLY", { format: "png" }, deps(scratch()))).rejects.toThrow( + /node/i, + ); + }); +}); diff --git a/packages/cli/src/commands/figma/asset.ts b/packages/cli/src/commands/figma/asset.ts new file mode 100644 index 0000000000..08685506d4 --- /dev/null +++ b/packages/cli/src/commands/figma/asset.ts @@ -0,0 +1,146 @@ +/** + * `hyperframes figma asset ` — Phase 1 of the figma integration: + * render a node over REST, sanitize (svg), freeze under .media/, record + * provenance in the shared manifest, print a composition snippet. + */ + +import { defineCommand } from "citty"; +import { + appendRecord, + buildAssetSnippet, + createFigmaClient, + findByFigmaNode, + freezeBytes, + nextId, + parseFigmaRef, + sanitizeSvg, + typeDirPath, + type AssetSnippet, + type FigmaAssetFormat, + type FigmaClient, + type FigmaManifestRecord, +} from "@hyperframes/core/figma"; +import { existsSync } from "node:fs"; +import { join, relative } from "node:path"; + +export interface AssetImportOptions { + format: FigmaAssetFormat; + scale?: number; +} + +export interface AssetImportDeps { + projectDir: string; + client: FigmaClient; + /** fetch a short-lived figma CDN url into bytes; injectable for tests */ + download: (url: string) => Promise; +} + +export interface AssetImportResult { + record: FigmaManifestRecord; + snippet: AssetSnippet; + reused: boolean; +} + +async function defaultDownload(url: string): Promise { + const res = await fetch(url); + if (!res.ok) throw new Error(`figma render download failed: HTTP ${res.status}`); + return new Uint8Array(await res.arrayBuffer()); +} + +export async function runAssetImport( + refInput: string, + opts: AssetImportOptions, + deps: AssetImportDeps, +): Promise { + const ref = parseFigmaRef(refInput); + if (!ref.nodeId) + throw new Error( + `ref "${refInput}" has no node id — share a link with ?node-id=… or use fileKey:nodeId`, + ); + + const { version } = await deps.client.fileVersion(ref.fileKey); + + // Cache key per spec §5: fileKey:nodeId:format:scale:version → reuse. + // Unspecified scale is canonically 1 on both sides (figma's default), so + // `--scale 1` and no flag dedupe to the same record. Reuse also requires + // the frozen file to still exist — a deleted file falls through to + // re-import instead of returning a snippet that points at nothing. + const existing = findByFigmaNode(deps.projectDir, ref.fileKey, ref.nodeId); + if ( + existing && + existing.provenance.format === opts.format && + (existing.provenance.scale ?? 1) === (opts.scale ?? 1) && + existing.provenance.version === version && + existsSync(join(deps.projectDir, existing.path)) + ) { + return { record: existing, snippet: buildAssetSnippet(existing), reused: true }; + } + + const rendered = await deps.client.renderNode(ref, opts); + let bytes = await deps.download(rendered.url); + if (rendered.ext === "svg") { + // Sniff before decoding: an SVG starts with '<' or an XML decl/BOM. A + // non-text payload would decode to U+FFFD soup and still write to disk. + const b0 = bytes[0]; + if (b0 !== 0x3c && b0 !== 0x3f && b0 !== 0xef) + throw new Error("figma render returned non-SVG bytes for an svg export — retry the import"); + bytes = new TextEncoder().encode(sanitizeSvg(new TextDecoder().decode(bytes))); + } + + const id = nextId(deps.projectDir, "image"); + const destAbs = join(typeDirPath(deps.projectDir, "image"), `${id}.${rendered.ext}`); + freezeBytes(bytes, destAbs); + + const record: FigmaManifestRecord = { + id, + type: "image", + path: relative(deps.projectDir, destAbs), + source: `figma:${ref.fileKey}/${ref.nodeId}`, + provenance: { + source: "figma", + fileKey: ref.fileKey, + nodeId: ref.nodeId, + version, + format: opts.format, + scale: opts.scale, + }, + }; + appendRecord(deps.projectDir, record); + return { record, snippet: buildAssetSnippet(record), reused: false }; +} + +const FORMATS: readonly FigmaAssetFormat[] = ["png", "svg", "jpg", "pdf"]; + +function parseFormat(raw: string): FigmaAssetFormat { + for (const f of FORMATS) if (f === raw) return f; + throw new Error(`unsupported format "${raw}" — use one of ${FORMATS.join(", ")}`); +} + +export default defineCommand({ + meta: { name: "asset", description: "Import a figma node as a frozen local asset" }, + args: { + ref: { + type: "positional", + description: "figma URL, fileKey:nodeId, or fileKey", + required: true, + }, + format: { type: "string", description: "png | svg | jpg | pdf", default: "svg" }, + scale: { type: "string", description: "export scale (e.g. 2)" }, + dir: { type: "string", description: "project directory", default: "." }, + }, + async run({ args }) { + const token = process.env.FIGMA_TOKEN ?? ""; + const client = createFigmaClient({ token }); + const result = await runAssetImport( + args.ref, + { + format: parseFormat(args.format), + scale: args.scale !== undefined ? Number(args.scale) : undefined, + }, + { projectDir: args.dir, client, download: defaultDownload }, + ); + const verb = result.reused ? "reused" : "imported"; + console.log(`${verb} ${result.record.id} → ${result.record.path}`); + console.log(result.snippet.html); + }, +}); diff --git a/packages/core/src/figma/bindings.test.ts b/packages/core/src/figma/bindings.test.ts new file mode 100644 index 0000000000..36d4eea345 --- /dev/null +++ b/packages/core/src/figma/bindings.test.ts @@ -0,0 +1,82 @@ +// @vitest-environment node +import { describe, expect, it, afterEach, beforeEach } from "vitest"; +import { appendFileSync, mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { + appendBinding, + upsertBindings, + findBindingByFigmaId, + readBindings, + readLibraryMap, + recordLibraryFile, + type FigmaBindingRecord, +} from "./bindings"; + +let dir = ""; +beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), "hf-bindings-")); +}); +afterEach(() => rmSync(dir, { recursive: true, force: true })); + +const REC: FigmaBindingRecord = { + kind: "binding", + figmaId: "VariableID:1:23", + key: "abc123", + sourceFileKey: "FILE", + compositionVariableId: "figma:Blue/500", + version: "7", +}; + +describe("bindings index", () => { + it("round-trips binding records through .media/figma-bindings.jsonl", () => { + expect(readBindings(dir)).toEqual([]); + appendBinding(dir, REC); + appendBinding(dir, { + ...REC, + figmaId: "VariableID:1:24", + compositionVariableId: "figma:Red/500", + }); + const all = readBindings(dir); + expect(all).toHaveLength(2); + expect(all[0]?.compositionVariableId).toBe("figma:Blue/500"); + }); + + it("findBindingByFigmaId matches exact ids only — never values or names", () => { + appendBinding(dir, REC); + expect(findBindingByFigmaId(dir, "VariableID:1:23")?.key).toBe("abc123"); + expect(findBindingByFigmaId(dir, "VariableID:1:99")).toBeNull(); + expect(findBindingByFigmaId(dir, "Blue/500")).toBeNull(); + }); + + it("matches alias-chain members too (semantic id bound, primitive in chain)", () => { + appendBinding(dir, { ...REC, aliasChain: ["VariableID:9:1", "VariableID:1:23"] }); + expect(findBindingByFigmaId(dir, "VariableID:9:1")?.compositionVariableId).toBe( + "figma:Blue/500", + ); + }); + + it("persists answered library-file mappings (asked once per project)", () => { + expect(readLibraryMap(dir)).toEqual({}); + recordLibraryFile(dir, "libkey-1", "LIBFILE"); + expect(readLibraryMap(dir)).toEqual({ "libkey-1": "LIBFILE" }); + }); + + it("skips malformed lines instead of crashing", () => { + appendBinding(dir, REC); + appendFileSync(join(dir, ".media", "figma-bindings.jsonl"), "not json\n"); + expect(readBindings(dir)).toHaveLength(1); + }); + + it("upsert replaces stale rows for re-imported figmaIds, keeps others + library rows", () => { + appendBinding(dir, REC); + appendBinding(dir, { ...REC, figmaId: "VariableID:2:2", compositionVariableId: "figma:Red" }); + recordLibraryFile(dir, "libkey-2", "LIB2"); + upsertBindings(dir, [{ ...REC, compositionVariableId: "figma:Blue/500-v2", version: "9" }]); + const all = readBindings(dir); + expect(all).toHaveLength(2); + expect(findBindingByFigmaId(dir, REC.figmaId)?.compositionVariableId).toBe("figma:Blue/500-v2"); + expect(findBindingByFigmaId(dir, "VariableID:2:2")?.compositionVariableId).toBe("figma:Red"); + expect(readLibraryMap(dir)["libkey-2"]).toBe("LIB2"); + }); +}); diff --git a/packages/core/src/figma/bindings.ts b/packages/core/src/figma/bindings.ts new file mode 100644 index 0000000000..7758459c36 --- /dev/null +++ b/packages/core/src/figma/bindings.ts @@ -0,0 +1,124 @@ +/** + * Binding index — the machine-readable join between figma variable/style + * identities and composition variables (design spec §7.1). + * + * Lives at .media/figma-bindings.jsonl, next to (but separate from) the + * human-readable figma-tokens.json sidecar. Resolution is exact-ID only: + * a missed link bakes a correct literal; a wrong link silently changes + * color at the next brand refresh. Never match by value or name. + */ + +import { appendFileSync, mkdirSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { readJsonlValues } from "./jsonl"; +import { mediaDir } from "./manifest"; + +const BINDINGS_FILE = "figma-bindings.jsonl"; + +export interface FigmaBindingRecord { + kind: "binding"; + /** the figma variable/style id as it appears in node data (exact match key) */ + figmaId: string; + /** stable cross-file identity, when known ("id and key are stable over the lifetime") */ + key?: string; + sourceFileKey: string; + /** semantic→primitive alias chain, directly-bound id first */ + aliasChain?: string[]; + compositionVariableId: string; + brandRole?: string; + /** figma file version at import time — staleness check */ + version: string; +} + +interface LibraryRecord { + kind: "library"; + libraryKey: string; + fileKey: string; +} + +function bindingsPath(projectDir: string): string { + return join(mediaDir(projectDir), BINDINGS_FILE); +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null; +} + +function isBindingRecord(value: unknown): value is FigmaBindingRecord { + return ( + isRecord(value) && + value.kind === "binding" && + typeof value.figmaId === "string" && + typeof value.sourceFileKey === "string" && + typeof value.compositionVariableId === "string" && + typeof value.version === "string" + ); +} + +function isLibraryRecord(value: unknown): value is LibraryRecord { + return ( + isRecord(value) && + value.kind === "library" && + typeof value.libraryKey === "string" && + typeof value.fileKey === "string" + ); +} + +function readLines(projectDir: string): unknown[] { + return readJsonlValues(bindingsPath(projectDir)); +} + +function appendLine(projectDir: string, record: unknown): void { + mkdirSync(mediaDir(projectDir), { recursive: true }); + appendFileSync(bindingsPath(projectDir), JSON.stringify(record) + "\n"); +} + +export function readBindings(projectDir: string): FigmaBindingRecord[] { + return readLines(projectDir).filter(isBindingRecord); +} + +export function appendBinding(projectDir: string, record: FigmaBindingRecord): void { + appendLine(projectDir, record); +} + +/** + * Upsert by figmaId: re-running a tokens import must REPLACE that file's + * stale binding rows, not append duplicates — `findBindingByFigmaId` + * returns the first match, so appended duplicates would pin lookups to the + * stale record forever. Library rows and other files' bindings survive. + */ +export function upsertBindings(projectDir: string, records: FigmaBindingRecord[]): void { + const incoming = new Set(records.map((r) => r.figmaId)); + const survivors = readLines(projectDir).filter( + (line) => !(isBindingRecord(line) && incoming.has(line.figmaId)), + ); + mkdirSync(mediaDir(projectDir), { recursive: true }); + const lines = [...survivors, ...records].map((r) => JSON.stringify(r)).join("\n"); + writeFileSync(bindingsPath(projectDir), lines.length > 0 ? lines + "\n" : ""); +} + +/** Exact-ID lookup, checking alias chains too. Never value/name matching. */ +export function findBindingByFigmaId( + projectDir: string, + figmaId: string, +): FigmaBindingRecord | null { + for (const b of readBindings(projectDir)) { + if (b.figmaId === figmaId) return b; + if (b.aliasChain?.includes(figmaId)) return b; + } + return null; +} + +/** Answered "which file is this library?" mappings — asked once per project. */ +export function readLibraryMap(projectDir: string): Record { + const map: Record = {}; + for (const r of readLines(projectDir)) { + if (isLibraryRecord(r)) map[r.libraryKey] = r.fileKey; + } + return map; +} + +export function recordLibraryFile(projectDir: string, libraryKey: string, fileKey: string): void { + const record: LibraryRecord = { kind: "library", libraryKey, fileKey }; + appendLine(projectDir, record); +} diff --git a/packages/core/src/figma/client.test.ts b/packages/core/src/figma/client.test.ts new file mode 100644 index 0000000000..04c3b14005 --- /dev/null +++ b/packages/core/src/figma/client.test.ts @@ -0,0 +1,185 @@ +// @vitest-environment node +import { describe, expect, it } from "vitest"; +import { createFigmaClient, FigmaClientError, type FigmaFetch } from "./client"; + +function jsonResponse(status: number, body: unknown): Response { + return new Response(JSON.stringify(body), { + status, + headers: { "content-type": "application/json" }, + }); +} + +function fetchStub(handler: (url: string) => Response): { fetch: FigmaFetch; calls: string[] } { + const calls: string[] = []; + const fetch: FigmaFetch = (url, init) => { + calls.push(`${url}|${JSON.stringify(init?.headers ?? {})}`); + return Promise.resolve(handler(url)); + }; + return { fetch, calls }; +} + +describe("createFigmaClient", () => { + it("throws NO_TOKEN when token is missing or blank", () => { + expect(() => createFigmaClient({ token: "" })).toThrowError( + expect.objectContaining({ code: "NO_TOKEN" }), + ); + expect(() => createFigmaClient({ token: " " })).toThrowError( + expect.objectContaining({ code: "NO_TOKEN" }), + ); + }); + + it("sends the token as X-Figma-Token on every request", async () => { + const { fetch, calls } = fetchStub(() => + jsonResponse(200, { images: { "1:2": "https://cdn.example/a.png" } }), + ); + const client = createFigmaClient({ token: "tok-1", fetch }); + await client.renderNode({ fileKey: "F", nodeId: "1:2" }, { format: "png" }); + expect(calls[0]).toContain('"X-Figma-Token":"tok-1"'); + }); +}); + +describe("renderNode", () => { + it("calls /v1/images/:key with ids/format/scale and returns the render url", async () => { + const { fetch, calls } = fetchStub(() => + jsonResponse(200, { images: { "1:2": "https://cdn.example/a.png" } }), + ); + const client = createFigmaClient({ token: "t", fetch }); + const out = await client.renderNode( + { fileKey: "FILE", nodeId: "1:2" }, + { format: "png", scale: 2 }, + ); + expect(out.url).toBe("https://cdn.example/a.png"); + expect(out.ext).toBe("png"); + expect(calls[0]).toContain("/v1/images/FILE?ids=1%3A2&format=png&scale=2"); + }); + + it("throws when the ref has no nodeId", async () => { + const client = createFigmaClient({ + token: "t", + fetch: fetchStub(() => jsonResponse(200, {})).fetch, + }); + await expect(client.renderNode({ fileKey: "F" }, { format: "png" })).rejects.toThrowError( + /nodeId/, + ); + }); + + it("throws RENDER_FAILED when figma returns a null render", async () => { + const { fetch } = fetchStub(() => jsonResponse(200, { images: { "1:2": null } })); + const client = createFigmaClient({ token: "t", fetch }); + await expect( + client.renderNode({ fileKey: "F", nodeId: "1:2" }, { format: "svg" }), + ).rejects.toThrowError(expect.objectContaining({ code: "RENDER_FAILED" })); + }); +}); + +describe("imageFills", () => { + it("returns the imageRef->url map from /v1/files/:key/images", async () => { + const { fetch } = fetchStub(() => + jsonResponse(200, { meta: { images: { refA: "https://cdn/x" } } }), + ); + const client = createFigmaClient({ token: "t", fetch }); + const fills = await client.imageFills("FILE"); + expect(fills.get("refA")).toBe("https://cdn/x"); + }); +}); + +describe("variables", () => { + it("maps HTTP 403 to REQUIRES_ENTERPRISE", async () => { + const { fetch } = fetchStub(() => jsonResponse(403, { message: "nope" })); + const client = createFigmaClient({ token: "t", fetch }); + await expect(client.variables("FILE")).rejects.toThrowError( + expect.objectContaining({ code: "REQUIRES_ENTERPRISE" }), + ); + }); + + it("returns meta payload on success", async () => { + const { fetch, calls } = fetchStub(() => + jsonResponse(200, { + meta: { variables: { "VariableID:1:2": { name: "Blue/500" } }, variableCollections: {} }, + }), + ); + const client = createFigmaClient({ token: "t", fetch }); + const out = await client.variables("FILE"); + expect(out.variables["VariableID:1:2"]?.name).toBe("Blue/500"); + expect(calls[0]).toContain("/v1/files/FILE/variables/local"); + }); +}); + +describe("error mapping", () => { + it("maps 429 to RATE_LIMITED and 401 to BAD_TOKEN", async () => { + const c429 = createFigmaClient({ + token: "t", + fetch: fetchStub(() => jsonResponse(429, {})).fetch, + }); + await expect(c429.styles("F")).rejects.toThrowError( + expect.objectContaining({ code: "RATE_LIMITED" }), + ); + const c401 = createFigmaClient({ + token: "t", + fetch: fetchStub(() => jsonResponse(401, {})).fetch, + }); + await expect(c401.styles("F")).rejects.toThrowError( + expect.objectContaining({ code: "BAD_TOKEN" }), + ); + }); + + it("wraps other failures as HTTP_ERROR with status", async () => { + const client = createFigmaClient({ + token: "t", + fetch: fetchStub(() => jsonResponse(500, {})).fetch, + }); + const err = await client.nodeTree({ fileKey: "F", nodeId: "1:2" }).catch((e: unknown) => e); + expect(err).toBeInstanceOf(FigmaClientError); + if (err instanceof FigmaClientError) { + expect(err.code).toBe("HTTP_ERROR"); + expect(err.status).toBe(500); + } + }); +}); + +describe("nodeTree", () => { + it("requests geometry=paths and returns the node document", async () => { + const { fetch, calls } = fetchStub(() => + jsonResponse(200, { + nodes: { "1:2": { document: { id: "1:2", name: "Hero", type: "FRAME" } } }, + }), + ); + const client = createFigmaClient({ token: "t", fetch }); + const node = await client.nodeTree({ fileKey: "F", nodeId: "1:2" }); + expect(node.name).toBe("Hero"); + expect(calls[0]).toContain("/v1/files/F/nodes?ids=1%3A2&geometry=paths"); + }); + + it("throws NODE_NOT_FOUND when the id is absent", async () => { + const { fetch } = fetchStub(() => jsonResponse(200, { nodes: {} })); + const client = createFigmaClient({ token: "t", fetch }); + await expect(client.nodeTree({ fileKey: "F", nodeId: "9:9" })).rejects.toThrowError( + expect.objectContaining({ code: "NODE_NOT_FOUND" }), + ); + }); +}); + +describe("styles", () => { + it("returns published styles list", async () => { + const { fetch } = fetchStub(() => + jsonResponse(200, { + meta: { styles: [{ key: "k1", name: "Primary", style_type: "FILL" }] }, + }), + ); + const client = createFigmaClient({ token: "t", fetch }); + const styles = await client.styles("F"); + expect(styles[0]?.key).toBe("k1"); + }); +}); + +describe("fileVersion", () => { + it("returns version + lastModified from file metadata", async () => { + const { fetch, calls } = fetchStub(() => + jsonResponse(200, { version: "42", lastModified: "2026-07-01T00:00:00Z" }), + ); + const client = createFigmaClient({ token: "t", fetch }); + const meta = await client.fileVersion("F"); + expect(meta.version).toBe("42"); + expect(calls[0]).toContain("/v1/files/F?depth=1"); + }); +}); diff --git a/packages/core/src/figma/client.ts b/packages/core/src/figma/client.ts new file mode 100644 index 0000000000..a433ae09a0 --- /dev/null +++ b/packages/core/src/figma/client.ts @@ -0,0 +1,233 @@ +import type { FigmaAssetFormat, FigmaRef } from "./types"; + +/** Typed capability/transport failures per design spec §4.4. */ +export type FigmaClientErrorCode = + | "NO_TOKEN" + | "BAD_TOKEN" + | "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 — mint a personal access token at figma.com/settings and export FIGMA_TOKEN", + ); + } + 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)", 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 === 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 }; + }, + }; +} diff --git a/packages/core/src/figma/index.ts b/packages/core/src/figma/index.ts index 5313eb4503..2eaf7e3946 100644 --- a/packages/core/src/figma/index.ts +++ b/packages/core/src/figma/index.ts @@ -1,4 +1,18 @@ export type * from "./types"; +export { createFigmaClient, FigmaClientError } from "./client"; +export type { + FigmaClient, + FigmaClientErrorCode, + FigmaClientOptions, + FigmaFetch, + FigmaFileVersion, + FigmaNodeDocument, + FigmaStyleMeta, + FigmaVariablePayload, + FigmaVariablesResult, + RenderedNode, + RenderNodeOptions, +} from "./client"; export { parseFigmaRef } from "./parseFigmaRef"; export { MAX_FREEZE_BYTES, @@ -18,6 +32,7 @@ export { nextId, } from "./manifest"; export { buildAssetSnippet } from "./assetSnippet"; +export { sanitizeSvg } from "./sanitizeSvg"; export { mapEase } from "./motionEase"; export { motionToGsap } from "./motionToGsap"; export { emitTimelineScript } from "./emitTimelineScript"; diff --git a/packages/core/src/figma/jsonl.ts b/packages/core/src/figma/jsonl.ts new file mode 100644 index 0000000000..a068624e95 --- /dev/null +++ b/packages/core/src/figma/jsonl.ts @@ -0,0 +1,19 @@ +import { existsSync, readFileSync } from "node:fs"; + +/** Parse a .jsonl file into values, skipping blank/malformed lines. */ +export function readJsonlValues(path: string): unknown[] { + if (!existsSync(path)) return []; + const out: unknown[] = []; + for (const line of readFileSync(path, "utf8").split(/\r?\n/)) { + const trimmed = line.trim(); + if (trimmed.length === 0) continue; + try { + out.push(JSON.parse(trimmed)); + } catch { + // ponytail: skip malformed lines (partial write from a crash), but say + // so — a silent skip reads as "no record found" downstream. + console.warn(`skipping malformed jsonl line in ${path}: ${trimmed.slice(0, 40)}…`); + } + } + return out; +} diff --git a/packages/core/src/figma/manifest.ts b/packages/core/src/figma/manifest.ts index c2180764ba..3bce171585 100644 --- a/packages/core/src/figma/manifest.ts +++ b/packages/core/src/figma/manifest.ts @@ -1,5 +1,6 @@ import { appendFileSync, existsSync, mkdirSync, readFileSync } from "node:fs"; import { join } from "node:path"; +import { readJsonlValues } from "./jsonl"; import type { FigmaManifestRecord } from "./types"; const MANIFEST_FILE = "manifest.jsonl"; @@ -54,20 +55,7 @@ export function isFigmaManifestRecord(value: unknown): value is FigmaManifestRec } export function readManifest(projectDir: string): FigmaManifestRecord[] { - const p = manifestPath(projectDir); - if (!existsSync(p)) return []; - const out: FigmaManifestRecord[] = []; - for (const line of readFileSync(p, "utf8").split(/\r?\n/)) { - const trimmed = line.trim(); - if (trimmed.length === 0) continue; - try { - const parsed: unknown = JSON.parse(trimmed); - if (isFigmaManifestRecord(parsed)) out.push(parsed); - } catch { - // ponytail: skip malformed/non-matching lines, don't crash the whole read - } - } - return out; + return readJsonlValues(manifestPath(projectDir)).filter(isFigmaManifestRecord); } export function appendRecord(projectDir: string, record: FigmaManifestRecord): void { diff --git a/packages/core/src/figma/sanitizeSvg.test.ts b/packages/core/src/figma/sanitizeSvg.test.ts new file mode 100644 index 0000000000..ee3c98f7d0 --- /dev/null +++ b/packages/core/src/figma/sanitizeSvg.test.ts @@ -0,0 +1,98 @@ +// @vitest-environment node +import { describe, expect, it } from "vitest"; +import { sanitizeSvg } from "./sanitizeSvg"; + +describe("sanitizeSvg", () => { + it("strips `; + const clean = sanitizeSvg(dirty); + expect(clean).not.toContain("script"); + expect(clean).toContain(" { + const dirty = `