mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-04 07:19:52 +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,82 @@
|
||||
// @vitest-environment node
|
||||
import { describe, expect, it, afterEach, beforeEach } from "vitest";
|
||||
import { appendFileSync, mkdtempSync, rmSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import {
|
||||
appendBinding,
|
||||
upsertBindings,
|
||||
findBindingByFigmaId,
|
||||
readBindings,
|
||||
readLibraryMap,
|
||||
recordLibraryFile,
|
||||
type FigmaBindingRecord,
|
||||
} from "./bindings";
|
||||
|
||||
let dir = "";
|
||||
beforeEach(() => {
|
||||
dir = mkdtempSync(join(tmpdir(), "hf-bindings-"));
|
||||
});
|
||||
afterEach(() => rmSync(dir, { recursive: true, force: true }));
|
||||
|
||||
const REC: FigmaBindingRecord = {
|
||||
kind: "binding",
|
||||
figmaId: "VariableID:1:23",
|
||||
key: "abc123",
|
||||
sourceFileKey: "FILE",
|
||||
compositionVariableId: "figma:Blue/500",
|
||||
version: "7",
|
||||
};
|
||||
|
||||
describe("bindings index", () => {
|
||||
it("round-trips binding records through .media/figma-bindings.jsonl", () => {
|
||||
expect(readBindings(dir)).toEqual([]);
|
||||
appendBinding(dir, REC);
|
||||
appendBinding(dir, {
|
||||
...REC,
|
||||
figmaId: "VariableID:1:24",
|
||||
compositionVariableId: "figma:Red/500",
|
||||
});
|
||||
const all = readBindings(dir);
|
||||
expect(all).toHaveLength(2);
|
||||
expect(all[0]?.compositionVariableId).toBe("figma:Blue/500");
|
||||
});
|
||||
|
||||
it("findBindingByFigmaId matches exact ids only — never values or names", () => {
|
||||
appendBinding(dir, REC);
|
||||
expect(findBindingByFigmaId(dir, "VariableID:1:23")?.key).toBe("abc123");
|
||||
expect(findBindingByFigmaId(dir, "VariableID:1:99")).toBeNull();
|
||||
expect(findBindingByFigmaId(dir, "Blue/500")).toBeNull();
|
||||
});
|
||||
|
||||
it("matches alias-chain members too (semantic id bound, primitive in chain)", () => {
|
||||
appendBinding(dir, { ...REC, aliasChain: ["VariableID:9:1", "VariableID:1:23"] });
|
||||
expect(findBindingByFigmaId(dir, "VariableID:9:1")?.compositionVariableId).toBe(
|
||||
"figma:Blue/500",
|
||||
);
|
||||
});
|
||||
|
||||
it("persists answered library-file mappings (asked once per project)", () => {
|
||||
expect(readLibraryMap(dir)).toEqual({});
|
||||
recordLibraryFile(dir, "libkey-1", "LIBFILE");
|
||||
expect(readLibraryMap(dir)).toEqual({ "libkey-1": "LIBFILE" });
|
||||
});
|
||||
|
||||
it("skips malformed lines instead of crashing", () => {
|
||||
appendBinding(dir, REC);
|
||||
appendFileSync(join(dir, ".media", "figma-bindings.jsonl"), "not json\n");
|
||||
expect(readBindings(dir)).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("upsert replaces stale rows for re-imported figmaIds, keeps others + library rows", () => {
|
||||
appendBinding(dir, REC);
|
||||
appendBinding(dir, { ...REC, figmaId: "VariableID:2:2", compositionVariableId: "figma:Red" });
|
||||
recordLibraryFile(dir, "libkey-2", "LIB2");
|
||||
upsertBindings(dir, [{ ...REC, compositionVariableId: "figma:Blue/500-v2", version: "9" }]);
|
||||
const all = readBindings(dir);
|
||||
expect(all).toHaveLength(2);
|
||||
expect(findBindingByFigmaId(dir, REC.figmaId)?.compositionVariableId).toBe("figma:Blue/500-v2");
|
||||
expect(findBindingByFigmaId(dir, "VariableID:2:2")?.compositionVariableId).toBe("figma:Red");
|
||||
expect(readLibraryMap(dir)["libkey-2"]).toBe("LIB2");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,124 @@
|
||||
/**
|
||||
* Binding index — the machine-readable join between figma variable/style
|
||||
* identities and composition variables (design spec §7.1).
|
||||
*
|
||||
* Lives at .media/figma-bindings.jsonl, next to (but separate from) the
|
||||
* human-readable figma-tokens.json sidecar. Resolution is exact-ID only:
|
||||
* a missed link bakes a correct literal; a wrong link silently changes
|
||||
* color at the next brand refresh. Never match by value or name.
|
||||
*/
|
||||
|
||||
import { appendFileSync, mkdirSync, writeFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { readJsonlValues } from "./jsonl";
|
||||
import { mediaDir } from "./manifest";
|
||||
|
||||
const BINDINGS_FILE = "figma-bindings.jsonl";
|
||||
|
||||
export interface FigmaBindingRecord {
|
||||
kind: "binding";
|
||||
/** the figma variable/style id as it appears in node data (exact match key) */
|
||||
figmaId: string;
|
||||
/** stable cross-file identity, when known ("id and key are stable over the lifetime") */
|
||||
key?: string;
|
||||
sourceFileKey: string;
|
||||
/** semantic→primitive alias chain, directly-bound id first */
|
||||
aliasChain?: string[];
|
||||
compositionVariableId: string;
|
||||
brandRole?: string;
|
||||
/** figma file version at import time — staleness check */
|
||||
version: string;
|
||||
}
|
||||
|
||||
interface LibraryRecord {
|
||||
kind: "library";
|
||||
libraryKey: string;
|
||||
fileKey: string;
|
||||
}
|
||||
|
||||
function bindingsPath(projectDir: string): string {
|
||||
return join(mediaDir(projectDir), BINDINGS_FILE);
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null;
|
||||
}
|
||||
|
||||
function isBindingRecord(value: unknown): value is FigmaBindingRecord {
|
||||
return (
|
||||
isRecord(value) &&
|
||||
value.kind === "binding" &&
|
||||
typeof value.figmaId === "string" &&
|
||||
typeof value.sourceFileKey === "string" &&
|
||||
typeof value.compositionVariableId === "string" &&
|
||||
typeof value.version === "string"
|
||||
);
|
||||
}
|
||||
|
||||
function isLibraryRecord(value: unknown): value is LibraryRecord {
|
||||
return (
|
||||
isRecord(value) &&
|
||||
value.kind === "library" &&
|
||||
typeof value.libraryKey === "string" &&
|
||||
typeof value.fileKey === "string"
|
||||
);
|
||||
}
|
||||
|
||||
function readLines(projectDir: string): unknown[] {
|
||||
return readJsonlValues(bindingsPath(projectDir));
|
||||
}
|
||||
|
||||
function appendLine(projectDir: string, record: unknown): void {
|
||||
mkdirSync(mediaDir(projectDir), { recursive: true });
|
||||
appendFileSync(bindingsPath(projectDir), JSON.stringify(record) + "\n");
|
||||
}
|
||||
|
||||
export function readBindings(projectDir: string): FigmaBindingRecord[] {
|
||||
return readLines(projectDir).filter(isBindingRecord);
|
||||
}
|
||||
|
||||
export function appendBinding(projectDir: string, record: FigmaBindingRecord): void {
|
||||
appendLine(projectDir, record);
|
||||
}
|
||||
|
||||
/**
|
||||
* Upsert by figmaId: re-running a tokens import must REPLACE that file's
|
||||
* stale binding rows, not append duplicates — `findBindingByFigmaId`
|
||||
* returns the first match, so appended duplicates would pin lookups to the
|
||||
* stale record forever. Library rows and other files' bindings survive.
|
||||
*/
|
||||
export function upsertBindings(projectDir: string, records: FigmaBindingRecord[]): void {
|
||||
const incoming = new Set(records.map((r) => r.figmaId));
|
||||
const survivors = readLines(projectDir).filter(
|
||||
(line) => !(isBindingRecord(line) && incoming.has(line.figmaId)),
|
||||
);
|
||||
mkdirSync(mediaDir(projectDir), { recursive: true });
|
||||
const lines = [...survivors, ...records].map((r) => JSON.stringify(r)).join("\n");
|
||||
writeFileSync(bindingsPath(projectDir), lines.length > 0 ? lines + "\n" : "");
|
||||
}
|
||||
|
||||
/** Exact-ID lookup, checking alias chains too. Never value/name matching. */
|
||||
export function findBindingByFigmaId(
|
||||
projectDir: string,
|
||||
figmaId: string,
|
||||
): FigmaBindingRecord | null {
|
||||
for (const b of readBindings(projectDir)) {
|
||||
if (b.figmaId === figmaId) return b;
|
||||
if (b.aliasChain?.includes(figmaId)) return b;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Answered "which file is this library?" mappings — asked once per project. */
|
||||
export function readLibraryMap(projectDir: string): Record<string, string> {
|
||||
const map: Record<string, string> = {};
|
||||
for (const r of readLines(projectDir)) {
|
||||
if (isLibraryRecord(r)) map[r.libraryKey] = r.fileKey;
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
export function recordLibraryFile(projectDir: string, libraryKey: string, fileKey: string): void {
|
||||
const record: LibraryRecord = { kind: "library", libraryKey, fileKey };
|
||||
appendLine(projectDir, record);
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
// @vitest-environment node
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { createFigmaClient, FigmaClientError, type FigmaFetch } from "./client";
|
||||
|
||||
function jsonResponse(status: number, body: unknown): Response {
|
||||
return new Response(JSON.stringify(body), {
|
||||
status,
|
||||
headers: { "content-type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
function fetchStub(handler: (url: string) => Response): { fetch: FigmaFetch; calls: string[] } {
|
||||
const calls: string[] = [];
|
||||
const fetch: FigmaFetch = (url, init) => {
|
||||
calls.push(`${url}|${JSON.stringify(init?.headers ?? {})}`);
|
||||
return Promise.resolve(handler(url));
|
||||
};
|
||||
return { fetch, calls };
|
||||
}
|
||||
|
||||
describe("createFigmaClient", () => {
|
||||
it("throws NO_TOKEN when token is missing or blank", () => {
|
||||
expect(() => createFigmaClient({ token: "" })).toThrowError(
|
||||
expect.objectContaining({ code: "NO_TOKEN" }),
|
||||
);
|
||||
expect(() => createFigmaClient({ token: " " })).toThrowError(
|
||||
expect.objectContaining({ code: "NO_TOKEN" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("sends the token as X-Figma-Token on every request", async () => {
|
||||
const { fetch, calls } = fetchStub(() =>
|
||||
jsonResponse(200, { images: { "1:2": "https://cdn.example/a.png" } }),
|
||||
);
|
||||
const client = createFigmaClient({ token: "tok-1", fetch });
|
||||
await client.renderNode({ fileKey: "F", nodeId: "1:2" }, { format: "png" });
|
||||
expect(calls[0]).toContain('"X-Figma-Token":"tok-1"');
|
||||
});
|
||||
});
|
||||
|
||||
describe("renderNode", () => {
|
||||
it("calls /v1/images/:key with ids/format/scale and returns the render url", async () => {
|
||||
const { fetch, calls } = fetchStub(() =>
|
||||
jsonResponse(200, { images: { "1:2": "https://cdn.example/a.png" } }),
|
||||
);
|
||||
const client = createFigmaClient({ token: "t", fetch });
|
||||
const out = await client.renderNode(
|
||||
{ fileKey: "FILE", nodeId: "1:2" },
|
||||
{ format: "png", scale: 2 },
|
||||
);
|
||||
expect(out.url).toBe("https://cdn.example/a.png");
|
||||
expect(out.ext).toBe("png");
|
||||
expect(calls[0]).toContain("/v1/images/FILE?ids=1%3A2&format=png&scale=2");
|
||||
});
|
||||
|
||||
it("throws when the ref has no nodeId", async () => {
|
||||
const client = createFigmaClient({
|
||||
token: "t",
|
||||
fetch: fetchStub(() => jsonResponse(200, {})).fetch,
|
||||
});
|
||||
await expect(client.renderNode({ fileKey: "F" }, { format: "png" })).rejects.toThrowError(
|
||||
/nodeId/,
|
||||
);
|
||||
});
|
||||
|
||||
it("throws RENDER_FAILED when figma returns a null render", async () => {
|
||||
const { fetch } = fetchStub(() => jsonResponse(200, { images: { "1:2": null } }));
|
||||
const client = createFigmaClient({ token: "t", fetch });
|
||||
await expect(
|
||||
client.renderNode({ fileKey: "F", nodeId: "1:2" }, { format: "svg" }),
|
||||
).rejects.toThrowError(expect.objectContaining({ code: "RENDER_FAILED" }));
|
||||
});
|
||||
});
|
||||
|
||||
describe("imageFills", () => {
|
||||
it("returns the imageRef->url map from /v1/files/:key/images", async () => {
|
||||
const { fetch } = fetchStub(() =>
|
||||
jsonResponse(200, { meta: { images: { refA: "https://cdn/x" } } }),
|
||||
);
|
||||
const client = createFigmaClient({ token: "t", fetch });
|
||||
const fills = await client.imageFills("FILE");
|
||||
expect(fills.get("refA")).toBe("https://cdn/x");
|
||||
});
|
||||
});
|
||||
|
||||
describe("variables", () => {
|
||||
it("maps HTTP 403 to REQUIRES_ENTERPRISE", async () => {
|
||||
const { fetch } = fetchStub(() => jsonResponse(403, { message: "nope" }));
|
||||
const client = createFigmaClient({ token: "t", fetch });
|
||||
await expect(client.variables("FILE")).rejects.toThrowError(
|
||||
expect.objectContaining({ code: "REQUIRES_ENTERPRISE" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("returns meta payload on success", async () => {
|
||||
const { fetch, calls } = fetchStub(() =>
|
||||
jsonResponse(200, {
|
||||
meta: { variables: { "VariableID:1:2": { name: "Blue/500" } }, variableCollections: {} },
|
||||
}),
|
||||
);
|
||||
const client = createFigmaClient({ token: "t", fetch });
|
||||
const out = await client.variables("FILE");
|
||||
expect(out.variables["VariableID:1:2"]?.name).toBe("Blue/500");
|
||||
expect(calls[0]).toContain("/v1/files/FILE/variables/local");
|
||||
});
|
||||
});
|
||||
|
||||
describe("error mapping", () => {
|
||||
it("maps 429 to RATE_LIMITED and 401 to BAD_TOKEN", async () => {
|
||||
const c429 = createFigmaClient({
|
||||
token: "t",
|
||||
fetch: fetchStub(() => jsonResponse(429, {})).fetch,
|
||||
});
|
||||
await expect(c429.styles("F")).rejects.toThrowError(
|
||||
expect.objectContaining({ code: "RATE_LIMITED" }),
|
||||
);
|
||||
const c401 = createFigmaClient({
|
||||
token: "t",
|
||||
fetch: fetchStub(() => jsonResponse(401, {})).fetch,
|
||||
});
|
||||
await expect(c401.styles("F")).rejects.toThrowError(
|
||||
expect.objectContaining({ code: "BAD_TOKEN" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("wraps other failures as HTTP_ERROR with status", async () => {
|
||||
const client = createFigmaClient({
|
||||
token: "t",
|
||||
fetch: fetchStub(() => jsonResponse(500, {})).fetch,
|
||||
});
|
||||
const err = await client.nodeTree({ fileKey: "F", nodeId: "1:2" }).catch((e: unknown) => e);
|
||||
expect(err).toBeInstanceOf(FigmaClientError);
|
||||
if (err instanceof FigmaClientError) {
|
||||
expect(err.code).toBe("HTTP_ERROR");
|
||||
expect(err.status).toBe(500);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("nodeTree", () => {
|
||||
it("requests geometry=paths and returns the node document", async () => {
|
||||
const { fetch, calls } = fetchStub(() =>
|
||||
jsonResponse(200, {
|
||||
nodes: { "1:2": { document: { id: "1:2", name: "Hero", type: "FRAME" } } },
|
||||
}),
|
||||
);
|
||||
const client = createFigmaClient({ token: "t", fetch });
|
||||
const node = await client.nodeTree({ fileKey: "F", nodeId: "1:2" });
|
||||
expect(node.name).toBe("Hero");
|
||||
expect(calls[0]).toContain("/v1/files/F/nodes?ids=1%3A2&geometry=paths");
|
||||
});
|
||||
|
||||
it("throws NODE_NOT_FOUND when the id is absent", async () => {
|
||||
const { fetch } = fetchStub(() => jsonResponse(200, { nodes: {} }));
|
||||
const client = createFigmaClient({ token: "t", fetch });
|
||||
await expect(client.nodeTree({ fileKey: "F", nodeId: "9:9" })).rejects.toThrowError(
|
||||
expect.objectContaining({ code: "NODE_NOT_FOUND" }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("styles", () => {
|
||||
it("returns published styles list", async () => {
|
||||
const { fetch } = fetchStub(() =>
|
||||
jsonResponse(200, {
|
||||
meta: { styles: [{ key: "k1", name: "Primary", style_type: "FILL" }] },
|
||||
}),
|
||||
);
|
||||
const client = createFigmaClient({ token: "t", fetch });
|
||||
const styles = await client.styles("F");
|
||||
expect(styles[0]?.key).toBe("k1");
|
||||
});
|
||||
});
|
||||
|
||||
describe("fileVersion", () => {
|
||||
it("returns version + lastModified from file metadata", async () => {
|
||||
const { fetch, calls } = fetchStub(() =>
|
||||
jsonResponse(200, { version: "42", lastModified: "2026-07-01T00:00:00Z" }),
|
||||
);
|
||||
const client = createFigmaClient({ token: "t", fetch });
|
||||
const meta = await client.fileVersion("F");
|
||||
expect(meta.version).toBe("42");
|
||||
expect(calls[0]).toContain("/v1/files/F?depth=1");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,233 @@
|
||||
import type { FigmaAssetFormat, FigmaRef } from "./types";
|
||||
|
||||
/** Typed capability/transport failures per design spec §4.4. */
|
||||
export type FigmaClientErrorCode =
|
||||
| "NO_TOKEN"
|
||||
| "BAD_TOKEN"
|
||||
| "REQUIRES_ENTERPRISE"
|
||||
| "RATE_LIMITED"
|
||||
| "RENDER_FAILED"
|
||||
| "NODE_NOT_FOUND"
|
||||
| "HTTP_ERROR";
|
||||
|
||||
export class FigmaClientError extends Error {
|
||||
readonly code: FigmaClientErrorCode;
|
||||
readonly status?: number;
|
||||
|
||||
constructor(code: FigmaClientErrorCode, message: string, status?: number) {
|
||||
super(message);
|
||||
this.name = "FigmaClientError";
|
||||
this.code = code;
|
||||
this.status = status;
|
||||
}
|
||||
}
|
||||
|
||||
/** Injectable fetch so tests never touch the network. */
|
||||
export type FigmaFetch = (
|
||||
url: string,
|
||||
init?: { headers?: Record<string, string> },
|
||||
) => Promise<Response>;
|
||||
|
||||
export interface RenderNodeOptions {
|
||||
format: FigmaAssetFormat;
|
||||
scale?: number;
|
||||
}
|
||||
|
||||
export interface RenderedNode {
|
||||
/** short-lived figma CDN url — freeze it immediately */
|
||||
url: string;
|
||||
ext: FigmaAssetFormat;
|
||||
}
|
||||
|
||||
export interface FigmaVariablePayload {
|
||||
name: string;
|
||||
key?: string;
|
||||
resolvedType?: string;
|
||||
valuesByMode?: Record<string, unknown>;
|
||||
variableCollectionId?: string;
|
||||
}
|
||||
|
||||
export interface FigmaVariablesResult {
|
||||
variables: Record<string, FigmaVariablePayload>;
|
||||
variableCollections: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface FigmaStyleMeta {
|
||||
key: string;
|
||||
name: string;
|
||||
style_type: string;
|
||||
node_id?: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
/** Raw figma node document from GET /v1/files/:key/nodes. Field-level shape
|
||||
* is consumed by nodeToHtml; kept loose here on purpose — consumers narrow
|
||||
* children/fills/etc themselves. */
|
||||
export interface FigmaNodeDocument {
|
||||
id: string;
|
||||
name: string;
|
||||
type: string;
|
||||
[field: string]: unknown;
|
||||
}
|
||||
|
||||
export interface FigmaFileVersion {
|
||||
version: string;
|
||||
lastModified: string;
|
||||
}
|
||||
|
||||
export interface FigmaClient {
|
||||
renderNode(ref: FigmaRef, opts: RenderNodeOptions): Promise<RenderedNode>;
|
||||
imageFills(fileKey: string): Promise<Map<string, string>>;
|
||||
variables(fileKey: string): Promise<FigmaVariablesResult>;
|
||||
styles(fileKey: string): Promise<FigmaStyleMeta[]>;
|
||||
nodeTree(ref: FigmaRef): Promise<FigmaNodeDocument>;
|
||||
fileVersion(fileKey: string): Promise<FigmaFileVersion>;
|
||||
}
|
||||
|
||||
export interface FigmaClientOptions {
|
||||
token: string;
|
||||
fetch?: FigmaFetch;
|
||||
baseUrl?: string;
|
||||
}
|
||||
|
||||
function requireNodeId(ref: FigmaRef): string {
|
||||
if (!ref.nodeId) throw new Error(`figma ref ${ref.fileKey} has no nodeId`);
|
||||
return ref.nodeId;
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null;
|
||||
}
|
||||
|
||||
function optionalString(value: unknown): string | undefined {
|
||||
return typeof value === "string" ? value : undefined;
|
||||
}
|
||||
|
||||
function toVariablePayload(payload: unknown): FigmaVariablePayload | null {
|
||||
if (!isRecord(payload) || typeof payload.name !== "string") return null;
|
||||
return {
|
||||
name: payload.name,
|
||||
key: optionalString(payload.key),
|
||||
resolvedType: optionalString(payload.resolvedType),
|
||||
valuesByMode: isRecord(payload.valuesByMode) ? payload.valuesByMode : undefined,
|
||||
variableCollectionId: optionalString(payload.variableCollectionId),
|
||||
};
|
||||
}
|
||||
|
||||
export function createFigmaClient(options: FigmaClientOptions): FigmaClient {
|
||||
const token = options.token.trim();
|
||||
if (token === "") {
|
||||
throw new FigmaClientError(
|
||||
"NO_TOKEN",
|
||||
"FIGMA_TOKEN is missing — mint a personal access token at figma.com/settings and export FIGMA_TOKEN",
|
||||
);
|
||||
}
|
||||
const doFetch: FigmaFetch = options.fetch ?? ((url, init) => fetch(url, init));
|
||||
const base = options.baseUrl ?? "https://api.figma.com";
|
||||
|
||||
async function get(path: string, enterpriseGated = false): Promise<unknown> {
|
||||
const res = await doFetch(`${base}${path}`, {
|
||||
headers: { "X-Figma-Token": token },
|
||||
});
|
||||
if (res.status === 401)
|
||||
throw new FigmaClientError("BAD_TOKEN", "figma rejected the token (401)", 401);
|
||||
if (res.status === 403 && enterpriseGated)
|
||||
throw new FigmaClientError(
|
||||
"REQUIRES_ENTERPRISE",
|
||||
"figma variables require an Enterprise plan (403) — fall back to styles",
|
||||
403,
|
||||
);
|
||||
if (res.status === 429)
|
||||
throw new FigmaClientError(
|
||||
"RATE_LIMITED",
|
||||
"figma rate limit hit (429) — back off and retry",
|
||||
429,
|
||||
);
|
||||
if (!res.ok)
|
||||
throw new FigmaClientError(
|
||||
"HTTP_ERROR",
|
||||
`figma request failed: HTTP ${res.status} ${path}`,
|
||||
res.status,
|
||||
);
|
||||
return res.json();
|
||||
}
|
||||
|
||||
return {
|
||||
async renderNode(ref, opts) {
|
||||
const nodeId = requireNodeId(ref);
|
||||
const params = new URLSearchParams({ ids: nodeId, format: opts.format });
|
||||
if (opts.scale !== undefined) params.set("scale", String(opts.scale));
|
||||
const body = await get(`/v1/images/${ref.fileKey}?${params}`);
|
||||
const images = isRecord(body) && isRecord(body.images) ? body.images : {};
|
||||
const url = images[nodeId];
|
||||
if (typeof url !== "string" || url === "")
|
||||
throw new FigmaClientError(
|
||||
"RENDER_FAILED",
|
||||
`figma could not render node ${nodeId} as ${opts.format}`,
|
||||
);
|
||||
return { url, ext: opts.format };
|
||||
},
|
||||
|
||||
async imageFills(fileKey) {
|
||||
const body = await get(`/v1/files/${fileKey}/images`);
|
||||
const meta = isRecord(body) && isRecord(body.meta) ? body.meta : {};
|
||||
const images = isRecord(meta.images) ? meta.images : {};
|
||||
const out = new Map<string, string>();
|
||||
for (const [ref, url] of Object.entries(images)) {
|
||||
if (typeof url === "string") out.set(ref, url);
|
||||
}
|
||||
return out;
|
||||
},
|
||||
|
||||
async variables(fileKey) {
|
||||
const body = await get(`/v1/files/${fileKey}/variables/local`, true);
|
||||
const meta = isRecord(body) && isRecord(body.meta) ? body.meta : {};
|
||||
const variables = isRecord(meta.variables) ? meta.variables : {};
|
||||
const collections = isRecord(meta.variableCollections) ? meta.variableCollections : {};
|
||||
const typed: Record<string, FigmaVariablePayload> = {};
|
||||
for (const [id, payload] of Object.entries(variables)) {
|
||||
const v = toVariablePayload(payload);
|
||||
if (v) typed[id] = v;
|
||||
}
|
||||
return { variables: typed, variableCollections: collections };
|
||||
},
|
||||
|
||||
async styles(fileKey) {
|
||||
const body = await get(`/v1/files/${fileKey}/styles`);
|
||||
const meta = isRecord(body) && isRecord(body.meta) ? body.meta : {};
|
||||
const styles = Array.isArray(meta.styles) ? meta.styles : [];
|
||||
return styles.filter(
|
||||
(s): s is FigmaStyleMeta =>
|
||||
isRecord(s) &&
|
||||
typeof s.key === "string" &&
|
||||
typeof s.name === "string" &&
|
||||
typeof s.style_type === "string",
|
||||
);
|
||||
},
|
||||
|
||||
async nodeTree(ref) {
|
||||
const nodeId = requireNodeId(ref);
|
||||
const params = new URLSearchParams({ ids: nodeId, geometry: "paths" });
|
||||
const body = await get(`/v1/files/${ref.fileKey}/nodes?${params}`);
|
||||
const nodes = isRecord(body) && isRecord(body.nodes) ? body.nodes : {};
|
||||
const entry = nodes[nodeId];
|
||||
const doc = isRecord(entry) ? entry.document : undefined;
|
||||
if (
|
||||
!isRecord(doc) ||
|
||||
typeof doc.id !== "string" ||
|
||||
typeof doc.name !== "string" ||
|
||||
typeof doc.type !== "string"
|
||||
)
|
||||
throw new FigmaClientError("NODE_NOT_FOUND", `node ${nodeId} not found in ${ref.fileKey}`);
|
||||
return { ...doc, id: doc.id, name: doc.name, type: doc.type };
|
||||
},
|
||||
|
||||
async fileVersion(fileKey) {
|
||||
const body = await get(`/v1/files/${fileKey}?depth=1`);
|
||||
const version = isRecord(body) && typeof body.version === "string" ? body.version : "";
|
||||
const lastModified =
|
||||
isRecord(body) && typeof body.lastModified === "string" ? body.lastModified : "";
|
||||
return { version, lastModified };
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -1,4 +1,18 @@
|
||||
export type * from "./types";
|
||||
export { createFigmaClient, FigmaClientError } from "./client";
|
||||
export type {
|
||||
FigmaClient,
|
||||
FigmaClientErrorCode,
|
||||
FigmaClientOptions,
|
||||
FigmaFetch,
|
||||
FigmaFileVersion,
|
||||
FigmaNodeDocument,
|
||||
FigmaStyleMeta,
|
||||
FigmaVariablePayload,
|
||||
FigmaVariablesResult,
|
||||
RenderedNode,
|
||||
RenderNodeOptions,
|
||||
} from "./client";
|
||||
export { parseFigmaRef } from "./parseFigmaRef";
|
||||
export {
|
||||
MAX_FREEZE_BYTES,
|
||||
@@ -18,6 +32,7 @@ export {
|
||||
nextId,
|
||||
} from "./manifest";
|
||||
export { buildAssetSnippet } from "./assetSnippet";
|
||||
export { sanitizeSvg } from "./sanitizeSvg";
|
||||
export { mapEase } from "./motionEase";
|
||||
export { motionToGsap } from "./motionToGsap";
|
||||
export { emitTimelineScript } from "./emitTimelineScript";
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
import { existsSync, readFileSync } from "node:fs";
|
||||
|
||||
/** Parse a .jsonl file into values, skipping blank/malformed lines. */
|
||||
export function readJsonlValues(path: string): unknown[] {
|
||||
if (!existsSync(path)) return [];
|
||||
const out: unknown[] = [];
|
||||
for (const line of readFileSync(path, "utf8").split(/\r?\n/)) {
|
||||
const trimmed = line.trim();
|
||||
if (trimmed.length === 0) continue;
|
||||
try {
|
||||
out.push(JSON.parse(trimmed));
|
||||
} catch {
|
||||
// ponytail: skip malformed lines (partial write from a crash), but say
|
||||
// so — a silent skip reads as "no record found" downstream.
|
||||
console.warn(`skipping malformed jsonl line in ${path}: ${trimmed.slice(0, 40)}…`);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { appendFileSync, existsSync, mkdirSync, readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { readJsonlValues } from "./jsonl";
|
||||
import type { FigmaManifestRecord } from "./types";
|
||||
|
||||
const MANIFEST_FILE = "manifest.jsonl";
|
||||
@@ -54,20 +55,7 @@ export function isFigmaManifestRecord(value: unknown): value is FigmaManifestRec
|
||||
}
|
||||
|
||||
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;
|
||||
return readJsonlValues(manifestPath(projectDir)).filter(isFigmaManifestRecord);
|
||||
}
|
||||
|
||||
export function appendRecord(projectDir: string, record: FigmaManifestRecord): void {
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
// @vitest-environment node
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { sanitizeSvg } from "./sanitizeSvg";
|
||||
|
||||
describe("sanitizeSvg", () => {
|
||||
it("strips <script> blocks including content", () => {
|
||||
const dirty = `<svg><script>alert(1)</script><rect width="1" height="1"/></svg>`;
|
||||
const clean = sanitizeSvg(dirty);
|
||||
expect(clean).not.toContain("script");
|
||||
expect(clean).toContain("<rect");
|
||||
});
|
||||
|
||||
it("strips foreignObject subtrees", () => {
|
||||
const dirty = `<svg><foreignObject><iframe src="https://evil"/></foreignObject><circle r="2"/></svg>`;
|
||||
const clean = sanitizeSvg(dirty);
|
||||
expect(clean).not.toContain("foreignObject");
|
||||
expect(clean).not.toContain("iframe");
|
||||
expect(clean).toContain("<circle");
|
||||
});
|
||||
|
||||
it("strips on* event handler attributes", () => {
|
||||
const dirty = `<svg onload="evil()"><rect onclick="evil()" width="1"/></svg>`;
|
||||
const clean = sanitizeSvg(dirty);
|
||||
expect(clean).not.toContain("onload");
|
||||
expect(clean).not.toContain("onclick");
|
||||
expect(clean).toContain('width="1"');
|
||||
});
|
||||
|
||||
it("strips javascript: and external http(s) hrefs but keeps local fragments", () => {
|
||||
const dirty = [
|
||||
`<svg>`,
|
||||
`<a href="javascript:evil()"><text>x</text></a>`,
|
||||
`<use xlink:href="https://evil.example/sprite.svg#icon"/>`,
|
||||
`<use href="#localClip"/>`,
|
||||
`</svg>`,
|
||||
].join("");
|
||||
const clean = sanitizeSvg(dirty);
|
||||
expect(clean).not.toContain("javascript:");
|
||||
expect(clean).not.toContain("evil.example");
|
||||
expect(clean).toContain('href="#localClip"');
|
||||
});
|
||||
|
||||
it("keeps data:image hrefs (figma embeds rasters this way)", () => {
|
||||
const dirty = `<svg><image href="data:image/png;base64,AAAA"/></svg>`;
|
||||
expect(sanitizeSvg(dirty)).toContain("data:image/png;base64,AAAA");
|
||||
});
|
||||
|
||||
it("leaves a typical clean figma export untouched apart from whitespace", () => {
|
||||
const clean = `<svg width="10" height="10" viewBox="0 0 10 10" xmlns="http://www.w3.org/2000/svg"><path d="M0 0h10v10H0z" fill="#123456" clip-path="url(#clip0)"/><defs><clipPath id="clip0"><rect width="10" height="10"/></clipPath></defs></svg>`;
|
||||
expect(sanitizeSvg(clean)).toBe(clean);
|
||||
});
|
||||
});
|
||||
|
||||
describe("sanitizeSvg hardening", () => {
|
||||
it("strips nested script/foreignObject without leaving inner content", () => {
|
||||
const dirty = `<svg><foreignObject><foreignObject><iframe/></foreignObject></foreignObject></svg>`;
|
||||
const clean = sanitizeSvg(dirty);
|
||||
expect(clean).not.toContain("foreignObject");
|
||||
expect(clean).not.toContain("iframe");
|
||||
});
|
||||
it("strips <style> blocks (css @import exfil)", () => {
|
||||
const clean = sanitizeSvg(`<svg><style>@import url("https://x/y.css");</style><rect/></svg>`);
|
||||
expect(clean).not.toContain("style");
|
||||
expect(clean).toContain("<rect");
|
||||
});
|
||||
it("strips unquoted on* handlers", () => {
|
||||
expect(sanitizeSvg(`<svg><rect onclick=evil() /></svg>`)).not.toContain("onclick");
|
||||
});
|
||||
it("drops non-image data: hrefs but keeps data:image and #fragments", () => {
|
||||
const dirty = `<svg><a href="data:text/html,<script>1</script>">x</a><use href="#clip"/><image href="data:image/png;base64,AA"/></svg>`;
|
||||
const clean = sanitizeSvg(dirty);
|
||||
expect(clean).not.toContain("data:text/html");
|
||||
expect(clean).toContain('href="#clip"');
|
||||
expect(clean).toContain("data:image/png");
|
||||
});
|
||||
it("drops blob: and protocol-relative hrefs", () => {
|
||||
const clean = sanitizeSvg(`<svg><a href="blob:x">a</a><a href='//evil/x'>b</a></svg>`);
|
||||
expect(clean).not.toContain("blob:");
|
||||
expect(clean).not.toContain("//evil");
|
||||
});
|
||||
});
|
||||
|
||||
describe("sanitizeSvg close-tag variants (js/bad-tag-filter)", () => {
|
||||
it("strips scripts whose close tag carries whitespace or junk before >", () => {
|
||||
const dirty = "<svg><script>evil()</script\t\n bar><rect/></svg>";
|
||||
const clean = sanitizeSvg(dirty);
|
||||
expect(clean).not.toContain("script");
|
||||
expect(clean).not.toContain("evil");
|
||||
expect(clean).toContain("<rect");
|
||||
});
|
||||
it("strips style/foreignObject with attribute-bearing close tags", () => {
|
||||
const dirty = `<svg><style>@import url(x)</style x><foreignObject><iframe/></foreignObject foo="1"></svg>`;
|
||||
const clean = sanitizeSvg(dirty);
|
||||
expect(clean).not.toContain("style");
|
||||
expect(clean).not.toContain("foreignObject");
|
||||
expect(clean).not.toContain("iframe");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,58 @@
|
||||
/**
|
||||
* Sanitize a figma-exported SVG before it touches disk (design spec §5).
|
||||
*
|
||||
* Threat model: figma-SHAPED exports, hardened against the cheap adversarial
|
||||
* variants (nesting, unquoted attrs, scheme smuggling). This is a lexical
|
||||
* pass, not a general-purpose HTML sanitizer — content from arbitrary
|
||||
* untrusted sources should go through a real sanitizer (DOMPurify) instead.
|
||||
* Strips:
|
||||
* - <script> elements (with content) and <style> blocks (@import exfil)
|
||||
* - <foreignObject> subtrees (arbitrary embedded HTML)
|
||||
* - on* event-handler attributes (quoted and unquoted)
|
||||
* - href/xlink:href values unless local fragment (#id) or data:image/
|
||||
*
|
||||
* Keeps local fragment refs (#id) and data:image embeds, which figma uses
|
||||
* for clip paths and embedded rasters.
|
||||
*/
|
||||
|
||||
/** Apply a replacement until the output stops changing (defeats nesting). */
|
||||
function replaceStable(input: string, pattern: RegExp, replacement: string): string {
|
||||
let out = input;
|
||||
let prev;
|
||||
do {
|
||||
prev = out;
|
||||
out = out.replace(pattern, replacement);
|
||||
} while (out !== prev);
|
||||
return out;
|
||||
}
|
||||
|
||||
export function sanitizeSvg(svg: string): string {
|
||||
let out = svg;
|
||||
out = replaceStable(out, /<script\b[\s\S]*?<\/script\b[^>]*>/gi, "");
|
||||
out = replaceStable(out, /<script\b[^>]*\/>/gi, "");
|
||||
out = replaceStable(out, /<style\b[\s\S]*?<\/style\b[^>]*>/gi, "");
|
||||
out = replaceStable(out, /<foreignObject\b[\s\S]*?<\/foreignObject\b[^>]*>/gi, "");
|
||||
out = replaceStable(out, /<foreignObject\b[^>]*\/>/gi, "");
|
||||
// Nesting leaves inert orphan close tags after the stable pass — drop them.
|
||||
out = out.replace(/<\/(?:script|style|foreignObject)\b[^>]*>/gi, "");
|
||||
// on* handler attributes: quoted and unquoted forms.
|
||||
out = replaceStable(out, /\son[a-z]+\s*=\s*"[^"]*"/gi, "");
|
||||
out = replaceStable(out, /\son[a-z]+\s*=\s*'[^']*'/gi, "");
|
||||
out = replaceStable(out, /\son[a-z]+\s*=\s*[^\s>'"]+/gi, "");
|
||||
// href-like attributes: POSITIVE allowlist — keep only local fragments and
|
||||
// data:image embeds; drop everything else (javascript:, https?:, blob:,
|
||||
// vbscript:, data:text/html, protocol-relative, …).
|
||||
// xmlns declarations are attribute *names*, not href values — untouched.
|
||||
out = out.replace(/\s(href|xlink:href)\s*=\s*"([^"]*)"/gi, (m, attr: string, value: string) =>
|
||||
isAllowedHref(value) ? m : "",
|
||||
);
|
||||
out = out.replace(/\s(href|xlink:href)\s*=\s*'([^']*)'/gi, (m, attr: string, value: string) =>
|
||||
isAllowedHref(value) ? m : "",
|
||||
);
|
||||
return out;
|
||||
}
|
||||
|
||||
function isAllowedHref(value: string): boolean {
|
||||
const v = value.trim().toLowerCase();
|
||||
return v.startsWith("#") || v.startsWith("data:image/");
|
||||
}
|
||||
Reference in New Issue
Block a user