From 892484ffd53a2f81d6a1a0b90e59db4e3331b9b0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sil=20Borm=C3=BCller?= Date: Fri, 25 Sep 2026 12:54:08 +0200 Subject: [PATCH] feat: images from URLs in render and fewer follow-up fixes for common layouts - render downloads image="https://..." and on the CLI side and passes the bytes - a lone Rectangle is a valid render root - text in a centered column is centered unless align is set - the daemon warms the file by loading all pages in the background after connecting - self-closing tags keep quoted values that contain a slash - the agent rules cover images and absolute positioning and clipping - live test scripts clean up their pages - release 2.2.2 Co-Authored-By: Claude Opus 5.5 (1M context) --- CHANGELOG.md | 15 +++++++ examples/autolayout-patterns.mjs | 38 ++++++++++++++-- package-lock.json | 4 +- package.json | 2 +- src/daemon.js | 5 +++ src/figma-client.js | 76 ++++++++++++++++++++++++++------ src/lib/agent-rules.js | 5 +++ tests/figma-client.test.js | 21 +++++++++ tests/live/parity-harness.mjs | 38 ++++++++++++++-- 9 files changed, 181 insertions(+), 23 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 578b7e22..f5032317 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 ``. figma-cli downloads the file itself, so it also works while a plugin with network restrictions is open in the file +- A lone `` 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 + +- `` `` `` and `` 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 diff --git a/examples/autolayout-patterns.mjs b/examples/autolayout-patterns.mjs index ee0625f9..a75606ab 100644 --- a/examples/autolayout-patterns.mjs +++ b/examples/autolayout-patterns.mjs @@ -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. @@ -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); +} diff --git a/package-lock.json b/package-lock.json index 92eaa76b..15558e83 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "figma-ds-cli", - "version": "2.2.1", + "version": "2.2.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "figma-ds-cli", - "version": "2.2.1", + "version": "2.2.2", "license": "MIT", "dependencies": { "chalk": "^5.3.0", diff --git a/package.json b/package.json index 836097fe..64829323 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/daemon.js b/src/daemon.js index b1c15598..0e384e3a 100644 --- a/src/daemon.js +++ b/src/daemon.js @@ -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; } diff --git a/src/figma-client.js b/src/figma-client.js index 74bbca5f..f2cb6d8f 100644 --- a/src/figma-client.js +++ b/src/figma-client.js @@ -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(/]*)>/); if (!openMatch) throw new Error('Invalid JSX: must start with '); const propsStr = openMatch[1]; @@ -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(); @@ -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(); @@ -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 or 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 ". + */ + normalizeRoot(jsx) { + const m = String(jsx).trim().match(/^<(Rectangle|Rect)\b([\s\S]*?)\/>$/); + return m ? `` : jsx; + } + + /** + * Download every image="https://..." / 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(/]*)>/); if (!openMatch) { @@ -771,8 +808,9 @@ export class FigmaClient { console.warn('[render] Supported elements: , , , , , '); } - // 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); @@ -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'], }; @@ -1039,7 +1077,7 @@ export class FigmaClient { } // Parse self-closing Slot elements - const slotSelfCloseRegex = //g; + const slotSelfCloseRegex = //g; while ((match = slotSelfCloseRegex.exec(childrenStr)) !== null) { const idx = match.index; const insideFrame = frameRanges.some(r => idx >= r.start && idx < r.end); @@ -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); @@ -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); @@ -1101,7 +1139,8 @@ export class FigmaClient { } // Parse Image elements (self-closing) - creates placeholder rectangle - const imageRegex = //g; + // quoted values may contain '/' (src="https://..."), so skip over them + const imageRegex = //g; while ((match = imageRegex.exec(childrenStr)) !== null) { const idx = match.index; const insideFrame = frameRanges.some(r => idx >= r.start && idx < r.end); @@ -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') { @@ -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'; @@ -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}`); @@ -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}; @@ -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') { @@ -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); @@ -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}' }];`; } diff --git a/src/lib/agent-rules.js b/src/lib/agent-rules.js index 90e0c40f..25ca1fa5 100644 --- a/src/lib/agent-rules.js +++ b/src/lib/agent-rules.js @@ -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: \`\` or + \`\`. 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 diff --git a/tests/figma-client.test.js b/tests/figma-client.test.js index e55153c6..4a5b36b6 100644 --- a/tests/figma-client.test.js +++ b/tests/figma-client.test.js @@ -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 and despite the slashes', () => { + const kids = c.parseChildren(''); + 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(''); + assert.ok(code.includes('"Box"')); + }); + it('centers text in a column with items="center" unless align is set', async () => { + const centered = await c.parseJSX('Hi'); + assert.ok(centered.includes("textAlignHorizontal = 'CENTER'")); + const left = await c.parseJSX('Hi'); + assert.ok(!left.includes("textAlignHorizontal = 'CENTER'")); + const plain = await c.parseJSX('Hi'); + assert.ok(!plain.includes("textAlignHorizontal = 'CENTER'")); + }); +}); diff --git a/tests/live/parity-harness.mjs b/tests/live/parity-harness.mjs index f0f37e44..d9f21a85 100644 --- a/tests/live/parity-harness.mjs +++ b/tests/live/parity-harness.mjs @@ -184,16 +184,40 @@ const diff = (a, b, trail = 'root', out = []) => { return out; }; +/** 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}`); + } +} + async function main() { const filter = process.argv[2]; const cases = filter ? CASES.filter((c) => c.name.includes(filter)) : CASES; - evalIn(`(async () => { + // Remember where the user was: the harness switches pages to build on its + // own scratch page, and leaving them somewhere else (or leaving the scratch + // page behind at all) is debris in someone's real file. + 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); for (const c of [...p.children]) c.remove(); - return 'clean'; + return prev; })()`); let failed = 0; @@ -235,7 +259,15 @@ async function main() { ` render ${timing.single}ms · render-batch ${timing.batch}ms` + ` (${(timing.single / Math.max(timing.batch, 1)).toFixed(1)}x)` ); + teardown(previousPage); process.exit(failed ? 1 : 0); } -if (process.argv[1] === fileURLToPath(import.meta.url)) main(); +if (process.argv[1] === fileURLToPath(import.meta.url)) { + // A case that throws must not leave the scratch page behind either. + main().catch((e) => { + teardown(null); + console.error(e && e.message ? e.message : e); + process.exit(1); + }); +}