mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-12 07:09:59 +00:00
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:
co-authored by
Claude Fable 5
parent
22942280b6
commit
4d60792adc
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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));
|
||||
}
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user