Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
13 changes: 13 additions & 0 deletions packages/cli/src/commands/figma/component.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -153,4 +153,17 @@ describe("runComponentImport", () => {
}),
).rejects.toThrow(/rate limit/);
});

it("honors a name override so variant frames don't slug-collide", async () => {
const out = await runComponentImport("FILE:1-1", {
projectDir: dir,
client: client(),
download: () => Promise.resolve(SVG),
name: "Hero Actions",
});
expect(out.name).toBe("hero-actions");
expect(out.htmlPath).toContain("hero-actions");
const html = readFileSync(join(dir, out.htmlPath), "utf8");
expect(html).toMatch(/^<div id="hero-actions"/);
});
});
136 changes: 87 additions & 49 deletions packages/cli/src/commands/figma/component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import {
slugify,
type BindingSite,
type FigmaClient,
type NodeToHtmlResult,
type RasterizeRequest,
} from "@hyperframes/core/figma";
import { existsSync, mkdirSync, writeFileSync } from "node:fs";
Expand All @@ -27,14 +28,17 @@ function escapeAttr(value: string): string {
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}
import { runAssetImport } from "./asset.js";
import { runAssetImport, type AssetImportResult } from "./asset.js";
import { downloadRender } from "./download.js";
import { withFigmaErrors } from "./cliError.js";

export interface ComponentImportDeps {
projectDir: string;
client: FigmaClient;
download: (url: string) => Promise<Uint8Array>;
/** override the component name — figma variant frames are often all named
* "Platform=Desktop", which would slug-collide across imports */
name?: string;
}

export interface ComponentImportResult {
Expand All @@ -55,62 +59,22 @@ export async function runComponentImport(

const tree = await deps.client.nodeTree(ref);
const bindings = resolveBindings(tree, readBindings(deps.projectDir));
const mapped = nodeToHtml(tree, bindings);
const mapped = nodeToHtml(tree, bindings, { rootName: deps.name });

const name = slugify(tree.name);
const name = slugify(deps.name ?? 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;
const frozenAssets: string[] = [];
const failedRasterize: string[] = [];
for (const req of mapped.rasterize) {
// figma sometimes refuses to render a node (nested instances commonly
// fail as svg) — retry once as png, then skip THIS node and keep the
// import: one unrenderable node must not abort the whole component. The
// placeholder keeps its data-figma-rasterize marker (no src) so the gap
// is visible and hand-fixable.
let asset = null;
for (const format of ["svg", "png"] as const) {
try {
asset = await runAssetImport(
`${ref.fileKey}:${req.nodeId}`,
{ format, description: req.name },
{ projectDir: deps.projectDir, client: deps.client, download: deps.download },
);
break;
} catch (err) {
if (!(err instanceof FigmaClientError) || err.code !== "RENDER_FAILED") throw err;
}
}
if (asset === null) {
failedRasterize.push(req.nodeId);
console.warn(
`could not render node ${req.nodeId} ("${req.name}") as svg or png — leaving its placeholder without src`,
);
continue;
}
frozenAssets.push(asset.record.path);
// src is a URL — always forward slashes, even when relative() yields
// windows separators.
const srcRel = relative(componentDir, join(deps.projectDir, asset.record.path)).replaceAll(
"\\",
"/",
);
const emittedId = escapeAttr(req.nodeId);
html = html.replaceAll(
`data-figma-rasterize="${emittedId}" `,
`data-figma-rasterize="${emittedId}" src="${escapeAttr(srcRel)}" `,
);
}
const { html, frozenAssets, failedRasterize } = await rasterizeFallback(
mapped,
ref.fileKey,
componentDir,
deps,
);

const htmlFile = join(componentDir, `${name}.html`);
writeFileSync(htmlFile, html + "\n");
Expand Down Expand Up @@ -148,10 +112,83 @@ export async function runComponentImport(
};
}

interface RasterizeOutcome {
html: string;
frozenAssets: string[];
failedRasterize: string[];
}

/**
* Rasterize fallback: export each unmappable node via Phase 1 and point the
* placeholder img at the frozen file (path relative to the component). figma
* sometimes refuses to render a node (nested instances commonly fail as svg)
* — retry once as png, then skip THAT node and keep the import: one
* unrenderable node must not abort the whole component. Skipped placeholders
* keep their data-figma-rasterize marker (no src) so the gap is visible.
*/
async function rasterizeFallback(
mapped: NodeToHtmlResult,
fileKey: string,
componentDir: string,
deps: ComponentImportDeps,
): Promise<RasterizeOutcome> {
let html = mapped.html;
const frozenAssets: string[] = [];
const failedRasterize: string[] = [];
for (const req of mapped.rasterize) {
const asset = await renderWithPngRetry(fileKey, req, deps);
if (asset === null) {
failedRasterize.push(req.nodeId);
console.warn(
`could not render node ${req.nodeId} ("${req.name}") as svg or png — leaving its placeholder without src`,
);
continue;
}
frozenAssets.push(asset.record.path);
// src is a URL — always forward slashes, even when relative() yields
// windows separators.
const srcRel = relative(componentDir, join(deps.projectDir, asset.record.path)).replaceAll(
"\\",
"/",
);
// The search key must match the EMITTED (html-escaped) node id, and
// replaceAll covers the same node appearing twice in the tree.
const emittedId = escapeAttr(req.nodeId);
html = html.replaceAll(
`data-figma-rasterize="${emittedId}" `,
`data-figma-rasterize="${emittedId}" src="${escapeAttr(srcRel)}" `,
);
}
return { html, frozenAssets, failedRasterize };
}

async function renderWithPngRetry(
fileKey: string,
req: RasterizeRequest,
deps: ComponentImportDeps,
): Promise<AssetImportResult | null> {
for (const format of ["svg", "png"] as const) {
try {
return await runAssetImport(
`${fileKey}:${req.nodeId}`,
{ format, description: req.name },
{ projectDir: deps.projectDir, client: deps.client, download: deps.download },
);
} catch (err) {
if (!(err instanceof FigmaClientError) || err.code !== "RENDER_FAILED") throw err;
}
}
return null;
}

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 },
name: {
type: "string",
description: "component name override (variant frames often share a name and would collide)",
},
dir: { type: "string", description: "project directory", default: "." },
},
async run({ args }) {
Expand All @@ -162,6 +199,7 @@ export default defineCommand({
projectDir: args.dir,
client,
download: downloadRender,
name: args.name,
});
console.log(`imported component "${result.name}" → ${result.htmlPath}`);
if (result.rasterized.length > 0) {
Expand Down
120 changes: 85 additions & 35 deletions packages/core/src/compiler/htmlBundler.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,7 @@
import { markFlattenedInnerRoot } from "../runtime/flattenedRoot";
export { FLATTENED_INNER_ROOT_STRIP_ATTRS } from "../runtime/flattenedRoot";
import { parseHostVariableValues } from "../runtime/getVariables";
import { cssVariableName } from "../tokenSlug";
import { readFileSync, existsSync } from "fs";
import { join, resolve, relative, dirname, isAbsolute, sep } from "path";
import { CSS_URL_RE, isNonRelativeUrl } from "./assetPaths.js";
Expand Down Expand Up @@ -392,43 +396,9 @@ function assignBundledRuntimeCompositionIds(
return identities;
}

function parseHostVariableValues(host: Element): Record<string, unknown> {
const raw = host.getAttribute("data-variable-values");
if (!raw) return {};
let parsed: unknown;
try {
parsed = JSON.parse(raw);
} catch {
return {};
}
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return {};
return parsed as Record<string, unknown>;
}

export const FLATTENED_INNER_ROOT_STRIP_ATTRS = [
"data-composition-id",
"data-composition-file",
"data-start",
"data-duration",
"data-end",
"data-track-index",
"data-track",
"data-composition-src",
"data-hf-authored-duration",
"data-hf-authored-end",
];

export function prepareFlattenedInnerRoot(innerRoot: Element): Element {
const prepared = innerRoot.cloneNode(true) as Element;
const authoredRootId = prepared.getAttribute("id")?.trim();
for (const attrName of FLATTENED_INNER_ROOT_STRIP_ATTRS) {
prepared.removeAttribute(attrName);
}
if (authoredRootId) {
prepared.removeAttribute("id");
prepared.setAttribute("data-hf-authored-id", authoredRootId);
}
prepared.setAttribute("data-hf-inner-root", "true");
markFlattenedInnerRoot(prepared);
const w = prepared.getAttribute("data-width");
const h = prepared.getAttribute("data-height");
const widthVal = w ? `${w}px` : "100%";
Expand Down Expand Up @@ -890,6 +860,13 @@ export async function bundleToSingleHtml(
if (runtimeCompId && Object.keys(mergedVariables).length > 0) {
compVariablesByComp[runtimeCompId] = mergedVariables;
}
pushSubCompVariableStyles(
innerDoc,
innerRoot,
mergedVariables,
runtimeScope,
compStyleChunks,
);

if (innerRoot) {
// Hoist styles into the collected style chunks
Expand Down Expand Up @@ -973,6 +950,8 @@ export async function bundleToSingleHtml(
document.body.appendChild(compScript);
}

emitRootCompositionVariableStyles(document);

enforceCompositionPixelSizing(document);
autoHealMissingCompositionIds(document);
coalesceHeadStylesAndBodyScripts(document);
Expand Down Expand Up @@ -1014,3 +993,74 @@ export async function bundleToSingleHtml(

return document.toString();
}

/** One stylesheet rule defining primitive composition variables under `selector`. */
function compositionVariablesCssBlock(
variables: Record<string, unknown>,
selector: string,
): string | null {
const lines: string[] = [];
for (const [id, value] of Object.entries(variables)) {
if ((typeof value === "string" && value !== "") || typeof value === "number") {
lines.push(` ${cssVariableName(id)}: ${String(value)};`);
}
}
if (lines.length === 0) return null;
return `${selector} {\n${lines.join("\n")}\n}`;
}

/**
* Compile-time counterpart of the runtime's injectCompositionCssVariables:
* every element declaring data-composition-variables gets a scoped stylesheet
* rule so var(--slug, literal) references resolve during body parse. The
* runtime injection remains define-if-absent, so it won't double-apply.
*/
function emitRootCompositionVariableStyles(document: Document): void {
const rules: string[] = [];
const htmlDeclared = readDeclaredDefaults(document.documentElement);
const htmlRule = compositionVariablesCssBlock(htmlDeclared, ":root");
if (htmlRule) rules.push(htmlRule);
for (const el of [...document.querySelectorAll("[data-composition-variables]")]) {
const compId = el.getAttribute("data-composition-id");
const elId = el.getAttribute("id");
const selector = compId
? cssAttributeSelector("data-composition-id", compId)
: elId
? `#${elId}`
: null;
if (!selector) continue;
const rule = compositionVariablesCssBlock(readDeclaredDefaults(el), selector);
if (rule) rules.push(rule);
}
if (rules.length === 0) return;
const style = document.createElement("style");
style.setAttribute("data-hf-composition-variables", "");
style.textContent = rules.join("\n\n");
document.head.appendChild(style);
}

/**
* Compile-time CSS custom properties for a sub-comp scope: declared defaults
* layered under per-instance host values, emitted as a stylesheet rule on the
* host selector. A stylesheet in <head> is in effect while the body parses,
* so eval-time reads (GSAP .from immediateRender, canvas tinting) see the
* right values — the runtime's DOMContentLoaded injection is too late for
* those on compiled pages.
*/
function pushSubCompVariableStyles(
innerDoc: Document,
innerRoot: Element | null,
mergedVariables: Record<string, unknown>,
runtimeScope: string,
compStyleChunks: string[],
): void {
if (!runtimeScope) return;
const declaredForCss = readDeclaredDefaults(innerDoc.documentElement);
const innerRootForVars = innerRoot ?? innerDoc.querySelector("[data-composition-variables]");
if (innerRootForVars) Object.assign(declaredForCss, readDeclaredDefaults(innerRootForVars));
const cssVars = compositionVariablesCssBlock(
{ ...declaredForCss, ...mergedVariables },
runtimeScope,
);
if (cssVars) compStyleChunks.push(cssVars);
}
Loading
Loading