Skip to content
Open
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
54 changes: 54 additions & 0 deletions packages/cli/src/utils/command-failure-tracking.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,60 @@ describe("trackCommandFailures", () => {
expect(onFailure).not.toHaveBeenCalled();
});

it("rejects an unknown flag on a leaf command", async () => {
const wrapped = trackCommandFailures(
() =>
Promise.resolve({
meta: { name: "leaf" },
args: { out: { type: "string" } },
run: () => Promise.resolve(),
} as CommandDef),
vi.fn(),
);
const cmd = await wrapped();
await expect(
(cmd.run as (ctx: unknown) => Promise<unknown>)({ rawArgs: ["--bogus", "x"] }),
).rejects.toThrow(/--bogus/);
});

it("skips the unknown-flag check when a command group delegates to a subcommand", async () => {
// `figma component <ref> --name x`: --name belongs to the subcommand's
// table; the group (subCommands + fallback-help run) must not reject it.
const run = vi.fn(() => Promise.resolve());
const wrapped = trackCommandFailures(
() =>
Promise.resolve({
meta: { name: "figma" },
subCommands: { component: () => Promise.resolve({ meta: { name: "component" } }) },
run,
} as unknown as CommandDef),
vi.fn(),
);
const cmd = await wrapped();
await expect(
(cmd.run as (ctx: unknown) => Promise<unknown>)({
rawArgs: ["component", "KEY:1-2", "--name", "hero"],
}),
).resolves.toBeUndefined();
expect(run).toHaveBeenCalled();
});

it("still rejects an unknown flag when the group is NOT delegating", async () => {
const wrapped = trackCommandFailures(
() =>
Promise.resolve({
meta: { name: "figma" },
subCommands: { component: () => Promise.resolve({ meta: { name: "component" } }) },
run: () => Promise.resolve(),
} as unknown as CommandDef),
vi.fn(),
);
const cmd = await wrapped();
await expect(
(cmd.run as (ctx: unknown) => Promise<unknown>)({ rawArgs: ["--bogus"] }),
).rejects.toThrow(/--bogus/);
});

it("passes through a command with no run() untouched", async () => {
const onFailure = vi.fn();
const parent: CommandDef = { meta: { name: "parent" } };
Expand Down
14 changes: 12 additions & 2 deletions packages/cli/src/utils/command-failure-tracking.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,8 +33,18 @@ export function trackCommandFailures(
// Reject unknown flags before the command runs: citty silently ignores
// them otherwise, dropping the value (e.g. `render --out x` fell back
// to the default output path). A leaf command with a `run` is the right
// place — nested command groups delegate to their own subcommands.
assertKnownFlags(cmd, ctx?.rawArgs ?? []);
// place — when a command group (subCommands + fallback-help run) is
// delegating to a subcommand, the flags belong to the subcommand's
// table, not the group's, so asserting here would falsely reject
// them (e.g. `figma component <ref> --name x`).
const rawArgs = ctx?.rawArgs ?? [];
const subCommands = (cmd as { subCommands?: Record<string, unknown> }).subCommands;
const firstPositional = rawArgs.find((tok) => tok && !tok.startsWith("-"));
const delegatesToSub =
subCommands != null &&
firstPositional != null &&
Object.prototype.hasOwnProperty.call(subCommands, firstPositional);
if (!delegatesToSub) assertKnownFlags(cmd, rawArgs);
try {
return await run(ctx);
} catch (err) {
Expand Down
32 changes: 32 additions & 0 deletions packages/core/src/figma/nodeToHtml.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,38 @@ describe("nodeToHtml", () => {
expect(out.html).toContain("background-color: #0066FF");
});

it("positions nested children relative to their PARENT, not the root", () => {
const out = nodeToHtml(
frame([
{
id: "1:2",
name: "Group",
type: "FRAME",
absoluteBoundingBox: BOX(600, 400, 300, 200),
children: [
{
id: "1:3",
name: "Inner",
type: "RECTANGLE",
absoluteBoundingBox: BOX(620, 440, 100, 40),
fills: [SOLID_BLUE],
},
],
},
]),
{ resolved: [], unresolved: [] },
);
// Group at canvas (600,400) inside root (100,200) → left 500, top 200.
expect(out.html).toContain("left: 500px");
expect(out.html).toContain("top: 200px");
// Inner at canvas (620,440) inside Group (600,400) → left 20, top 40 —
// NOT root-relative 520/240, which double-offsets when CSS resolves
// absolute position against the positioned parent.
expect(out.html).toContain("left: 20px");
expect(out.html).toContain("top: 40px");
expect(out.html).not.toContain("left: 520px");
});

it("emits var() with literal fallback for resolved bindings", () => {
const out = nodeToHtml(
frame([
Expand Down
24 changes: 17 additions & 7 deletions packages/core/src/figma/nodeToHtml.ts
Original file line number Diff line number Diff line change
Expand Up @@ -186,7 +186,7 @@ function unresolvedAttr(node: FigmaNodeDocument, ctx: RenderContext): string {
return ` data-figma-unresolved="${escapeHtml(props.join(" "))}"`;
}

function geometryCss(node: FigmaNodeDocument, ctx: RenderContext, isRoot: boolean): string[] {
function geometryCss(node: FigmaNodeDocument, parentBox: Box, isRoot: boolean): string[] {
const box = boxOf(node);
const styles: string[] = [];
if (!box) return styles;
Expand All @@ -197,10 +197,14 @@ function geometryCss(node: FigmaNodeDocument, ctx: RenderContext, isRoot: boolea
`height: ${round(box.height)}px`,
);
} else {
// CSS absolute positioning is relative to the nearest positioned
// ancestor — the PARENT's box, not the root origin. Subtracting the root
// for every depth double-offsets nested children (each level re-adds its
// ancestors' offsets), drifting content down-right and off-frame.
styles.push(
"position: absolute",
`left: ${round(box.x - ctx.origin.x)}px`,
`top: ${round(box.y - ctx.origin.y)}px`,
`left: ${round(box.x - parentBox.x)}px`,
`top: ${round(box.y - parentBox.y)}px`,
`width: ${round(box.width)}px`,
`height: ${round(box.height)}px`,
);
Expand Down Expand Up @@ -243,10 +247,15 @@ function decorationCss(node: FigmaNodeDocument, ctx: RenderContext): string[] {
// instead of a RangeError; real figma frames are nowhere near this deep.
const MAX_DEPTH = 500;

function renderChildren(node: FigmaNodeDocument, ctx: RenderContext, depth: number): string {
function renderChildren(
node: FigmaNodeDocument,
ctx: RenderContext,
depth: number,
parentBox: Box,
): string {
const childHtml: string[] = [];
for (const child of childDocuments(node)) {
const rendered = renderNodeHtml(child, ctx, false, depth + 1);
const rendered = renderNodeHtml(child, ctx, false, depth + 1, parentBox);
if (rendered.length > 0) childHtml.push(rendered);
}
return childHtml.length > 0 ? `\n${childHtml.join("\n")}\n` : "";
Expand All @@ -257,11 +266,12 @@ function renderNodeHtml(
ctx: RenderContext,
isRoot: boolean,
depth = 0,
parentBox: Box = ctx.origin,
): string {
if (node.visible === false || depth > MAX_DEPTH) return "";
const slug = uniqueSlug(ctx, node.name);
const style = escapeHtml(
[...geometryCss(node, ctx, isRoot), ...decorationCss(node, ctx)].join("; "),
[...geometryCss(node, parentBox, isRoot), ...decorationCss(node, ctx)].join("; "),
);
// data-hf-snippet marks the file as a mountable fragment, not a standalone
// composition — the project linter skips composition-root rules for it.
Expand All @@ -278,7 +288,7 @@ function renderNodeHtml(
return `<div ${idAttrs} style="${style}">${text}</div>`;
}

return `<div ${idAttrs} style="${style}">${renderChildren(node, ctx, depth)}</div>`;
return `<div ${idAttrs} style="${style}">${renderChildren(node, ctx, depth, boxOf(node) ?? parentBox)}</div>`;
}

export interface NodeToHtmlOptions {
Expand Down
Loading