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:
Vance Ingalls
2026-07-03 18:11:33 -07:00
committed by GitHub
co-authored by Claude Fable 5
parent 23f67da5e2
commit fb13797d2f
13 changed files with 1114 additions and 14 deletions
+82
View File
@@ -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");
});
});
+124
View File
@@ -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);
}
+185
View File
@@ -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");
});
});
+233
View File
@@ -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 };
},
};
}
+15
View File
@@ -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";
+19
View File
@@ -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;
}
+2 -14
View File
@@ -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");
});
});
+58
View File
@@ -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/");
}