mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-10 22:20:14 +00:00
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:
co-authored by
Claude Fable 5
parent
23f67da5e2
commit
fb13797d2f
@@ -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,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user