feat(core,cli): figma tokens import with alias-aware binding records (M2) (#1871)

tokensToVariables: variables -> composition brand-variable entries
(COLOR->hex/rgba, FLOAT/STRING/BOOLEAN), alias chains walked cycle-safe
to the leaf value while the binding keeps the semantic id. Sidecar
figma-tokens.json + .media/figma-bindings.jsonl records per spec 7.1.

hyperframes figma tokens: variables path, REQUIRES_ENTERPRISE degrades
to published-styles metadata (values resolve at component time).

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Vance Ingalls
2026-07-03 18:16:48 -07:00
committed by GitHub
co-authored by Claude Fable 5
parent 22942280b6
commit 4d60792adc
6 changed files with 547 additions and 0 deletions
@@ -0,0 +1,74 @@
// @vitest-environment node
import { describe, expect, it, afterEach, beforeEach } from "vitest";
import { mkdtempSync, readFileSync, rmSync, existsSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { runTokensImport } from "./tokens.js";
import { FigmaClientError, type FigmaClient } from "@hyperframes/core/figma";
let dir = "";
beforeEach(() => {
dir = mkdtempSync(join(tmpdir(), "hf-figma-tokens-"));
});
afterEach(() => rmSync(dir, { recursive: true, force: true }));
function client(overrides: Partial<FigmaClient>): FigmaClient {
return {
renderNode: () => Promise.reject(new Error("unused")),
imageFills: () => Promise.resolve(new Map()),
variables: () =>
Promise.resolve({
variables: {
"VariableID:1:1": {
name: "Blue/500",
key: "kblue",
resolvedType: "COLOR",
valuesByMode: { m1: { r: 0, g: 0.4, b: 1, a: 1 } },
},
},
variableCollections: {},
}),
styles: () => Promise.resolve([{ key: "s1", name: "Primary", style_type: "FILL" }]),
nodeTree: () => Promise.reject(new Error("unused")),
fileVersion: () => Promise.resolve({ version: "7", lastModified: "2026-07-01" }),
...overrides,
};
}
describe("runTokensImport", () => {
it("imports variables: entries + sidecar + binding index", async () => {
const out = await runTokensImport("FILE", { projectDir: dir, client: client({}) });
expect(out.entries).toHaveLength(1);
expect(out.mode).toBe("variables");
const sidecar = JSON.parse(readFileSync(join(dir, "figma-tokens.json"), "utf8")) as {
tokens: unknown[];
};
expect(sidecar.tokens).toHaveLength(1);
const bindings = readFileSync(join(dir, ".media", "figma-bindings.jsonl"), "utf8");
expect(bindings).toContain('"figmaId":"VariableID:1:1"');
});
it("falls back to styles metadata when variables are enterprise-gated", async () => {
const gated = client({
variables: () =>
Promise.reject(new FigmaClientError("REQUIRES_ENTERPRISE", "enterprise only", 403)),
});
const out = await runTokensImport("FILE", { projectDir: dir, client: gated });
expect(out.mode).toBe("styles");
expect(out.entries).toEqual([]);
const sidecar = JSON.parse(readFileSync(join(dir, "figma-tokens.json"), "utf8")) as {
tokens: Array<{ name: string; type: string }>;
};
expect(sidecar.tokens[0]).toMatchObject({ name: "Primary", type: "style:FILL" });
});
it("propagates non-enterprise failures", async () => {
const broken = client({
variables: () => Promise.reject(new FigmaClientError("RATE_LIMITED", "429", 429)),
});
await expect(runTokensImport("FILE", { projectDir: dir, client: broken })).rejects.toThrow(
/429/,
);
expect(existsSync(join(dir, "figma-tokens.json"))).toBe(false);
});
});
+93
View File
@@ -0,0 +1,93 @@
/**
* `hyperframes figma tokens <fileKey>` — Phase 2: import figma variables as
* composition brand-variable entries + figma-tokens.json sidecar + binding
* index records. Variables are Enterprise-gated upstream; degrades to a
* styles-metadata listing on REQUIRES_ENTERPRISE (style *values* resolve at
* component-import time, Phase 3).
*/
import { defineCommand } from "citty";
import {
createFigmaClient,
FigmaClientError,
parseFigmaRef,
tokensToVariables,
upsertBindings,
type CompositionVariableEntry,
type FigmaClient,
type FigmaTokensSidecar,
} from "@hyperframes/core/figma";
import { writeFileSync } from "node:fs";
import { join } from "node:path";
export interface TokensImportDeps {
projectDir: string;
client: FigmaClient;
}
export interface TokensImportResult {
mode: "variables" | "styles";
entries: CompositionVariableEntry[];
sidecarPath: string;
}
export async function runTokensImport(
refInput: string,
deps: TokensImportDeps,
): Promise<TokensImportResult> {
const { fileKey } = parseFigmaRef(refInput);
const { version } = await deps.client.fileVersion(fileKey);
const sidecarPath = join(deps.projectDir, "figma-tokens.json");
// Only the variables() call may trigger the styles fallback — a translator
// or write failure must propagate, not silently rerun as a styles import.
let vars = null;
try {
vars = await deps.client.variables(fileKey);
} catch (err) {
if (!(err instanceof FigmaClientError) || err.code !== "REQUIRES_ENTERPRISE") throw err;
}
if (vars !== null) {
const out = tokensToVariables(vars, { fileKey, version });
upsertBindings(deps.projectDir, out.bindings);
writeFileSync(sidecarPath, JSON.stringify(out.sidecar, null, 2) + "\n");
return { mode: "variables", entries: out.entries, sidecarPath };
}
// Styles fallback: metadata only — values resolve at component-import time.
const styles = await deps.client.styles(fileKey);
const sidecar: FigmaTokensSidecar = {
source: { fileKey, version },
tokens: styles.map((s) => ({
name: s.name,
type: `style:${s.style_type}`,
figmaId: s.node_id ?? s.key,
key: s.key,
value: null,
})),
};
writeFileSync(sidecarPath, JSON.stringify(sidecar, null, 2) + "\n");
return { mode: "styles", entries: [], sidecarPath };
}
export default defineCommand({
meta: { name: "tokens", description: "Import figma variables/styles as brand tokens" },
args: {
ref: { type: "positional", description: "figma fileKey or URL", required: true },
dir: { type: "string", description: "project directory", default: "." },
},
async run({ args }) {
const client = createFigmaClient({ token: process.env.FIGMA_TOKEN ?? "" });
const result = await runTokensImport(args.ref, { projectDir: args.dir, client });
if (result.mode === "styles") {
console.log(
"variables are Enterprise-gated on this plan — recorded published style metadata instead",
);
}
console.log(`wrote ${result.sidecarPath} (${result.mode})`);
if (result.entries.length > 0) {
console.log("add to data-composition-variables:");
console.log(JSON.stringify(result.entries, null, 2));
}
},
});