Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 2 additions & 7 deletions packages/cli/src/commands/figma/asset.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import {
} from "@hyperframes/core/figma";
import { existsSync } from "node:fs";
import { join, relative } from "node:path";
import { downloadRender } from "./download.js";

export interface AssetImportOptions {
format: FigmaAssetFormat;
Expand All @@ -41,12 +42,6 @@ export interface AssetImportResult {
reused: boolean;
}

async function defaultDownload(url: string): Promise<Uint8Array> {
const res = await fetch(url);
if (!res.ok) throw new Error(`figma render download failed: HTTP ${res.status}`);
return new Uint8Array(await res.arrayBuffer());
}

export async function runAssetImport(
refInput: string,
opts: AssetImportOptions,
Expand Down Expand Up @@ -137,7 +132,7 @@ export default defineCommand({
format: parseFormat(args.format),
scale: args.scale !== undefined ? Number(args.scale) : undefined,
},
{ projectDir: args.dir, client, download: defaultDownload },
{ projectDir: args.dir, client, download: downloadRender },
);
const verb = result.reused ? "reused" : "imported";
console.log(`${verb} ${result.record.id} → ${result.record.path}`);
Expand Down
97 changes: 97 additions & 0 deletions packages/cli/src/commands/figma/component.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
// @vitest-environment node
import { describe, expect, it, afterEach, beforeEach } from "vitest";
import { mkdtempSync, readFileSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { runComponentImport } from "./component.js";
import { appendBinding, type FigmaClient } from "@hyperframes/core/figma";

let dir = "";
beforeEach(() => {
dir = mkdtempSync(join(tmpdir(), "hf-figma-component-"));
});
afterEach(() => rmSync(dir, { recursive: true, force: true }));

const TREE = {
id: "1:1",
name: "Hero Card",
type: "FRAME",
absoluteBoundingBox: { x: 0, y: 0, width: 400, height: 300 },
fills: [{ type: "SOLID", color: { r: 1, g: 1, b: 1, a: 1 } }],
children: [
{
id: "1:2",
name: "Badge",
type: "RECTANGLE",
absoluteBoundingBox: { x: 20, y: 20, width: 100, height: 40 },
fills: [{ type: "SOLID", color: { r: 0, g: 0.4, b: 1, a: 1 } }],
boundVariables: { fills: [{ type: "VARIABLE_ALIAS", id: "VariableID:1:1" }] },
},
{
id: "1:3",
name: "Mark",
type: "VECTOR",
absoluteBoundingBox: { x: 40, y: 100, width: 64, height: 64 },
},
],
};

const SVG = new TextEncoder().encode("<svg/>");

function client(): FigmaClient {
return {
renderNode: () => Promise.resolve({ url: "https://cdn/x", ext: "svg" }),
imageFills: () => Promise.resolve(new Map()),
variables: () => Promise.resolve({ variables: {}, variableCollections: {} }),
styles: () => Promise.resolve([]),
nodeTree: () => Promise.resolve(TREE),
fileVersion: () => Promise.resolve({ version: "7", lastModified: "2026-07-01" }),
};
}

async function importHero() {
const out = await runComponentImport("FILE:1-1", {
projectDir: dir,
client: client(),
download: () => Promise.resolve(SVG),
});
return { out, html: readFileSync(join(dir, out.htmlPath), "utf8") };
}

describe("runComponentImport", () => {
it("writes component html with resolved var() when the binding index knows the id", async () => {
appendBinding(dir, {
kind: "binding",
figmaId: "VariableID:1:1",
sourceFileKey: "FILE",
compositionVariableId: "figma:Blue/500",
version: "7",
});
const { out, html } = await importHero();
expect(html).toContain("var(--figma-blue-500, #0066FF)");
expect(out.unresolved).toEqual([]);
});

it("bakes literals and reports unresolved bindings when the index is empty", async () => {
const { out, html } = await importHero();
expect(html).toContain("background: #0066FF");
expect(html).not.toContain("var(");
expect(out.unresolved).toHaveLength(1);
expect(out.unresolved[0]?.figmaId).toBe("VariableID:1:1");
});

it("rasterizes vectors into frozen assets, fills img src, writes the registry item", async () => {
const { out, html } = await importHero();
expect(html).toContain('src="../../../.media/images/image_001.svg"');
expect(out.rasterized).toHaveLength(1);
const item = JSON.parse(
readFileSync(
join(dir, "compositions", "components", "hero-card", "registry-item.json"),
"utf8",
),
) as { type: string; files: Array<{ type: string }> };
expect(item.type).toBe("hyperframes:component");
expect(item.files.some((f) => f.type === "hyperframes:snippet")).toBe(true);
expect(out.name).toBe("hero-card");
});
});
138 changes: 138 additions & 0 deletions packages/cli/src/commands/figma/component.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
/**
* `hyperframes figma component <ref>` — Phase 3: node tree → editable HTML
* component with the §7.1 binding pass, per-node rasterize fallback via
* Phase-1 asset import, packaged as a registry item.
*/

import { defineCommand } from "citty";
import {
createFigmaClient,
nodeToHtml,
parseFigmaRef,
readBindings,
resolveBindings,
slugify,
type BindingSite,
type FigmaClient,
type RasterizeRequest,
} from "@hyperframes/core/figma";
import { existsSync, mkdirSync, writeFileSync } from "node:fs";
import { join, relative } from "node:path";

function escapeAttr(value: string): string {
return value
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}
import { runAssetImport } from "./asset.js";
import { downloadRender } from "./download.js";

export interface ComponentImportDeps {
projectDir: string;
client: FigmaClient;
download: (url: string) => Promise<Uint8Array>;
}

export interface ComponentImportResult {
name: string;
htmlPath: string;
unresolved: BindingSite[];
rasterized: RasterizeRequest[];
}

export async function runComponentImport(
refInput: string,
deps: ComponentImportDeps,
): Promise<ComponentImportResult> {
const ref = parseFigmaRef(refInput);
if (!ref.nodeId) throw new Error(`ref "${refInput}" has no node id`);

const tree = await deps.client.nodeTree(ref);
const bindings = resolveBindings(tree, readBindings(deps.projectDir));
const mapped = nodeToHtml(tree, bindings);

const name = slugify(tree.name);
const componentDir = join(deps.projectDir, "compositions", "components", name);
if (existsSync(componentDir))
console.warn(
`component dir compositions/components/${name} already exists — overwriting (rename the figma frame for a separate import)`,
);
mkdirSync(componentDir, { recursive: true });

// Rasterize fallback: export each unmappable node via Phase 1 and point
// the placeholder img at the frozen file (path relative to the component).
// The search key must match the EMITTED (html-escaped) node id, and
// replaceAll covers the same node appearing twice in the tree.
let html = mapped.html;
for (const req of mapped.rasterize) {
const asset = await runAssetImport(
`${ref.fileKey}:${req.nodeId}`,
{ format: "svg" },
{ projectDir: deps.projectDir, client: deps.client, download: deps.download },
);
const srcRel = relative(componentDir, join(deps.projectDir, asset.record.path));
const emittedId = escapeAttr(req.nodeId);
html = html.replaceAll(
`data-figma-rasterize="${emittedId}" `,
`data-figma-rasterize="${emittedId}" src="${escapeAttr(srcRel)}" `,
);
}

const htmlFile = join(componentDir, `${name}.html`);
writeFileSync(htmlFile, html + "\n");

const registryItem = {
name,
type: "hyperframes:component",
description: `Imported from figma ${ref.fileKey}/${ref.nodeId}`,
files: [
{
path: `${name}.html`,
target: `compositions/components/${name}/${name}.html`,
type: "hyperframes:snippet",
},
...mapped.rasterize.map((r) => ({
path: `${r.slug}.svg`,
target: `.media/images/${r.slug}.svg`,
type: "hyperframes:asset",
})),
],
};
writeFileSync(
join(componentDir, "registry-item.json"),
JSON.stringify(registryItem, null, 2) + "\n",
);

return {
name,
htmlPath: relative(deps.projectDir, htmlFile),
unresolved: bindings.unresolved,
rasterized: mapped.rasterize,
};
}

export default defineCommand({
meta: { name: "component", description: "Import a figma frame as an editable HTML component" },
args: {
ref: { type: "positional", description: "figma URL or fileKey:nodeId", required: true },
dir: { type: "string", description: "project directory", default: "." },
},
async run({ args }) {
const client = createFigmaClient({ token: process.env.FIGMA_TOKEN ?? "" });
const result = await runComponentImport(args.ref, {
projectDir: args.dir,
client,
download: downloadRender,
});
console.log(`imported component "${result.name}" → ${result.htmlPath}`);
if (result.rasterized.length > 0)
console.log(`rasterized ${result.rasterized.length} node(s) via asset export`);
if (result.unresolved.length > 0) {
console.log(
`${result.unresolved.length} binding(s) reference tokens not yet imported — colors baked as literals (flagged data-figma-unresolved). Run \`hyperframes figma tokens\` on the source/library file, then re-import to link them.`,
);
}
},
});
15 changes: 15 additions & 0 deletions packages/cli/src/commands/figma/download.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import { exceedsFreezeCap, MAX_FREEZE_BYTES } from "@hyperframes/core/figma";

/** Fetch a short-lived figma CDN render url into bytes. */
export async function downloadRender(url: string): Promise<Uint8Array> {
const res = await fetch(url);
if (!res.ok) throw new Error(`figma render download failed: HTTP ${res.status}`);
// Reject oversized responses before buffering the body — the freeze cap
// alone only fires after the full allocation.
const declared = Number(res.headers.get("content-length") ?? 0);
if (exceedsFreezeCap(declared))
throw new Error(
`figma render download failed: content-length ${declared} exceeds ${MAX_FREEZE_BYTES} cap`,
);
return new Uint8Array(await res.arrayBuffer());
}
21 changes: 21 additions & 0 deletions packages/core/src/figma/color.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null;
}

function toHexByte(channel: number): string {
return Math.round(channel * 255)
.toString(16)
.padStart(2, "0")
.toUpperCase();
}

/** figma {r,g,b,a?} floats (0..1) → #RRGGBB, or rgba() when translucent. */
export function figmaColorToCss(value: unknown, extraOpacity = 1): string | null {
if (!isRecord(value)) return null;
const { r, g, b, a } = value;
if (typeof r !== "number" || typeof g !== "number" || typeof b !== "number") return null;
const alpha = (typeof a === "number" ? a : 1) * extraOpacity;
if (alpha >= 1) return `#${toHexByte(r)}${toHexByte(g)}${toHexByte(b)}`;
const c = (n: number) => Math.round(n * 255);
return `rgba(${c(r)}, ${c(g)}, ${c(b)}, ${Number(alpha.toFixed(4))})`;
}
5 changes: 5 additions & 0 deletions packages/core/src/figma/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,11 @@ export {
recordLibraryFile,
} from "./bindings";
export type { FigmaBindingRecord } from "./bindings";
export { figmaColorToCss } from "./color";
export { resolveBindings } from "./resolveBindings";
export type { BindingSite, ResolvedBindingSite, ResolveBindingsResult } from "./resolveBindings";
export { nodeToHtml, slugify } from "./nodeToHtml";
export type { NodeToHtmlResult, RasterizeRequest } from "./nodeToHtml";
export { tokensToVariables } from "./tokensToVariables";
export type {
CompositionVariableEntry,
Expand Down
29 changes: 29 additions & 0 deletions packages/core/src/figma/nodeDocument.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import type { FigmaNodeDocument } from "./client";

function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null;
}

/** Narrow an unknown child entry to a node document, or null. */
function asNodeDocument(value: unknown): FigmaNodeDocument | null {
if (
isRecord(value) &&
typeof value.id === "string" &&
typeof value.name === "string" &&
typeof value.type === "string"
) {
return { ...value, id: value.id, name: value.name, type: value.type };
}
return null;
}

/** Typed children of a node document (unknown-shaped entries skipped). */
export function childDocuments(node: FigmaNodeDocument): FigmaNodeDocument[] {
if (!Array.isArray(node.children)) return [];
const out: FigmaNodeDocument[] = [];
for (const child of node.children) {
const doc = asNodeDocument(child);
if (doc) out.push(doc);
}
return out;
}
Loading
Loading