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
15 changes: 15 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,20 @@
# Changelog

## 2.2.2 (2026-09-25)

### Added

- Images from the web in `render`: `image="https://..."` on frames and rectangles and `<Image src="https://..." />`. figma-cli downloads the file itself, so it also works while a plugin with network restrictions is open in the file
- A lone `<Rectangle ... />` is accepted as the outermost element of `render`
- Text in a column with `items="center"` is centered automatically. `align` still wins
- The daemon loads all pages in the background right after connecting, so the first command in a large file does not wait for page loading
- The agent rules explain images and absolute positioning and clipping

### Fixed

- `<Image>` `<Rect>` `<Ellipse>` and `<Slot>` were dropped when a quoted value contained a `/` (for example a URL)
- The live test scripts remove their pages when they finish

## 2.2.1 (2026-09-25)

### Added: new versions reach users without extra steps
Expand Down
38 changes: 35 additions & 3 deletions examples/autolayout-patterns.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -187,12 +187,15 @@ const evalIn = (code) => {
function main() {
const keep = process.argv.includes('--keep');

evalIn(`(async () => {
// Remember where the user was. This script builds on its own page, and
// without --keep it must leave the file exactly as it found it.
const previousPage = evalIn(`(async () => {
let p = figma.root.children.find(c => c.name === ${JSON.stringify(PAGE)});
const prev = figma.currentPage.id;
if (!p) { p = figma.createPage(); p.name = ${JSON.stringify(PAGE)}; }
await figma.setCurrentPageAsync(p);
${keep ? '' : 'for (const c of [...p.children]) c.remove();'}
return 'ready';
return prev;
})()`);

// Caption above each example, so the page reads as documentation.
Expand Down Expand Up @@ -272,7 +275,36 @@ function main() {

console.log(`\n${PATTERNS.length - failures}/${PATTERNS.length} patterns match their expected layout`);
if (warned) console.log('note: render reported an auto-layout warning above — none of these patterns should trigger one');
if (!keep) teardown(previousPage);
else console.log(`kept on page "${PAGE}" — run without --keep to clean up`);
process.exit(failures || warned ? 1 : 0);
}

main();
/** Remove the scratch page and put the user back where they were. */
function teardown(previousPageId) {
try {
evalIn(`(async () => {
await figma.loadAllPagesAsync();
const p = figma.root.children.find(c => c.name === ${JSON.stringify(PAGE)});
const prev = ${JSON.stringify(String(previousPageId || ''))};
const back = prev ? await figma.getNodeByIdAsync(prev) : null;
if (back && back.type === 'PAGE') await figma.setCurrentPageAsync(back);
else if (p) { const other = figma.root.children.find(x => x !== p); if (other) await figma.setCurrentPageAsync(other); }
if (p) p.remove();
return 'torn down';
})()`);
} catch (e) {
// Best effort — never mask the real result, but do say so: a silent
// teardown failure is how scratch pages accumulate in someone's file.
console.error(`⚠ teardown failed: ${e && e.message ? e.message.split('\n')[0] : e}`);
}
}

try {
main();
} catch (e) {
// A crash must not leave the scratch page behind either.
teardown(null);
console.error(e && e.message ? e.message : e);
process.exit(1);
}
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "figma-ds-cli",
"version": "2.2.1",
"version": "2.2.2",
"description": "CLI for managing Figma design systems. Create variables, components, and more. No API key required.",
"author": {
"name": "Sil Bormüller",
Expand Down
5 changes: 5 additions & 0 deletions src/daemon.js
Original file line number Diff line number Diff line change
Expand Up @@ -209,6 +209,11 @@ async function getCdpClient() {
lastHealthCheck = Date.now();
lastHealthResult = true;
console.log('[daemon] Connected to Figma via CDP (Yolo Mode)');
// File warming: Figma loads pages lazily, so the first command that looks
// beyond the current page waits for every page to load (seconds on a big
// design system). Start that load now, in the background, so it is done
// before the first real command arrives. Never awaited, never fatal.
cdpClient.eval('figma.loadAllPagesAsync().then(() => true)').catch(() => {});
} finally {
isCdpConnecting = false;
}
Expand Down
76 changes: 62 additions & 14 deletions src/figma-client.js
Original file line number Diff line number Diff line change
Expand Up @@ -456,7 +456,8 @@ export class FigmaClient {
const vertical = options.vertical || false;

// Parse each JSX to get props and children
const parsed = jsxArray.map(jsx => {
const parsed = jsxArray.map(raw => {
const jsx = this.normalizeRoot(raw);
const openMatch = jsx.match(/<Frame\s+([^>]*)>/);
if (!openMatch) throw new Error('Invalid JSX: must start with <Frame>');
const propsStr = openMatch[1];
Expand All @@ -470,6 +471,7 @@ export class FigmaClient {
// Pre-fetch any icon SVGs used in any frame (shared child generator
// renders real Iconify SVGs, falling back to placeholders offline)
const iconSvgMap = await this.prefetchIconSvgs(parsed.flatMap(p => p.children));
await this.prefetchImages(parsed.flatMap(p => [p.props, ...p.children]));

// Collect all fonts needed ({ family, style } pairs, deduped)
const allFontMap = new Map();
Expand Down Expand Up @@ -653,7 +655,7 @@ export class FigmaClient {
const effectsCode = this.generateEffectsCode(props, `f${frameIdx}`);
const imageCode = props.image ? this.generateImageFillCode(props.image, `f${frameIdx}`, props.imageScale) : '';

const childCode = this.generateChildrenCode(children, `f${frameIdx}`, flex, { counter: { value: 0 }, prefix: `${frameIdx}_`, iconSvgMap });
const childCode = this.generateChildrenCode(children, `f${frameIdx}`, flex, { counter: { value: 0 }, prefix: `${frameIdx}_`, iconSvgMap }, props.items || props.align || null);

return `
const f${frameIdx} = figma.createFrame();
Expand Down Expand Up @@ -744,7 +746,42 @@ export class FigmaClient {
* divergent auto-layout implementation). Supporting them here is what lets
* every render go through ONE code path.
*/
/**
* A lone <Rectangle ... /> or <Rect ... /> is a common first attempt at "a
* box with an image". A Frame does everything a rectangle does, so accept it
* as the root instead of failing with "must start with <Frame>".
*/
normalizeRoot(jsx) {
const m = String(jsx).trim().match(/^<(Rectangle|Rect)\b([\s\S]*?)\/>$/);
return m ? `<Frame${m[2]}></Frame>` : jsx;
}

/**
* Download every image="https://..." / <Image src> in the tree on the CLI side
* and hand Figma the bytes. figma.createImageAsync(url) runs under the network
* rules of whatever plugin context the code lands in (an open plugin's
* allowedDomains), so it fails for most URLs; createImage(bytes) never does.
*/
async prefetchImages(nodes) {
const urls = new Set();
const walk = (n) => {
if (!n) return;
for (const u of [n.image, n._type === 'image' ? n.src : null]) if (typeof u === 'string' && /^https?:\/\//.test(u)) urls.add(u);
(n._children || []).forEach(walk);
};
nodes.forEach(walk);
this._imageData = this._imageData || {};
await Promise.all([...urls].map(async (u) => {
if (this._imageData[u]) return;
try {
const res = await fetch(u, { signal: AbortSignal.timeout(15000) });
if (res.ok) this._imageData[u] = Buffer.from(await res.arrayBuffer()).toString('base64');
} catch (e) { /* falls back to createImageAsync inside Figma */ }
}));
}

async parseJSX(jsx, opts = {}) {
jsx = this.normalizeRoot(jsx);
// Find opening Frame tag
const openMatch = jsx.match(/<Frame\s+([^>]*)>/);
if (!openMatch) {
Expand All @@ -771,8 +808,9 @@ export class FigmaClient {
console.warn('[render] Supported elements: <Frame>, <Text>, <Rectangle>, <Rect>, <Image>, <Icon>');
}

// Pre-fetch any icon SVGs before code generation
// Pre-fetch any icon SVGs and images before code generation
const iconSvgMap = await this.prefetchIconSvgs(childElements);
await this.prefetchImages([props, ...childElements]);

// Generate code
return this.generateCode(props, childElements, iconSvgMap, opts);
Expand Down Expand Up @@ -881,12 +919,12 @@ export class FigmaClient {
Text: ['name', 'textStyle', 'size', 'weight', 'color', 'font', 'italic', 'align', 'w', 'h', 'width', 'height',
'grow', 'opacity', 'x', 'y', 'position', 'lineHeight', 'letterSpacing', 'truncate', 'maxLines'],
Icon: ['name', 'size', 's', 'color', 'c', 'x', 'y', 'position'],
Rect: ['name', 'w', 'h', 'width', 'height', 'bg', 'fill', 'rounded', 'radius', 'opacity', 'x', 'y', 'position'],
Rect: ['name', 'image', 'imageScale', 'w', 'h', 'width', 'height', 'bg', 'fill', 'rounded', 'radius', 'opacity', 'x', 'y', 'position'],
Rectangle: null, // alias of Rect, filled below
Ellipse: ['name', 'w', 'h', 'width', 'height', 'bg', 'fill', 'stroke', 'strokeWidth', 'strokeAlign',
'arc', 'arcStart', 'innerRadius', 'opacity', 'x', 'y', 'position'],
Circle: null, // alias of Ellipse, filled below
Image: ['name', 'w', 'h', 'width', 'height', 'bg', 'fill', 'rounded', 'radius', 'opacity', 'x', 'y', 'position'],
Image: ['name', 'src', 'image', 'fit', 'imageScale', 'w', 'h', 'width', 'height', 'bg', 'fill', 'rounded', 'radius', 'opacity', 'x', 'y', 'position'],
Slot: ['name', 'flex', 'gap', 'p', 'px', 'py', 'padding', 'w', 'h', 'width', 'height', 'bg', 'fill'],
Instance: ['name', 'component', 'id', 'variant', 'text', 'w', 'h', 'width', 'height'],
};
Expand Down Expand Up @@ -1039,7 +1077,7 @@ export class FigmaClient {
}

// Parse self-closing Slot elements
const slotSelfCloseRegex = /<Slot(?:\s+([^/]*?))?\s*\/>/g;
const slotSelfCloseRegex = /<Slot(?:\s+((?:[^/"]|"[^"]*")*?))?\s*\/>/g;
while ((match = slotSelfCloseRegex.exec(childrenStr)) !== null) {
const idx = match.index;
const insideFrame = frameRanges.some(r => idx >= r.start && idx < r.end);
Expand Down Expand Up @@ -1074,7 +1112,7 @@ export class FigmaClient {

// Parse Rectangle elements (self-closing)
// Use (?:\s+([^/]*?))? to allow Rect with or without attributes
const rectRegex = /<(?:Rectangle|Rect)(?:\s+([^/]*?))?\s*\/>/g;
const rectRegex = /<(?:Rectangle|Rect)(?:\s+((?:[^/"]|"[^"]*")*?))?\s*\/>/g;
while ((match = rectRegex.exec(childrenStr)) !== null) {
const idx = match.index;
const insideFrame = frameRanges.some(r => idx >= r.start && idx < r.end);
Expand All @@ -1088,7 +1126,7 @@ export class FigmaClient {

// Parse Ellipse / Circle elements (self-closing). Supports rings, spinners,
// donut/pie via arc (sweep°), arcStart (start°, 0=3 o'clock) and innerRadius.
const ellipseRegex = /<(?:Ellipse|Circle)(?:\s+([^/]*?))?\s*\/>/g;
const ellipseRegex = /<(?:Ellipse|Circle)(?:\s+((?:[^/"]|"[^"]*")*?))?\s*\/>/g;
while ((match = ellipseRegex.exec(childrenStr)) !== null) {
const idx = match.index;
const insideFrame = frameRanges.some(r => idx >= r.start && idx < r.end);
Expand All @@ -1101,7 +1139,8 @@ export class FigmaClient {
}

// Parse Image elements (self-closing) - creates placeholder rectangle
const imageRegex = /<Image\s+([^/]*)\s*\/>/g;
// quoted values may contain '/' (src="https://..."), so skip over them
const imageRegex = /<Image\s+((?:[^/"]|"[^"]*")*?)\s*\/>/g;
while ((match = imageRegex.exec(childrenStr)) !== null) {
const idx = match.index;
const insideFrame = frameRanges.some(r => idx >= r.start && idx < r.end);
Expand Down Expand Up @@ -1321,7 +1360,7 @@ export class FigmaClient {
* ctx: { counter: {value}, prefix: string (el-name prefix, e.g. '0_'),
* iconSvgMap: {name: svg} }
*/
generateChildrenCode(items, parentVar, parentFlex, ctx) {
generateChildrenCode(items, parentVar, parentFlex, ctx, parentItems = null) {
return items.map(item => {
const idx = ctx.prefix + (ctx.counter.value++);
if (item._type === 'text') {
Expand All @@ -1344,7 +1383,10 @@ export class FigmaClient {
return `{ value: ${Number(v)}, unit: 'PIXELS' }`;
};
const alignMapT = { left: 'LEFT', center: 'CENTER', right: 'RIGHT', justify: 'JUSTIFIED', start: 'LEFT', end: 'RIGHT' };
const tAlign = item.align ? alignMapT[String(item.align).toLowerCase()] : null;
// A text in a column whose items are centered reads centered: without
// this a wrapping headline sits flush left inside a centered stack.
const inheritsCenter = !item.align && (parentFlex === 'col' || parentFlex === 'column') && parentItems === 'center';
const tAlign = item.align ? alignMapT[String(item.align).toLowerCase()] : (inheritsCenter ? 'CENTER' : null);
const tLineHeight = item.lineHeight !== undefined ? dimUnit(item.lineHeight) : null;
const tLetterSpacing = item.letterSpacing !== undefined ? dimUnit(item.letterSpacing) : null;
const tTruncate = item.truncate === true || item.truncate === 'true';
Expand Down Expand Up @@ -1503,7 +1545,7 @@ export class FigmaClient {

const { alignVal: fAlignVal, justifyVal: fJustifyVal } = resolveAlign(fFlex, item);

const nestedChildren = item._children ? this.generateChildrenCode(item._children, `el${idx}`, fFlex, ctx) : '';
const nestedChildren = item._children ? this.generateChildrenCode(item._children, `el${idx}`, fFlex, ctx, item.items || item.align || null) : '';
const frameFillCode = fBg ? this.generateFillCode(fBg, `el${idx}`) : { code: `el${idx}.fills = [];`, usesVars: false };
const frameStrokeCode = fStroke ? this.generateStrokeCode(fStroke, `el${idx}`, fStrokeWidth, fStrokeAlign) : { code: '' };
const frameEffectsCode = this.generateEffectsCode(item, `el${idx}`);
Expand Down Expand Up @@ -1573,6 +1615,7 @@ export class FigmaClient {
${frameFillCode.code}
${frameStrokeCode.code}
${frameEffectsCode}
${item.image ? this.generateImageFillCode(item.image, `el${idx}`, item.imageScale) : ''}
${isNone ? '' : `el${idx}.primaryAxisAlignItems = '${fJustifyVal}';
el${idx}.counterAxisAlignItems = '${fAlignVal}';`}
el${idx}.clipsContent = ${fClip};
Expand Down Expand Up @@ -1720,6 +1763,7 @@ export class FigmaClient {
el${idx}.resize(${iWidth}, ${iHeight});
el${idx}.cornerRadius = ${iRounded};
${imgFillCode.code}
${item.src || item.image ? this.generateImageFillCode(item.src || item.image, `el${idx}`, item.imageScale || item.fit) : ''}
${parentVar}.appendChild(el${idx});
${this.genCommonNodeProps(item, `el${idx}`, parentFlex === 'none' || parentFlex === 'stack' || parentFlex === 'free')}`;
} else if (item._type === 'icon') {
Expand Down Expand Up @@ -1905,7 +1949,7 @@ export class FigmaClient {
const collected = this.collectFontsAndVarUsage(children);
if (collected.usesVars) usesVars = true;

const childCode = this.generateChildrenCode(children, 'frame', flex, { counter: { value: 0 }, prefix: '', iconSvgMap });
const childCode = this.generateChildrenCode(children, 'frame', flex, { counter: { value: 0 }, prefix: '', iconSvgMap }, props.items || props.align || null);

const { alignVal, justifyVal } = resolveAlign(flex, props);

Expand Down Expand Up @@ -2216,8 +2260,12 @@ export class FigmaClient {
const finalMode = validModes.includes(mode) ? mode : 'FILL';
const safeName = elementVar.replace(/[^a-zA-Z0-9]/g, '');
// Image REPLACES fills (not appends) — user expects bg-style behavior
const bytes = this._imageData && this._imageData[url];
const create = bytes
? `figma.createImage(figma.base64Decode(${JSON.stringify(bytes)}))`
: `await figma.createImageAsync(${JSON.stringify(url)})`;
return `
const __img${safeName} = await figma.createImageAsync(${JSON.stringify(url)});
const __img${safeName} = ${create};
${elementVar}.fills = [{ type: 'IMAGE', imageHash: __img${safeName}.hash, scaleMode: '${finalMode}' }];`;
}

Expand Down
5 changes: 5 additions & 0 deletions src/lib/agent-rules.js
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,11 @@ Helpers available inside \`eval\` (they handle the Figma pitfalls for you):
(\`text\` relabels the first text layer).
- A component text or style whose font is not installed is switched to Inter
automatically and reported as a \`note:\` line. You do not need to fix fonts.
- Images from the web: \`<Frame image="https://..." imageScale="fill|fit" .../>\` or
\`<Image src="https://..." w={320} h={200} />\`. figma-cli downloads the file itself.
- Overlays and badges: \`position="absolute" x={0} y={0}\` takes a child out of the
auto-layout flow (a named child). Clip children to rounded corners with \`overflow="hidden"\`.
- Text in a column with \`items="center"\` is centered automatically; set \`align\` to override.
- \`render\` prints the new node id and **the structure it built**: sizes, layout,
padding, gap, bound variables, styles, which component each instance uses.
Check that output; do not re-read the result with \`eval\`. \`--verify\` adds a
Expand Down
21 changes: 21 additions & 0 deletions tests/figma-client.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -97,3 +97,24 @@ it('root frame honors per-side padding in render and render-batch', async () =>
assert.ok(code.includes(want), 'render-batch: ' + want);
}
});

describe('self-closing tags with URLs and centered text', () => {
const c = new FigmaClient();
it('parses <Image src="https://..."> and <Rect image="https://..."> despite the slashes', () => {
const kids = c.parseChildren('<Image name="Hero" src="https://example.com/a/b.png" w={10} h={10} /><Rect name="R" image="https://example.com/x.png" w={5} h={5} />');
assert.deepStrictEqual(kids.map(k => [k._type, k.name]), [['image', 'Hero'], ['rect', 'R']]);
assert.strictEqual(kids[0].src, 'https://example.com/a/b.png');
});
it('accepts a lone Rectangle as the root', async () => {
const code = await c.parseJSX('<Rectangle name="Box" w={20} h={20} bg="#f00" />');
assert.ok(code.includes('"Box"'));
});
it('centers text in a column with items="center" unless align is set', async () => {
const centered = await c.parseJSX('<Frame flex="col" items="center" w={200}><Text w="fill">Hi</Text></Frame>');
assert.ok(centered.includes("textAlignHorizontal = 'CENTER'"));
const left = await c.parseJSX('<Frame flex="col" items="center" w={200}><Text w="fill" align="left">Hi</Text></Frame>');
assert.ok(!left.includes("textAlignHorizontal = 'CENTER'"));
const plain = await c.parseJSX('<Frame flex="col" w={200}><Text w="fill">Hi</Text></Frame>');
assert.ok(!plain.includes("textAlignHorizontal = 'CENTER'"));
});
});
Loading
Loading