feat(core,cli): figma REST client, asset import command, binding index (M0+M1) (#1870)

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 <noreply@anthropic.com>
This commit is contained in:
Vance Ingalls
2026-07-03 18:11:33 -07:00
committed by GitHub
co-authored by Claude Fable 5
parent 23f67da5e2
commit fb13797d2f
13 changed files with 1114 additions and 14 deletions
@@ -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> = {}): 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> = {}): 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("<img");
const frozen = readFileSync(join(dir, out.record.path));
expect(Array.from(frozen)).toEqual(Array.from(PNG_BYTES));
const manifest = readFileSync(join(dir, ".media", "manifest.jsonl"), "utf8");
expect(manifest).toContain('"fileKey":"FILEKEY"');
});
it("sanitizes svg output before freezing", async () => {
const dir = scratch();
const dirty = `<svg><script>evil()</script><rect width="1"/></svg>`;
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("<rect");
});
it("reuses on identical version, re-imports when the file version moved on", async () => {
const dir = scratch();
const importPng = (over?: Partial<AssetImportDeps>) =>
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,
);
});
});
+146
View File
@@ -0,0 +1,146 @@
/**
* `hyperframes figma asset <ref>` — 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<Uint8Array>;
}
export interface AssetImportResult {
record: FigmaManifestRecord;
snippet: AssetSnippet;
reused: boolean;
}
async function defaultDownload(url: string): Promise<Uint8Array> {
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<AssetImportResult> {
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);
},
});