Files
hyperframes/packages/core/src/figma/bindings.ts
T
Vance IngallsandClaude Fable 5 fb13797d2f 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>
2026-07-03 18:11:33 -07:00

125 lines
4.2 KiB
TypeScript

/**
* 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);
}