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:
@@ -203,6 +203,12 @@
|
||||
"import": "./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-item.json": "./schemas/registry-item.json"
|
||||
},
|
||||
@@ -226,6 +232,10 @@
|
||||
"import": "./dist/editing/affordances.js",
|
||||
"types": "./dist/editing/affordances.d.ts"
|
||||
},
|
||||
"./figma": {
|
||||
"import": "./dist/figma/index.js",
|
||||
"types": "./dist/figma/index.d.ts"
|
||||
},
|
||||
"./generators": {
|
||||
"import": "./dist/generators/hyperframes.js",
|
||||
"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