mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-03 04:38:33 +00:00
feat(core): figma module foundations — types, parseFigmaRef, freeze, manifest, asset snippet (#1868)
## 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)
This commit is contained in:
@@ -202,6 +202,19 @@
|
|||||||
"file": "packages/studio/src/utils/timelineElementSplit.ts",
|
"file": "packages/studio/src/utils/timelineElementSplit.ts",
|
||||||
"exports": ["buildPatchTarget", "readFileContent"],
|
"exports": ["buildPatchTarget", "readFileContent"],
|
||||||
},
|
},
|
||||||
|
// freezeUrl and freezeLocalFile are public API for Task 4 manifest flow
|
||||||
|
// and the /figma skill integration; not yet imported by current code.
|
||||||
|
{
|
||||||
|
"file": "packages/core/src/figma/freeze.ts",
|
||||||
|
"exports": ["freezeUrl", "freezeLocalFile"],
|
||||||
|
},
|
||||||
|
// mediaDir, typeDirPath, isFigmaManifestRecord: exported from manifest.ts
|
||||||
|
// for barrel wiring in Task 8 (one-batch export across Tasks 2-7).
|
||||||
|
// Not yet consumed by any code in the current stack.
|
||||||
|
{
|
||||||
|
"file": "packages/core/src/figma/manifest.ts",
|
||||||
|
"exports": ["mediaDir", "typeDirPath", "isFigmaManifestRecord"],
|
||||||
|
},
|
||||||
],
|
],
|
||||||
"ignoreDependencies": [
|
"ignoreDependencies": [
|
||||||
// Runtime/dynamic deps not visible to static analysis: tsup `external`,
|
// Runtime/dynamic deps not visible to static analysis: tsup `external`,
|
||||||
@@ -325,6 +338,12 @@
|
|||||||
// → assert-remove-spawn shape; each verifies a distinct prune behavior, so
|
// → assert-remove-spawn shape; each verifies a distinct prune behavior, so
|
||||||
// extracting the shared scaffold would obscure what each case asserts.
|
// extracting the shared scaffold would obscure what each case asserts.
|
||||||
"packages/cli/src/commands/skills.test.ts",
|
"packages/cli/src/commands/skills.test.ts",
|
||||||
|
// figma test files: freeze.test.ts and manifest.test.ts share a common
|
||||||
|
// arrange/act/assert setup preamble (file creation + test dir handling) that
|
||||||
|
// is minimal boilerplate; collapsing it into a shared fixture would reduce
|
||||||
|
// readability of each test's independent setup.
|
||||||
|
"packages/core/src/figma/freeze.test.ts",
|
||||||
|
"packages/core/src/figma/manifest.test.ts",
|
||||||
// layout-audit.browser.test.ts: parallel arrange/act/assert cases per audit
|
// layout-audit.browser.test.ts: parallel arrange/act/assert cases per audit
|
||||||
// rule (overflow / overlap / occlusion) that each install their own mocked
|
// rule (overflow / overlap / occlusion) that each install their own mocked
|
||||||
// geometry + computed-style before asserting. The clone groups are this
|
// geometry + computed-style before asserting. The clone groups are this
|
||||||
@@ -447,6 +466,11 @@
|
|||||||
// label-position code added earlier in the file shifted its line number,
|
// label-position code added earlier in the file shifted its line number,
|
||||||
// so the line-shift fingerprint re-flags this inherited finding.
|
// so the line-shift fingerprint re-flags this inherited finding.
|
||||||
"packages/parsers/src/gsapParserAcorn.ts",
|
"packages/parsers/src/gsapParserAcorn.ts",
|
||||||
|
// isFigmaManifestRecord: type guard with 19 cyclomatic due to chained
|
||||||
|
// field validation (id/type/path/source types + value shape guards).
|
||||||
|
// This is the correct shape for a discriminating type guard; refactoring
|
||||||
|
// into smaller guards would obscure the unified validation contract.
|
||||||
|
"packages/core/src/figma/manifest.ts",
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -203,6 +203,12 @@
|
|||||||
"import": "./src/fonts/systemFontLocator.ts",
|
"import": "./src/fonts/systemFontLocator.ts",
|
||||||
"types": "./src/fonts/systemFontLocator.ts"
|
"types": "./src/fonts/systemFontLocator.ts"
|
||||||
},
|
},
|
||||||
|
"./figma": {
|
||||||
|
"bun": "./src/figma/index.ts",
|
||||||
|
"node": "./dist/figma/index.js",
|
||||||
|
"import": "./src/figma/index.ts",
|
||||||
|
"types": "./src/figma/index.ts"
|
||||||
|
},
|
||||||
"./schemas/registry.json": "./schemas/registry.json",
|
"./schemas/registry.json": "./schemas/registry.json",
|
||||||
"./schemas/registry-item.json": "./schemas/registry-item.json"
|
"./schemas/registry-item.json": "./schemas/registry-item.json"
|
||||||
},
|
},
|
||||||
@@ -226,6 +232,10 @@
|
|||||||
"import": "./dist/editing/affordances.js",
|
"import": "./dist/editing/affordances.js",
|
||||||
"types": "./dist/editing/affordances.d.ts"
|
"types": "./dist/editing/affordances.d.ts"
|
||||||
},
|
},
|
||||||
|
"./figma": {
|
||||||
|
"import": "./dist/figma/index.js",
|
||||||
|
"types": "./dist/figma/index.d.ts"
|
||||||
|
},
|
||||||
"./generators": {
|
"./generators": {
|
||||||
"import": "./dist/generators/hyperframes.js",
|
"import": "./dist/generators/hyperframes.js",
|
||||||
"types": "./dist/generators/hyperframes.d.ts"
|
"types": "./dist/generators/hyperframes.d.ts"
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
// @vitest-environment node
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { buildAssetSnippet } from "./assetSnippet";
|
||||||
|
import type { FigmaManifestRecord } from "./types";
|
||||||
|
|
||||||
|
const record: FigmaManifestRecord = {
|
||||||
|
id: "image_001",
|
||||||
|
type: "image",
|
||||||
|
path: ".media/images/image_001.png",
|
||||||
|
source: "figma",
|
||||||
|
description: 'Hero "banner"',
|
||||||
|
width: 240,
|
||||||
|
height: 57,
|
||||||
|
provenance: { source: "figma", fileKey: "FK", nodeId: "92:573", format: "png" },
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("buildAssetSnippet", () => {
|
||||||
|
it("emits an img tag with src, dims, escaped alt, and data-figma-id", () => {
|
||||||
|
const { path, html } = buildAssetSnippet(record);
|
||||||
|
expect(path).toBe(".media/images/image_001.png");
|
||||||
|
expect(html).toContain('src=".media/images/image_001.png"');
|
||||||
|
expect(html).toContain('width="240"');
|
||||||
|
expect(html).toContain('height="57"');
|
||||||
|
expect(html).toContain('data-figma-id="92:573"');
|
||||||
|
expect(html).toContain(""banner"");
|
||||||
|
expect(html).not.toContain('"banner"');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import type { AssetSnippet, FigmaManifestRecord } from "./types";
|
||||||
|
|
||||||
|
function escapeAttr(value: string): string {
|
||||||
|
return value
|
||||||
|
.replace(/&/g, "&")
|
||||||
|
.replace(/</g, "<")
|
||||||
|
.replace(/>/g, ">")
|
||||||
|
.replace(/"/g, """);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildAssetSnippet(record: FigmaManifestRecord): AssetSnippet {
|
||||||
|
// Every interpolated attribute is escaped — path and nodeId are
|
||||||
|
// system-generated today, but defense-in-depth is free here.
|
||||||
|
const alt = escapeAttr(record.description ?? record.id);
|
||||||
|
const src = escapeAttr(record.path);
|
||||||
|
const w = record.width !== undefined ? ` width="${record.width}"` : "";
|
||||||
|
const h = record.height !== undefined ? ` height="${record.height}"` : "";
|
||||||
|
const html = `<img src="${src}" alt="${alt}"${w}${h} data-figma-id="${escapeAttr(record.provenance.nodeId)}" />`;
|
||||||
|
return { path: record.path, html };
|
||||||
|
}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
// @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 { exceedsFreezeCap, freezeBytes, MAX_FREEZE_BYTES } from "./freeze";
|
||||||
|
|
||||||
|
const dirs: string[] = [];
|
||||||
|
function scratch(): string {
|
||||||
|
const d = mkdtempSync(join(tmpdir(), "hf-freeze-"));
|
||||||
|
dirs.push(d);
|
||||||
|
return d;
|
||||||
|
}
|
||||||
|
afterEach(() => {
|
||||||
|
for (const d of dirs.splice(0)) rmSync(d, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("exceedsFreezeCap", () => {
|
||||||
|
it("is false at and under the cap, true just over it", () => {
|
||||||
|
expect(exceedsFreezeCap(MAX_FREEZE_BYTES)).toBe(false);
|
||||||
|
expect(exceedsFreezeCap(MAX_FREEZE_BYTES + 1)).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("freezeBytes", () => {
|
||||||
|
it("writes bytes to a nested path and returns the length", () => {
|
||||||
|
const dest = join(scratch(), "images", "a.png");
|
||||||
|
const bytes = new Uint8Array([1, 2, 3, 4]);
|
||||||
|
expect(freezeBytes(bytes, dest)).toBe(4);
|
||||||
|
expect(Array.from(readFileSync(dest))).toEqual([1, 2, 3, 4]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("throws on empty bytes", () => {
|
||||||
|
expect(() => freezeBytes(new Uint8Array(0), join(scratch(), "x"))).toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("freezeUrl allowlist", () => {
|
||||||
|
it("accepts figma + figma-s3 hosts, https only", async () => {
|
||||||
|
const { isAllowedFreezeUrl } = await import("./freeze");
|
||||||
|
expect(isAllowedFreezeUrl("https://s3-alpha-sig.figma.com/img/x")).toBe(true);
|
||||||
|
expect(isAllowedFreezeUrl("https://figma-alpha-api.s3.us-west-2.amazonaws.com/images/x")).toBe(
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
expect(isAllowedFreezeUrl("http://s3-alpha-sig.figma.com/img/x")).toBe(false);
|
||||||
|
expect(isAllowedFreezeUrl("https://169.254.169.254/latest/meta-data/")).toBe(false);
|
||||||
|
expect(isAllowedFreezeUrl("https://evilfigma.com/x")).toBe(false);
|
||||||
|
expect(isAllowedFreezeUrl("file:///etc/passwd")).toBe(false);
|
||||||
|
expect(isAllowedFreezeUrl("not a url")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refuses to freeze from a non-allowlisted url", async () => {
|
||||||
|
const { freezeUrl } = await import("./freeze");
|
||||||
|
await expect(freezeUrl("http://localhost:6379/x", "/tmp/never")).rejects.toThrow(
|
||||||
|
/refusing non-figma url/,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
/**
|
||||||
|
* "Freeze" = write asset bytes to local disk permanently so renders never
|
||||||
|
* re-fetch from figma (design spec §5) — not Object.freeze.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { copyFileSync, mkdirSync, rmSync, statSync, writeFileSync } from "node:fs";
|
||||||
|
import { dirname } from "node:path";
|
||||||
|
|
||||||
|
// ponytail: bound the write so a hostile/runaway source can't fill the disk.
|
||||||
|
export const MAX_FREEZE_BYTES = 256 * 1024 * 1024;
|
||||||
|
|
||||||
|
export function exceedsFreezeCap(byteLength: number): boolean {
|
||||||
|
return byteLength > MAX_FREEZE_BYTES;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function freezeBytes(bytes: Uint8Array, destPath: string): number {
|
||||||
|
if (bytes.length === 0) throw new Error("freeze failed: empty bytes");
|
||||||
|
if (exceedsFreezeCap(bytes.length))
|
||||||
|
throw new Error(`freeze failed: ${bytes.length} bytes exceeds ${MAX_FREEZE_BYTES} cap`);
|
||||||
|
mkdirSync(dirname(destPath), { recursive: true });
|
||||||
|
// Exclusive create; on EEXIST remove and retry — never write through an
|
||||||
|
// existing file or planted symlink (CodeQL js/insecure-temporary-file).
|
||||||
|
try {
|
||||||
|
writeFileSync(destPath, bytes, { flag: "wx" });
|
||||||
|
} catch (err) {
|
||||||
|
if ((err as NodeJS.ErrnoException).code !== "EEXIST") throw err;
|
||||||
|
rmSync(destPath);
|
||||||
|
writeFileSync(destPath, bytes, { flag: "wx" });
|
||||||
|
}
|
||||||
|
return bytes.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Only figma-owned hosts may be frozen from a URL — render/CDN responses
|
||||||
|
* come from figma.com subdomains or figma's S3 buckets. Blocks SSRF via a
|
||||||
|
* crafted manifest/config URL (metadata endpoints, internal services).
|
||||||
|
*/
|
||||||
|
export function isAllowedFreezeUrl(url: string): boolean {
|
||||||
|
let parsed: URL;
|
||||||
|
try {
|
||||||
|
parsed = new URL(url);
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (parsed.protocol !== "https:") return false;
|
||||||
|
const host = parsed.hostname;
|
||||||
|
return host === "figma.com" || host.endsWith(".figma.com") || host.endsWith(".amazonaws.com");
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function freezeUrl(url: string, destPath: string): Promise<number> {
|
||||||
|
if (!isAllowedFreezeUrl(url))
|
||||||
|
throw new Error(`freeze failed: refusing non-figma url ${url} (https + figma hosts only)`);
|
||||||
|
const res = await fetch(url);
|
||||||
|
if (!res.ok) throw new Error(`freeze failed: HTTP ${res.status}`);
|
||||||
|
const declared = Number(res.headers.get("content-length") ?? 0);
|
||||||
|
if (exceedsFreezeCap(declared))
|
||||||
|
throw new Error(`freeze failed: content-length ${declared} exceeds ${MAX_FREEZE_BYTES} cap`);
|
||||||
|
return freezeBytes(new Uint8Array(await res.arrayBuffer()), destPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function freezeLocalFile(srcPath: string, destPath: string): void {
|
||||||
|
const size = statSync(srcPath).size;
|
||||||
|
if (exceedsFreezeCap(size))
|
||||||
|
throw new Error(`freeze failed: ${size} bytes exceeds ${MAX_FREEZE_BYTES} cap`);
|
||||||
|
mkdirSync(dirname(destPath), { recursive: true });
|
||||||
|
copyFileSync(srcPath, destPath);
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
// @vitest-environment node
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { MAX_FREEZE_BYTES } from "./index";
|
||||||
|
|
||||||
|
describe("figma barrel", () => {
|
||||||
|
it("re-exports the freeze cap constant", () => {
|
||||||
|
expect(MAX_FREEZE_BYTES).toBe(256 * 1024 * 1024);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
export * from "./types";
|
||||||
|
export { MAX_FREEZE_BYTES } from "./freeze";
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
// @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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
import { appendFileSync, existsSync, mkdirSync, readFileSync } from "node:fs";
|
||||||
|
import { join } from "node:path";
|
||||||
|
import type { FigmaManifestRecord } from "./types";
|
||||||
|
|
||||||
|
const MANIFEST_FILE = "manifest.jsonl";
|
||||||
|
|
||||||
|
// Mirrors the media-use `.media/` layout for interop (one shared inventory).
|
||||||
|
const TYPE_DIRS: Record<FigmaManifestRecord["type"], string> = {
|
||||||
|
image: "images",
|
||||||
|
video: "video",
|
||||||
|
};
|
||||||
|
|
||||||
|
export function mediaDir(projectDir: string): string {
|
||||||
|
return join(projectDir, ".media");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function manifestPath(projectDir: string): string {
|
||||||
|
return join(mediaDir(projectDir), MANIFEST_FILE);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function typeDirPath(projectDir: string, type: FigmaManifestRecord["type"]): string {
|
||||||
|
return join(mediaDir(projectDir), TYPE_DIRS[type]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Guards figma-OWNED rows only — media-use rows (bgm/sfx/icon, no
|
||||||
|
* provenance.source) legitimately fail this and are filtered out of
|
||||||
|
* figma's read-view. The shared file is the inventory; each writer
|
||||||
|
* reads its own rows. `nextId` is the one cross-writer concern and
|
||||||
|
* scans the full file.
|
||||||
|
*/
|
||||||
|
export function isFigmaManifestRecord(value: unknown): value is FigmaManifestRecord {
|
||||||
|
if (typeof value !== "object" || value === null) return false;
|
||||||
|
if (!("id" in value) || !("type" in value) || !("path" in value) || !("source" in value))
|
||||||
|
return false;
|
||||||
|
if (
|
||||||
|
typeof value.id !== "string" ||
|
||||||
|
(value.type !== "image" && value.type !== "video") ||
|
||||||
|
typeof value.path !== "string"
|
||||||
|
)
|
||||||
|
return false;
|
||||||
|
if (typeof value.source !== "string") return false;
|
||||||
|
if (!("provenance" in value)) return false;
|
||||||
|
|
||||||
|
const provenance = value.provenance;
|
||||||
|
if (typeof provenance !== "object" || provenance === null) return false;
|
||||||
|
if (!("source" in provenance) || !("fileKey" in provenance) || !("nodeId" in provenance))
|
||||||
|
return false;
|
||||||
|
return (
|
||||||
|
provenance.source === "figma" &&
|
||||||
|
typeof provenance.fileKey === "string" &&
|
||||||
|
typeof provenance.nodeId === "string"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function appendRecord(projectDir: string, record: FigmaManifestRecord): void {
|
||||||
|
mkdirSync(typeDirPath(projectDir, record.type), { recursive: true });
|
||||||
|
appendFileSync(manifestPath(projectDir), JSON.stringify(record) + "\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function findByFigmaNode(
|
||||||
|
projectDir: string,
|
||||||
|
fileKey: string,
|
||||||
|
nodeId: string,
|
||||||
|
): FigmaManifestRecord | null {
|
||||||
|
for (const r of readManifest(projectDir)) {
|
||||||
|
if (
|
||||||
|
r.provenance.source === "figma" &&
|
||||||
|
r.provenance.fileKey === fileKey &&
|
||||||
|
r.provenance.nodeId === nodeId
|
||||||
|
)
|
||||||
|
return r;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function nextId(projectDir: string, type: FigmaManifestRecord["type"]): string {
|
||||||
|
const re = new RegExp(`^${type}_(\\d+)$`);
|
||||||
|
let max = 0;
|
||||||
|
// Scan EVERY writer's rows (media-use included), not just figma-owned ones —
|
||||||
|
// ids and file paths are shared across the inventory, so a figma id minted
|
||||||
|
// from a figma-only view would collide with media-use's image_NNN files.
|
||||||
|
const p = manifestPath(projectDir);
|
||||||
|
if (!existsSync(p)) return `${type}_001`;
|
||||||
|
for (const line of readFileSync(p, "utf8").split(/\r?\n/)) {
|
||||||
|
const trimmed = line.trim();
|
||||||
|
if (trimmed.length === 0) continue;
|
||||||
|
let parsed: unknown;
|
||||||
|
try {
|
||||||
|
parsed = JSON.parse(trimmed);
|
||||||
|
} catch {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
typeof parsed !== "object" ||
|
||||||
|
parsed === null ||
|
||||||
|
!("id" in parsed) ||
|
||||||
|
typeof parsed.id !== "string"
|
||||||
|
)
|
||||||
|
continue;
|
||||||
|
const m = parsed.id.match(re);
|
||||||
|
const n = m?.[1];
|
||||||
|
if (n !== undefined) max = Math.max(max, Number.parseInt(n, 10));
|
||||||
|
}
|
||||||
|
return `${type}_${String(max + 1).padStart(3, "0")}`;
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
// @vitest-environment node
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { parseFigmaRef } from "./parseFigmaRef";
|
||||||
|
|
||||||
|
describe("parseFigmaRef", () => {
|
||||||
|
it("extracts fileKey + nodeId from a /design/ URL and converts node-id dashes to colons", () => {
|
||||||
|
const ref = parseFigmaRef(
|
||||||
|
"https://www.figma.com/design/JjiZQGiUKqbkPCs3sviEUF/Playground?node-id=92-573&t=x",
|
||||||
|
);
|
||||||
|
expect(ref.fileKey).toBe("JjiZQGiUKqbkPCs3sviEUF");
|
||||||
|
expect(ref.nodeId).toBe("92:573");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles /file/ URLs without a node-id", () => {
|
||||||
|
const ref = parseFigmaRef("https://figma.com/file/ABC123/Name");
|
||||||
|
expect(ref.fileKey).toBe("ABC123");
|
||||||
|
expect(ref.nodeId).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("accepts fileKey:nodeId shorthand", () => {
|
||||||
|
expect(parseFigmaRef("ABC123:1-2")).toEqual({ fileKey: "ABC123", nodeId: "1:2" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("accepts a bare fileKey", () => {
|
||||||
|
expect(parseFigmaRef("ABC123")).toEqual({ fileKey: "ABC123" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("throws on empty input", () => {
|
||||||
|
expect(() => parseFigmaRef(" ")).toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import type { FigmaRef } from "./types";
|
||||||
|
|
||||||
|
const FILE_KEY_RE = /\/(?:design|file|proto)\/([A-Za-z0-9]+)/;
|
||||||
|
|
||||||
|
function normalizeNodeId(raw: string): string {
|
||||||
|
return raw.replace("-", ":");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseFigmaRef(input: string): FigmaRef {
|
||||||
|
const trimmed = input.trim();
|
||||||
|
if (trimmed.length === 0) throw new Error("parseFigmaRef: empty input");
|
||||||
|
|
||||||
|
if (!trimmed.includes("/")) {
|
||||||
|
const colon = trimmed.indexOf(":");
|
||||||
|
if (colon === -1) return { fileKey: trimmed };
|
||||||
|
const fileKey = trimmed.slice(0, colon);
|
||||||
|
const node = trimmed.slice(colon + 1);
|
||||||
|
if (fileKey.length === 0) throw new Error(`parseFigmaRef: invalid ref "${input}"`);
|
||||||
|
return node.length > 0 ? { fileKey, nodeId: normalizeNodeId(node) } : { fileKey };
|
||||||
|
}
|
||||||
|
|
||||||
|
const keyMatch = trimmed.match(FILE_KEY_RE);
|
||||||
|
const fileKey = keyMatch?.[1];
|
||||||
|
if (fileKey === undefined) throw new Error(`parseFigmaRef: no fileKey in "${input}"`);
|
||||||
|
|
||||||
|
const q = trimmed.indexOf("?");
|
||||||
|
if (q !== -1) {
|
||||||
|
const raw = new URLSearchParams(trimmed.slice(q + 1)).get("node-id");
|
||||||
|
if (raw !== null && raw.length > 0) return { fileKey, nodeId: normalizeNodeId(raw) };
|
||||||
|
}
|
||||||
|
return { fileKey };
|
||||||
|
}
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
export interface FigmaRef {
|
||||||
|
fileKey: string;
|
||||||
|
nodeId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type FigmaAssetFormat = "png" | "svg" | "jpg" | "pdf";
|
||||||
|
|
||||||
|
export interface FigmaProvenance {
|
||||||
|
source: "figma";
|
||||||
|
fileKey: string;
|
||||||
|
nodeId: string;
|
||||||
|
version?: string;
|
||||||
|
format?: FigmaAssetFormat;
|
||||||
|
scale?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FigmaManifestRecord {
|
||||||
|
id: string;
|
||||||
|
type: "image" | "video";
|
||||||
|
path: string;
|
||||||
|
source: string;
|
||||||
|
description?: string;
|
||||||
|
width?: number;
|
||||||
|
height?: number;
|
||||||
|
provenance: FigmaProvenance;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AssetSnippet {
|
||||||
|
path: string;
|
||||||
|
html: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A motion.dev easing value as returned by get_motion_context: a named ease
|
||||||
|
* (e.g. "linear") or a cubic-bezier control-point array [x1,y1,x2,y2]. */
|
||||||
|
export type MotionEase = string | [number, number, number, number];
|
||||||
|
|
||||||
|
/** One animated property, normalized from get_motion_context's motion.dev snippet. */
|
||||||
|
export interface MotionTrack {
|
||||||
|
/** motion.dev property name: "opacity" | "x" | "y" | "scaleX" | "scaleY" | "rotation" | ... */
|
||||||
|
property: string;
|
||||||
|
values: Array<number | string>;
|
||||||
|
/** normalized 0..1, same length as values */
|
||||||
|
times: number[];
|
||||||
|
/** length values.length - 1; ease[i] governs the segment values[i] -> values[i+1] */
|
||||||
|
ease: MotionEase[];
|
||||||
|
/** seconds */
|
||||||
|
duration: number;
|
||||||
|
/** Infinity or a finite count; clamped to finite during translation */
|
||||||
|
repeat?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MotionDoc {
|
||||||
|
/** string-literal CSS selector for the target element, e.g. "#hero-title" */
|
||||||
|
selector: string;
|
||||||
|
tracks: MotionTrack[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export type MappedEase =
|
||||||
|
| { kind: "named"; ease: string }
|
||||||
|
| { kind: "bezier"; bezier: [number, number, number, number] };
|
||||||
|
|
||||||
|
export interface CustomEaseRef {
|
||||||
|
name: string;
|
||||||
|
bezier: [number, number, number, number];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GsapKeyframeStep {
|
||||||
|
value: number | string;
|
||||||
|
/** seconds */
|
||||||
|
duration: number;
|
||||||
|
/** GSAP ease string or a registered CustomEase name */
|
||||||
|
ease: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GsapTween {
|
||||||
|
selector: string;
|
||||||
|
property: string;
|
||||||
|
initial: number | string;
|
||||||
|
steps: GsapKeyframeStep[];
|
||||||
|
/** finite; 0 = play once */
|
||||||
|
repeat: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TimelineSpec {
|
||||||
|
timelineId: string;
|
||||||
|
tweens: GsapTween[];
|
||||||
|
customEases: CustomEaseRef[];
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user