mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-11 06:30:03 +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,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 };
|
||||
},
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user