mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-03 23:03:09 +00:00
## What
Foundations of the `@hyperframes/core/figma` module — the pure, transport-agnostic layer every later phase builds on:
- **`types.ts`** — `FigmaRef`, `FigmaProvenance`, `FigmaManifestRecord`, and the Motion model (`MotionDoc`/`MotionTrack`/`TimelineSpec`/`GsapTween`) shared across the stack.
- **`parseFigmaRef`** — normalizes any user input (full `/design|/file|/proto` URLs with `?node-id=1-2`, `fileKey:nodeId` shorthand, bare `fileKey`) into `{ fileKey, nodeId }`, including the URL-dash → API-colon node-id conversion.
- **`freeze.ts`** — `freezeBytes`/`freezeUrl`/`freezeLocalFile` with a 256 MB cap; every Figma asset is frozen to a local file before it can reach a composition (determinism: no render-time network).
- **`manifest.ts`** — the `.media/manifest.jsonl` ledger (same layout `media-use` writes, so a project has one shared media inventory without either skill depending on the other): append/read/find-by-node/next-id, with a pure type-guard (`isFigmaManifestRecord`) instead of `as`-casts.
- **`assetSnippet.ts`** — manifest record → composition `<img>` snippet with escaped attrs + `data-figma-id`.
- **publishConfig fix** — `./figma` added to `packages/core` `publishConfig.exports` (the packed-manifest CI gate requires every source export to have a dist mapping).
## Why
Design spec: `docs/superpowers/specs/2026-06-30-figma-asset-integration-design.md`. These functions are deliberately transport-agnostic — when the project reversed from MCP-first to a REST/MCP split (spec §2), nothing in this layer changed. That was the point.
## Tests
Unit tests per module (URL variants, freeze cap edges, manifest round-trip/malformed-line tolerance, snippet escaping). All colocated `*.test.ts`, vitest, no network.
---
Stack (1/6): this PR → #1869 → #1870 → #1871 → #1872 → #1873
🤖 Generated with [Claude Code](https://claude.com/claude-code)
94 lines
3.0 KiB
TypeScript
94 lines
3.0 KiB
TypeScript
// @vitest-environment node
|
|
import { describe, expect, it, afterEach } from "vitest";
|
|
import { appendFileSync, mkdirSync, mkdtempSync, rmSync } from "node:fs";
|
|
import { tmpdir } from "node:os";
|
|
import { join } from "node:path";
|
|
import { appendRecord, findByFigmaNode, manifestPath, nextId, readManifest } from "./manifest";
|
|
import type { FigmaManifestRecord } from "./types";
|
|
|
|
const dirs: string[] = [];
|
|
function project(): string {
|
|
const d = mkdtempSync(join(tmpdir(), "hf-manifest-"));
|
|
dirs.push(d);
|
|
return d;
|
|
}
|
|
afterEach(() => {
|
|
for (const d of dirs.splice(0)) rmSync(d, { recursive: true, force: true });
|
|
});
|
|
|
|
function rec(id: string, nodeId: string): FigmaManifestRecord {
|
|
return {
|
|
id,
|
|
type: "image",
|
|
path: `.media/images/${id}.png`,
|
|
source: "figma",
|
|
provenance: { source: "figma", fileKey: "FK", nodeId, format: "png" },
|
|
};
|
|
}
|
|
|
|
describe("manifest", () => {
|
|
it("appends and reads back records", () => {
|
|
const p = project();
|
|
appendRecord(p, rec("image_001", "1:2"));
|
|
appendRecord(p, rec("image_002", "3:4"));
|
|
const all = readManifest(p);
|
|
expect(all.map((r) => r.id)).toEqual(["image_001", "image_002"]);
|
|
expect(all[1]?.provenance.nodeId).toBe("3:4");
|
|
});
|
|
|
|
it("finds a record by figma node", () => {
|
|
const p = project();
|
|
appendRecord(p, rec("image_001", "1:2"));
|
|
expect(findByFigmaNode(p, "FK", "1:2")?.id).toBe("image_001");
|
|
expect(findByFigmaNode(p, "FK", "9:9")).toBeNull();
|
|
});
|
|
|
|
it("allocates incrementing ids per type", () => {
|
|
const p = project();
|
|
expect(nextId(p, "image")).toBe("image_001");
|
|
appendRecord(p, rec("image_001", "1:2"));
|
|
expect(nextId(p, "image")).toBe("image_002");
|
|
});
|
|
|
|
it("skips a manifest line that doesn't match the record shape", () => {
|
|
const p = project();
|
|
appendRecord(p, rec("image_001", "1:2"));
|
|
appendFileSync(manifestPath(p), JSON.stringify({ foo: "bar" }) + "\n");
|
|
appendRecord(p, rec("image_002", "3:4"));
|
|
expect(readManifest(p).map((r) => r.id)).toEqual(["image_001", "image_002"]);
|
|
});
|
|
|
|
it("nextId scans other writers' rows (media-use) so ids never collide", () => {
|
|
const p = project();
|
|
mkdirSync(join(p, ".media"), { recursive: true });
|
|
// media-use shaped row: no provenance.source — fails the figma guard
|
|
appendFileSync(
|
|
manifestPath(p),
|
|
JSON.stringify({
|
|
id: "image_007",
|
|
type: "image",
|
|
path: ".media/images/image_007.png",
|
|
source: "unsplash",
|
|
provenance: { provider: "unsplash" },
|
|
}) + "\n",
|
|
);
|
|
expect(nextId(p, "image")).toBe("image_008");
|
|
});
|
|
|
|
it("rejects manifest rows with a non image/video type", () => {
|
|
const p = project();
|
|
mkdirSync(join(p, ".media"), { recursive: true });
|
|
appendFileSync(
|
|
manifestPath(p),
|
|
JSON.stringify({
|
|
id: "audio_001",
|
|
type: "audio",
|
|
path: "x",
|
|
source: "figma:F/1",
|
|
provenance: { source: "figma", fileKey: "F", nodeId: "1:1" },
|
|
}) + "\n",
|
|
);
|
|
expect(readManifest(p)).toHaveLength(0);
|
|
});
|
|
});
|