mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-11 14:50:02 +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,
|
||||
);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user