Files
hyperframes/packages/cli/src/commands/figma/asset.ts
T
Vance Ingalls bd8ee05ae2 fix(cli): label the batch asset-import RENDER_FAILED with the images endpoint
Review feedback on #2358: the batch-miss RENDER_FAILED in
runAssetImportMany (asset.ts) throws the same typed error as
client.ts's single-node renderNode, but wasn't labeled — so
cli_error.endpoint would silently come back undefined for the
flow that most heavily exercises /v1/images.
2026-07-13 21:41:55 +00:00

332 lines
12 KiB
TypeScript

/**
* `hyperframes figma asset <ref>` — Phase 1 of the figma integration:
* render a node over REST, sanitize (svg), freeze under .media/, record
* provenance in the shared manifest, print a composition snippet.
*/
import { defineCommand } from "citty";
import {
appendRecord,
buildAssetSnippet,
createFigmaClient,
FigmaClientError,
findAllByFigmaNode,
freezeBytes,
nextId,
parseFigmaRef,
regenerateIndex,
sanitizeSvg,
typeDirPath,
updateRecord,
type AssetSnippet,
type FigmaAssetFormat,
type FigmaClient,
type FigmaManifestRecord,
} from "@hyperframes/core/figma";
import { existsSync } from "node:fs";
import { join, relative } from "node:path";
import { downloadRender } from "./download.js";
import { withFigmaErrors } from "./cliError.js";
export interface AssetImportOptions {
format: FigmaAssetFormat;
scale?: number;
/** human description — lands in the manifest + index.md + <img alt> */
description?: string;
/** media-use interop: entity name for `resolve --entity` cache hits */
entity?: string;
}
export interface AssetImportDeps {
projectDir: string;
client: FigmaClient;
/** fetch a short-lived figma CDN url into bytes; injectable for tests */
download: (url: string) => Promise<Uint8Array>;
}
export interface AssetImportResult {
record: FigmaManifestRecord;
snippet: AssetSnippet;
reused: boolean;
}
/**
* Flatten CLI positionals into asset refs. Comma-splits bare
* `fileKey:nodeId` tokens (so `asset A,B` batches) but leaves URL tokens
* whole — a figma URL can carry commas in its query (multi-select
* `node-id=1:2,3:4`), and splitting those would tear the URL apart. To batch
* URLs, pass them as separate positional args.
*/
export function gatherAssetRefs(positionals: string[]): string[] {
return positionals
.flatMap((r) => (/^https?:/i.test(r.trim()) ? [r] : r.split(",")))
.map((r) => r.trim())
.filter((r) => r.length > 0);
}
function requireNodeRef(refInput: string): { fileKey: string; nodeId: string } {
const ref = parseFigmaRef(refInput);
if (!ref.nodeId)
throw new Error(
`ref "${refInput}" has no node id — share a link with ?node-id=… or use fileKey:nodeId`,
);
return { fileKey: ref.fileKey, nodeId: ref.nodeId };
}
/** Cache hit per spec §5 (fileKey:nodeId:format:scale:version). Check EVERY
* row for the node — a node can carry several format/scale/version tuples,
* and the oldest-row shortcut minted duplicates forever. Reuse requires the
* frozen file to still exist; a deleted file falls through to re-import.
* Metadata supplied on a re-import upserts rather than being discarded. */
function reuseExisting(
fileKey: string,
nodeId: string,
opts: AssetImportOptions,
version: string,
deps: AssetImportDeps,
description: string | undefined,
entity: string | undefined,
): AssetImportResult | null {
const existing = findAllByFigmaNode(deps.projectDir, fileKey, nodeId).find(
(r) =>
r.provenance.format === opts.format &&
(r.provenance.scale ?? 1) === (opts.scale ?? 1) &&
r.provenance.version === version &&
existsSync(join(deps.projectDir, r.path)),
);
if (!existing) return null;
let record = existing;
if (
(description !== undefined && description !== existing.description) ||
(entity !== undefined && entity !== existing.entity)
) {
record = {
...existing,
...(description !== undefined && { description }),
...(entity !== undefined && { entity }),
};
updateRecord(deps.projectDir, record);
}
return { record, snippet: buildAssetSnippet(record), reused: true };
}
/** Freeze a rendered node's bytes and record it. Does NOT regenerate index.md
* — the caller does that once (batch imports would otherwise rewrite it N
* times). */
async function freezeAndRecord(
fileKey: string,
nodeId: string,
url: string,
ext: FigmaAssetFormat,
opts: AssetImportOptions,
version: string,
deps: AssetImportDeps,
description: string | undefined,
entity: string | undefined,
): Promise<AssetImportResult> {
let bytes = await deps.download(url);
if (ext === "svg") {
// Sniff before decoding: an SVG starts with '<' or an XML decl/BOM. A
// non-text payload would decode to U+FFFD soup and still write to disk.
const b0 = bytes[0];
if (b0 !== 0x3c && b0 !== 0x3f && b0 !== 0xef)
throw new Error("figma render returned non-SVG bytes for an svg export — retry the import");
bytes = new TextEncoder().encode(sanitizeSvg(new TextDecoder().decode(bytes)));
}
const id = nextId(deps.projectDir, "image");
const destAbs = join(typeDirPath(deps.projectDir, "image"), `${id}.${ext}`);
freezeBytes(bytes, destAbs);
const record: FigmaManifestRecord = {
id,
type: "image",
path: relative(deps.projectDir, destAbs),
source: `figma:${fileKey}/${nodeId}`,
...(description !== undefined && { description }),
...(entity !== undefined && { entity }),
provenance: {
source: "figma",
fileKey,
nodeId,
version,
format: opts.format,
scale: opts.scale,
},
};
appendRecord(deps.projectDir, record);
return { record, snippet: buildAssetSnippet(record), reused: false };
}
export async function runAssetImport(
refInput: string,
opts: AssetImportOptions,
deps: AssetImportDeps,
): Promise<AssetImportResult> {
const [result] = await runAssetImportMany([refInput], opts, deps);
if (!result) throw new Error(`figma asset import produced no result for "${refInput}"`);
return result;
}
/**
* Import many nodes of ONE figma file. Cache-checks each, renders the misses
* in a SINGLE /v1/images batch call (figma's documented rate-limit
* workaround — N nodes, one REST request), freezes each, and regenerates
* index.md once. Results come back in input order.
*/
export async function runAssetImportMany(
refInputs: string[],
opts: AssetImportOptions,
deps: AssetImportDeps,
): Promise<AssetImportResult[]> {
if (refInputs.length === 0) return [];
const refs = refInputs.map(requireNodeRef);
const fileKey = refs[0]!.fileKey;
const mixed = refs.find((r) => r.fileKey !== fileKey);
if (mixed)
throw new Error(
`all refs in one import must share a fileKey (batch is per-file) — got ${fileKey} and ${mixed.fileKey}; run separate commands per file`,
);
const { version } = await deps.client.fileVersion(fileKey);
const description = normalizeMeta(opts.description);
const entity = normalizeMeta(opts.entity);
// Resolve cache hits first; batch-render only the misses.
const slots: (AssetImportResult | null)[] = refs.map((r) =>
reuseExisting(fileKey, r.nodeId, opts, version, deps, description, entity),
);
const missIndexes = slots.flatMap((s, i) => (s === null ? [i] : []));
try {
if (missIndexes.length > 0) {
const missNodeIds = missIndexes.map((i) => refs[i]!.nodeId);
const rendered = await deps.client.renderNodes(fileKey, missNodeIds, opts);
const byNode = new Map(rendered.map((r) => [r.nodeId, r] as const));
for (const i of missIndexes) {
const nodeId = refs[i]!.nodeId;
const r = byNode.get(nodeId);
// Keep the typed code: component import's rasterize fallback skips on
// RENDER_FAILED, so a plain Error here would abort the whole import.
if (!r || r.url === null)
throw new FigmaClientError(
"RENDER_FAILED",
`figma could not render node ${nodeId} as ${opts.format}`,
undefined,
"images",
);
slots[i] = await freezeAndRecord(
fileKey,
nodeId,
r.url,
r.ext,
opts,
version,
deps,
description,
entity,
);
}
}
} finally {
// Regenerate once — in `finally` so a mid-batch RENDER_FAILED still leaves
// index.md consistent with the nodes that DID freeze, not stale until the
// next import.
safeRegenerateIndex(deps.projectDir);
}
return slots.map((s, i) => {
if (!s) throw new Error(`figma asset import produced no result for "${refInputs[i]}"`);
return s;
});
}
/** index.md is a single table row per record — newlines/tabs in a
* description would corrupt the whole table. */
function normalizeMeta(value: string | undefined): string | undefined {
if (value === undefined) return undefined;
const cleaned = value.replace(/\s+/g, " ").trim();
return cleaned.length > 0 ? cleaned : undefined;
}
/** Keep the agent-readable inventory in step with the manifest (media-use
* regenerates the same file after its writes). Best-effort: the import is
* already durable, so an index write failure must not fail the command. */
function safeRegenerateIndex(projectDir: string): void {
try {
regenerateIndex(projectDir);
} catch (err) {
console.warn(`index.md regeneration failed: ${err instanceof Error ? err.message : err}`);
}
}
const FORMATS: readonly FigmaAssetFormat[] = ["png", "svg", "jpg", "pdf"];
function parseFormat(raw: string): FigmaAssetFormat {
for (const f of FORMATS) if (f === raw) return f;
throw new Error(`unsupported format "${raw}" — use one of ${FORMATS.join(", ")}`);
}
export default defineCommand({
meta: { name: "asset", description: "Import one or more figma nodes as frozen local assets" },
args: {
ref: {
type: "positional",
description:
"figma URL, fileKey:nodeId, or fileKey (pass several, or comma-separate ids, to batch)",
required: true,
},
format: { type: "string", description: "png | svg | jpg | pdf", default: "svg" },
scale: { type: "string", description: "export scale (e.g. 2)" },
description: {
type: "string",
description: "what this asset is (index.md + <img alt>); e.g. the layer's purpose",
},
entity: {
type: "string",
description: 'entity name for media-use cache lookups (e.g. "Acme logo")',
},
dir: { type: "string", description: "project directory", default: "." },
},
async run({ args }) {
await withFigmaErrors("figma:asset", async () => {
const t0 = Date.now();
const token = process.env.FIGMA_TOKEN ?? "";
const client = createFigmaClient({ token });
// citty puts ALL positionals in `args._` (including the one bound to the
// named `ref`), so use `_` as the source of truth — reading both would
// double-count the first. Split any comma-joined ids, so `asset A B`,
// `asset A,B`, and `asset URL1 URL2` all batch into ONE /v1/images call.
const positionals = (
Array.isArray(args._) && args._.length > 0 ? (args._ as string[]) : [args.ref]
).map(String);
const refs = gatherAssetRefs(positionals);
const results = await runAssetImportMany(
refs,
{
format: parseFormat(args.format),
scale: args.scale !== undefined ? Number(args.scale) : undefined,
description: args.description,
entity: args.entity,
},
{ projectDir: args.dir, client, download: downloadRender },
);
for (const result of results) {
const verb = result.reused ? "reused" : "imported";
console.log(`${verb} ${result.record.id}${result.record.path}`);
console.log(result.snippet.html);
}
if (results.length > 1) {
const rendered = results.filter((r) => !r.reused).length;
console.log(
rendered > 0
? `(${results.length} nodes, ${rendered} rendered in 1 figma request)`
: `(${results.length} nodes, all reused from cache — no figma request)`,
);
}
const { trackFigmaImport } = await import("../../telemetry/index.js");
trackFigmaImport({
phase: "asset",
reused: results.every((r) => r.reused),
durationMs: Date.now() - t0,
});
});
},
});