From 7124d0045f5c6826b34fd93d809bb9e2e56abe70 Mon Sep 17 00:00:00 2001 From: Danila Poyarkov Date: Wed, 22 Jul 2026 14:56:58 +0300 Subject: [PATCH] fix(cli): populate lazy FIG pages - Populate requested pages for scoped reads and all pages for document-wide operations - Export whole FIG documents by default while keeping explicit page exports partial --- CHANGELOG.md | 1 + packages/cli/src/commands/convert.ts | 3 +- packages/cli/src/commands/eval.ts | 3 +- packages/cli/src/commands/export.ts | 38 ++++++-- packages/cli/src/headless.ts | 40 +++++++++ packages/cli/src/rpc-data.ts | 3 +- packages/core/src/kiwi/fig/lazy-import.ts | 44 ++++++---- packages/core/src/kiwi/index.ts | 1 + tests/engine/cli/export.test.ts | 59 +++++++++++++ tests/engine/cli/headless.test.ts | 86 +++++++++++++++++++ tests/engine/io/fig/import/lazy-pages.test.ts | 2 +- 11 files changed, 254 insertions(+), 26 deletions(-) create mode 100644 tests/engine/cli/headless.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 2dc46ab37..26f41e22c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -32,6 +32,7 @@ - Improve `.fig` import and rendering fidelity for groups, booleans, instances, rotated vectors, complex text fills, auto-sized text, layout grids, page guides, patterns, noise effects, masks, and canvas backgrounds. - Preserve pages, components, prototype and library metadata, export settings, unsupported effects, and other unrelated Figma data when editing and resaving `.fig` files. - Prevent duplicate generated IDs from corrupting `.fig` round trips. +- Populate lazy `.fig` pages for file-mode CLI inspection and preserve the whole document when exporting FIG unless a page is explicitly requested. - Match Figma auto-layout reflow after deleting children, hiding optional instance slots, or syncing component changes. - Make group and boolean-operation children scale with their parent during resize. - Restore desktop copy, cut, and paste when browser clipboard events are unavailable. diff --git a/packages/cli/src/commands/convert.ts b/packages/cli/src/commands/convert.ts index a87c4b12b..abc4b48e6 100644 --- a/packages/cli/src/commands/convert.ts +++ b/packages/cli/src/commands/convert.ts @@ -7,7 +7,7 @@ import { BUILTIN_IO_FORMATS, IORegistry } from '@open-pencil/core/io' import { requireFile } from '#cli/app-client' import { ok, printError } from '#cli/format' -import { loadDocument } from '#cli/headless' +import { loadDocument, populateWholeDocument } from '#cli/headless' const io = new IORegistry(BUILTIN_IO_FORMATS) @@ -51,6 +51,7 @@ export default defineCommand({ const file = requireFile(args.file) const graph = await loadDocument(file) + populateWholeDocument(graph) const result = await io.writeDocument(format, graph) const output = args.output ? resolve(args.output) : defaultOutput(file, format) await writeFile(output, result.data as Uint8Array) diff --git a/packages/cli/src/commands/eval.ts b/packages/cli/src/commands/eval.ts index 153ffecd0..7558ae2ec 100644 --- a/packages/cli/src/commands/eval.ts +++ b/packages/cli/src/commands/eval.ts @@ -7,7 +7,7 @@ import { FigmaAPI } from '@open-pencil/core/figma-api' import { isAppMode, requireFile, rpc } from '#cli/app-client' import { appTargetOptions, appTargetRpcArgs } from '#cli/app-target' import { printError } from '#cli/format' -import { loadDocument } from '#cli/headless' +import { loadDocument, populateWholeDocument } from '#cli/headless' function printResult(value: unknown, json: boolean) { if (json || !process.stdout.isTTY) { @@ -71,6 +71,7 @@ export default defineCommand({ const file = requireFile(args.file) const graph = await loadDocument(file) + populateWholeDocument(graph) const figma = new FigmaAPI(graph) type AsyncFunctionConstructor = new ( diff --git a/packages/cli/src/commands/export.ts b/packages/cli/src/commands/export.ts index af595ece2..b0a134f3f 100644 --- a/packages/cli/src/commands/export.ts +++ b/packages/cli/src/commands/export.ts @@ -14,7 +14,7 @@ import { import { isAppMode, requireFile, rpc } from '#cli/app-client' import { appTargetOptions, appTargetRpcArgs } from '#cli/app-target' import { ok, printError } from '#cli/format' -import { loadDocument } from '#cli/headless' +import { loadDocument, populateDocumentPage, populateWholeDocument } from '#cli/headless' const io = new IORegistry(BUILTIN_IO_FORMATS) const RASTER_FORMATS = ['PNG', 'JPG', 'WEBP'] @@ -102,7 +102,8 @@ function exportFileName(defaultName: string, extension: string, scale?: number): return scale ? `${defaultName}@${scale}x.${extension}` : `${defaultName}.${extension}` } -function targetLabel(pageName?: string, nodeId?: string): string { +function targetLabel(pageName?: string, nodeId?: string, wholeDocument = false): string { + if (wholeDocument) return 'whole document' if (nodeId) return `node ${nodeId}` return pageName ? `page "${pageName}"` : 'first page' } @@ -152,6 +153,31 @@ async function exportHTMLFromFile( console.log(ok(`Target: ${targetLabel(args.page, args.node)}`)) } +function prepareGraphForExport( + graph: Awaited>, + pageId: string, + format: string, + args: ExportArgs +): boolean { + const wholeDocument = format === 'FIG' && !args.page && !args.node + if (wholeDocument || args.node) populateWholeDocument(graph) + else populateDocumentPage(graph, pageId) + return wholeDocument +} + +async function executeFileExport( + formatId: string, + graph: Awaited>, + target: FileExportTarget, + options: + | { format?: string; scale?: number; quality?: number; renderThumbnail?: boolean } + | undefined, + wholeDocument: boolean +) { + if (wholeDocument) return io.writeDocument(formatId, graph, options) + return io.exportContent(formatId, { graph, target }, options) +} + async function exportFromFile(format: string, args: ExportArgs) { const file = requireFile(args.file) const graph = await loadDocument(file) @@ -175,6 +201,8 @@ async function exportFromFile(format: string, args: ExportArgs) { process.exit(1) } + const wholeDocument = prepareGraphForExport(graph, page.id, format, args) + const target = args.node ? { scope: 'node' as const, nodeId: args.node } : { scope: 'page' as const, pageId: page.id } @@ -205,7 +233,7 @@ async function exportFromFile(format: string, args: ExportArgs) { } } - const result = await io.exportContent(formatId, { graph, target }, options) + const result = await executeFileExport(formatId, graph, target, options, wholeDocument) const output = resolve( args.output ?? exportFileName( @@ -215,7 +243,7 @@ async function exportFromFile(format: string, args: ExportArgs) { ) ) await writeAndLog(output, result.data as string | Uint8Array) - console.log(ok(`Target: ${targetLabel(args.page, args.node)}`)) + console.log(ok(`Target: ${targetLabel(args.page, args.node, wholeDocument)}`)) } export default defineCommand({ @@ -247,7 +275,7 @@ export default defineCommand({ }, page: { type: 'string', - description: 'Export a specific page by name (default: first page)', + description: 'Export a specific page by name (FIG defaults to the whole document)', required: false }, node: { diff --git a/packages/cli/src/headless.ts b/packages/cli/src/headless.ts index 4ec11c262..ef898b4b6 100644 --- a/packages/cli/src/headless.ts +++ b/packages/cli/src/headless.ts @@ -1,6 +1,7 @@ import { readFile } from 'node:fs/promises' import { BUILTIN_IO_FORMATS, IORegistry, initCanvasKit } from '@open-pencil/core/io' +import { populateAllLazyFigImportRoots, populateLazyFigImportRoots } from '@open-pencil/core/kiwi' import { computeAllLayouts } from '@open-pencil/core/layout' import type { SceneGraph } from '@open-pencil/scene-graph' @@ -14,3 +15,42 @@ export async function loadDocument(filePath: string): Promise { computeAllLayouts(graph) return graph } + +export function populateDocumentPage(graph: SceneGraph, pageId: string): boolean { + const changed = populateLazyFigImportRoots(graph, [pageId]) + if (changed) computeAllLayouts(graph, pageId) + return changed +} + +export function populateWholeDocument(graph: SceneGraph): boolean { + const changed = populateAllLazyFigImportRoots(graph) + if (changed) computeAllLayouts(graph) + return changed +} + +function pageNameFromArgs(args: unknown): string | undefined { + if (!args || typeof args !== 'object' || Array.isArray(args)) return undefined + const page = (args as { page?: unknown }).page + return typeof page === 'string' ? page : undefined +} + +function populateRequestedPage(graph: SceneGraph, pageName?: string): void { + const pages = graph.getPages() + const page = pageName ? pages.find((candidate) => candidate.name === pageName) : pages[0] + if (page) populateDocumentPage(graph, page.id) +} + +export function prepareDocumentForRpc(graph: SceneGraph, command: string, args?: unknown): void { + if (command === 'pages' || command === 'variables') return + if (command === 'tree') { + populateRequestedPage(graph, pageNameFromArgs(args)) + return + } + if (command === 'find' || command === 'query') { + const pageName = pageNameFromArgs(args) + if (pageName) populateRequestedPage(graph, pageName) + else populateWholeDocument(graph) + return + } + populateWholeDocument(graph) +} diff --git a/packages/cli/src/rpc-data.ts b/packages/cli/src/rpc-data.ts index ed4482b45..ea1efa44f 100644 --- a/packages/cli/src/rpc-data.ts +++ b/packages/cli/src/rpc-data.ts @@ -2,7 +2,7 @@ import { executeRpcCommand } from '@open-pencil/core/rpc' import { isAppMode, requireFile, rpc } from '#cli/app-client' import { appTargetRpcArgs, type AppTargetCliArgs } from '#cli/app-target' -import { loadDocument } from '#cli/headless' +import { loadDocument, prepareDocumentForRpc } from '#cli/headless' type RpcArgs = { [key: string]: unknown } @@ -23,5 +23,6 @@ export async function loadRpcData( }) } const graph = await loadDocument(requireFile(file)) + prepareDocumentForRpc(graph, command, args) return executeRpcCommand(graph, command, args) as Result } diff --git a/packages/core/src/kiwi/fig/lazy-import.ts b/packages/core/src/kiwi/fig/lazy-import.ts index 0e42fa345..e7013aa73 100644 --- a/packages/core/src/kiwi/fig/lazy-import.ts +++ b/packages/core/src/kiwi/fig/lazy-import.ts @@ -19,6 +19,24 @@ export function getLazyFigImportContext(graph: SceneGraph): LazyFigImportContext return lazyFigImportContexts.get(graph) } +function applyPopulation( + graph: SceneGraph, + context: LazyFigImportContext, + rootIds?: string[] +): void { + graph.preserveSourceMetadataDuring(() => { + populateAndApplyOverrides( + graph, + context.changeMap, + context.guidToNodeId, + context.blobs, + rootIds + ) + }) + const populatedRootIds = rootIds ?? graph.getPages(true).map((page) => page.id) + for (const id of populatedRootIds) context.populatedRootIds.add(id) +} + function populateRoots( graph: SceneGraph, context: LazyFigImportContext, @@ -26,18 +44,7 @@ function populateRoots( ): boolean { const pending = [...rootIds].filter((id) => id && !context.populatedRootIds.has(id)) if (pending.length === 0) return false - - graph.preserveSourceMetadataDuring(() => { - populateAndApplyOverrides( - graph, - context.changeMap, - context.guidToNodeId, - context.blobs, - pending - ) - }) - - for (const id of pending) context.populatedRootIds.add(id) + applyPopulation(graph, context, pending) return true } @@ -49,9 +56,12 @@ export function populateLazyFigImportRoots(graph: SceneGraph, rootIds: Iterable< export function populateAllLazyFigImportRoots(graph: SceneGraph): boolean { const context = getLazyFigImportContext(graph) if (!context) return false - return populateRoots( - graph, - context, - graph.getPages(true).map((page) => page.id) - ) + const rootIds = graph.getPages(true).map((page) => page.id) + if (rootIds.every((id) => context.populatedRootIds.has(id))) return false + + // Revisit the initially populated page without an active-root filter. + // Cross-page component chains can only stabilize when global override + // resolution can see every source and target in the same pass. + applyPopulation(graph, context) + return true } diff --git a/packages/core/src/kiwi/index.ts b/packages/core/src/kiwi/index.ts index 15c0b588f..7a5ac5e22 100644 --- a/packages/core/src/kiwi/index.ts +++ b/packages/core/src/kiwi/index.ts @@ -1,6 +1,7 @@ export { prefetchFigmaSchema } from '#core/clipboard' export { readFigFile, parseFigFile } from './fig/file' export { importNodeChanges } from './fig/import' +export { populateAllLazyFigImportRoots, populateLazyFigImportRoots } from './fig/lazy-import' export { deduplicateNodeChangePluginData } from '@open-pencil/kiwi/fig/parse' export { initCodec, diff --git a/tests/engine/cli/export.test.ts b/tests/engine/cli/export.test.ts index 19710da63..226e60ea3 100644 --- a/tests/engine/cli/export.test.ts +++ b/tests/engine/cli/export.test.ts @@ -31,11 +31,70 @@ async function createFigFixture() { rect.paddingBottom = 16 rect.fills = [{ type: 'SOLID', color: { r: 1, g: 1, b: 1, a: 1 } }] + const secondPage = graph.addPage('Second Page') + createRect(graph, secondPage.id, { + name: 'Second Card', + x: 0, + y: 0, + width: 120, + height: 60 + }) + const result = await io.writeDocument('fig', graph) await Bun.write(figPath, result.data as Uint8Array) return { dir, figPath } } +test('FIG export preserves the whole document by default', async () => { + const { dir, figPath } = await createFigFixture() + const output = join(dir, 'whole.fig') + + const { stdout, stderr, exitCode } = await runOpenPencilCLI([ + 'export', + figPath, + '--format', + 'fig', + '--output', + output + ]) + + expect(stderr).toBe('') + expect(exitCode).toBe(0) + expect(stdout).toContain('Target: whole document') + + const { graph } = await io.readDocument({ + name: output, + data: new Uint8Array(await Bun.file(output).arrayBuffer()) + }) + expect(graph.getPages()).toHaveLength(3) + expect(graph.getPages().map((page) => page.name)).toContain('Second Page') +}) + +test('FIG export requires an explicit page for a partial archive', async () => { + const { dir, figPath } = await createFigFixture() + const output = join(dir, 'page.fig') + + const { stderr, exitCode } = await runOpenPencilCLI([ + 'export', + figPath, + '--format', + 'fig', + '--page', + 'Second Page', + '--output', + output + ]) + + expect(stderr).toBe('') + expect(exitCode).toBe(0) + + const { graph } = await io.readDocument({ + name: output, + data: new Uint8Array(await Bun.file(output).arrayBuffer()) + }) + expect(graph.getPages().map((page) => page.name)).toEqual(['Second Page']) +}) + test('export CLI writes HTML with inline styles by default', async () => { const { dir, figPath } = await createFigFixture() const output = join(dir, 'card.html') diff --git a/tests/engine/cli/headless.test.ts b/tests/engine/cli/headless.test.ts new file mode 100644 index 000000000..a2388485c --- /dev/null +++ b/tests/engine/cli/headless.test.ts @@ -0,0 +1,86 @@ +import { describe, expect, test } from 'bun:test' +import { mkdtemp } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' + +import { BUILTIN_IO_FORMATS, IORegistry } from '@open-pencil/core/io' +import { SceneGraph } from '@open-pencil/scene-graph' + +import { loadDocument, prepareDocumentForRpc } from '#cli/headless' + +const io = new IORegistry(BUILTIN_IO_FORMATS) + +async function createLazyFixture() { + const graph = new SceneGraph() + const [page1] = graph.getPages() + page1.name = 'Page 1' + const page2 = graph.addPage('Page 2') + const component = graph.createNode('COMPONENT', page1.id, { + name: 'Button', + width: 100, + height: 40 + }) + graph.createNode('RECTANGLE', component.id, { + name: 'Background', + width: 100, + height: 40 + }) + graph.createNode('INSTANCE', page1.id, { + name: 'Button instance 1', + componentId: component.id, + width: 100, + height: 40 + }) + graph.createNode('INSTANCE', page2.id, { + name: 'Button instance 2', + componentId: component.id, + width: 100, + height: 40 + }) + + const dir = await mkdtemp(join(tmpdir(), 'open-pencil-headless-lazy-')) + const path = join(dir, 'lazy.fig') + const result = await io.writeDocument('fig', graph) + await Bun.write(path, result.data as Uint8Array) + return loadDocument(path) +} + +function pageInstance(graph: SceneGraph, pageName: string) { + const page = graph.getPages().find((candidate) => candidate.name === pageName) + return page + ? graph.getChildren(page.id).find((candidate) => candidate.type === 'INSTANCE') + : undefined +} + +describe('headless CLI lazy .fig preparation', () => { + test('populates only the requested tree page', async () => { + const graph = await createLazyFixture() + const page1Instance = pageInstance(graph, 'Page 1') + const page2Instance = pageInstance(graph, 'Page 2') + + expect(page1Instance ? graph.getChildren(page1Instance.id) : []).toHaveLength(1) + expect(page2Instance ? graph.getChildren(page2Instance.id) : []).toHaveLength(0) + + prepareDocumentForRpc(graph, 'tree', { page: 'Page 2' }) + + expect(page2Instance ? graph.getChildren(page2Instance.id) : []).toHaveLength(1) + }) + + test('populates all pages for document-wide searches', async () => { + const graph = await createLazyFixture() + const page2Instance = pageInstance(graph, 'Page 2') + + prepareDocumentForRpc(graph, 'find', {}) + + expect(page2Instance ? graph.getChildren(page2Instance.id) : []).toHaveLength(1) + }) + + test('keeps page listings lazy', async () => { + const graph = await createLazyFixture() + const page2Instance = pageInstance(graph, 'Page 2') + + prepareDocumentForRpc(graph, 'pages') + + expect(page2Instance ? graph.getChildren(page2Instance.id) : []).toHaveLength(0) + }) +}) diff --git a/tests/engine/io/fig/import/lazy-pages.test.ts b/tests/engine/io/fig/import/lazy-pages.test.ts index 5a6f2ed1d..9d4ad6840 100644 --- a/tests/engine/io/fig/import/lazy-pages.test.ts +++ b/tests/engine/io/fig/import/lazy-pages.test.ts @@ -64,7 +64,7 @@ describe('lazy .fig page population', () => { const { graph, page1Instance, page2Instance } = createLazyGraph() expect(populateAllLazyFigImportRoots(graph)).toBe(true) - expect(graph.getChildren(page1Instance.id)).toHaveLength(0) + expect(graph.getChildren(page1Instance.id)).toHaveLength(1) expect(graph.getChildren(page2Instance.id)).toHaveLength(1) expect(populateAllLazyFigImportRoots(graph)).toBe(false) })