Files
hyperframes/packages/core/src/figma/resolveBindings.ts
T
Vance IngallsandClaude Fable 5 ee7a96147a feat(core,cli): figma component import with binding-aware node-to-html mapper (M3) (#1872)
resolveBindings: scan the full tree (boundVariables + style ids, alias
chains, children) and partition exact-ID-only against the binding index
before any CSS is emitted per spec 7.1 - never value matching.

nodeToHtml: absolute geometry at figma bounds inside a fixed-size root,
solid/linear-gradient fills, corner radius, opacity, drop shadow, blur,
text styles; resolved bindings emit var(--slug, literal), unresolved
bake literals with data-figma-unresolved; visible:false respected;
vectors/boolean ops route to a rasterize list.

hyperframes figma component: tree -> bindings -> html, rasterize
fallback via Phase-1 asset export with src backfill, registry-item
packaging, unresolved-binding guidance in output.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 18:19:01 -07:00

98 lines
3.2 KiB
TypeScript

/**
* Binding resolution pass (design spec §7.1): scan a node tree's complete
* binding set — boundVariables and style ids, alias chains honored — and
* partition against the binding index BEFORE any CSS is emitted.
*
* Exact-ID matching only. A missed link bakes a visually-correct literal;
* a wrong link silently changes color at the next brand refresh. Never
* match by value or name.
*
* NOTE: the consumer-side boundVariables shape is probe-flagged in the spec
* (§7.1 pre-build probes). Extraction here is tolerant: single alias objects
* and arrays of alias objects both count; unknown shapes are ignored rather
* than guessed at.
*/
import type { FigmaBindingRecord } from "./bindings";
import type { FigmaNodeDocument } from "./client";
import { childDocuments } from "./nodeDocument";
export interface BindingSite {
nodeId: string;
/** boundVariables property ("fills") or style slot prefixed "style:" ("style:text") */
property: string;
figmaId: string;
}
export interface ResolvedBindingSite extends BindingSite {
compositionVariableId: string;
}
export interface ResolveBindingsResult {
resolved: ResolvedBindingSite[];
unresolved: BindingSite[];
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null;
}
function aliasId(value: unknown): string | null {
if (isRecord(value) && value.type === "VARIABLE_ALIAS" && typeof value.id === "string")
return value.id;
return null;
}
function boundVariableSites(node: FigmaNodeDocument, out: BindingSite[]): void {
const bound = node.boundVariables;
if (!isRecord(bound)) return;
for (const [property, value] of Object.entries(bound)) {
const candidates = Array.isArray(value) ? value : [value];
for (const item of candidates) {
const id = aliasId(item);
if (id) out.push({ nodeId: node.id, property, figmaId: id });
}
}
}
function styleSites(node: FigmaNodeDocument, out: BindingSite[]): void {
const styles = node.styles;
if (!isRecord(styles)) return;
for (const [slot, styleId] of Object.entries(styles)) {
if (typeof styleId === "string" && styleId.length > 0)
out.push({ nodeId: node.id, property: `style:${slot}`, figmaId: styleId });
}
}
function collectSites(node: FigmaNodeDocument, out: BindingSite[]): void {
boundVariableSites(node, out);
styleSites(node, out);
for (const child of childDocuments(node)) collectSites(child, out);
}
function findInIndex(index: FigmaBindingRecord[], figmaId: string): FigmaBindingRecord | null {
for (const b of index) {
if (b.figmaId === figmaId) return b;
if (b.aliasChain?.includes(figmaId)) return b;
if (b.key !== undefined && b.key === figmaId) return b;
}
return null;
}
export function resolveBindings(
node: FigmaNodeDocument,
index: FigmaBindingRecord[],
): ResolveBindingsResult {
const sites: BindingSite[] = [];
collectSites(node, sites);
const resolved: ResolvedBindingSite[] = [];
const unresolved: BindingSite[] = [];
for (const site of sites) {
const match = findInIndex(index, site.figmaId);
if (match) resolved.push({ ...site, compositionVariableId: match.compositionVariableId });
else unresolved.push(site);
}
return { resolved, unresolved };
}