@open-pencil/cli 0.10.0 → 0.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@open-pencil/cli",
3
- "version": "0.10.0",
3
+ "version": "0.11.0",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "bin": {
@@ -19,7 +19,7 @@
19
19
  "provenance": true
20
20
  },
21
21
  "dependencies": {
22
- "@open-pencil/core": "^0.10.0",
22
+ "@open-pencil/core": "^0.11.0",
23
23
  "agentfmt": "^0.1.3",
24
24
  "canvaskit-wasm": "^0.40.0",
25
25
  "citty": "^0.1.6"
package/src/app-client.ts CHANGED
@@ -11,14 +11,14 @@ export async function getAppToken(): Promise<string> {
11
11
  if (!res || !res.ok) {
12
12
  throw new Error(
13
13
  `Could not connect to OpenPencil app on localhost:${AUTOMATION_HTTP_PORT}.\n` +
14
- 'Is the app running? Start it with: bun run tauri dev'
14
+ 'Is the app running? Start it with: bun run tauri dev'
15
15
  )
16
16
  }
17
17
  const data = (await res.json()) as { status: string; token?: string }
18
18
  if (data.status !== 'ok' || !data.token) {
19
19
  throw new Error(
20
20
  'OpenPencil app is running but no document is open.\n' +
21
- 'Open a document in the app, or provide a .fig file path.'
21
+ 'Open a document in the app, or provide a .fig file path.'
22
22
  )
23
23
  }
24
24
  cachedToken = data.token
@@ -37,7 +37,10 @@ export async function rpc<T = unknown>(command: string, args: unknown = {}): Pro
37
37
  })
38
38
 
39
39
  if (!res.ok) {
40
- const body = (await res.json().catch(() => ({ error: `HTTP ${res.status}` }))) as { error?: string; ok?: boolean }
40
+ const body = (await res.json().catch(() => ({ error: `HTTP ${res.status}` }))) as {
41
+ error?: string
42
+ ok?: boolean
43
+ }
41
44
  throw new Error(body.error ?? `RPC failed: HTTP ${res.status}`)
42
45
  }
43
46
 
@@ -1,27 +1,13 @@
1
1
  import { defineCommand } from 'citty'
2
2
 
3
- import { loadDocument } from '../../headless'
3
+ import { executeRpcCommand, calcClusterConfidence } from '@open-pencil/core'
4
+
4
5
  import { isAppMode, requireFile, rpc } from '../../app-client'
5
6
  import { bold, fmtList, fmtSummary } from '../../format'
6
- import { executeRpcCommand } from '@open-pencil/core'
7
+ import { loadDocument } from '../../headless'
7
8
 
8
9
  import type { AnalyzeClustersResult } from '@open-pencil/core'
9
10
 
10
- function calcConfidence(nodes: Array<{ width: number; height: number; childCount: number }>): number {
11
- if (nodes.length < 2) return 100
12
- const base = nodes[0]
13
- let score = 0
14
- for (const node of nodes.slice(1)) {
15
- const sizeDiff = Math.abs(node.width - base.width) + Math.abs(node.height - base.height)
16
- const childDiff = Math.abs(node.childCount - base.childCount)
17
- if (sizeDiff <= 4 && childDiff === 0) score++
18
- else if (sizeDiff <= 10 && childDiff <= 1) score += 0.8
19
- else if (sizeDiff <= 20 && childDiff <= 2) score += 0.6
20
- else score += 0.4
21
- }
22
- return Math.round((score / (nodes.length - 1)) * 100)
23
- }
24
-
25
11
  function formatSignature(sig: string): string {
26
12
  const [typeSize, children] = sig.split('|')
27
13
  const type = typeSize.split(':')[0]
@@ -39,8 +25,15 @@ function formatSignature(sig: string): string {
39
25
  return `${typeName} > [${childParts.join(', ')}]`
40
26
  }
41
27
 
42
- async function getData(file: string | undefined, args: { limit?: string; 'min-size'?: string; 'min-count'?: string }): Promise<AnalyzeClustersResult> {
43
- const rpcArgs = { limit: Number(args.limit ?? 20), minSize: Number(args['min-size'] ?? 30), minCount: Number(args['min-count'] ?? 2) }
28
+ async function getData(
29
+ file: string | undefined,
30
+ args: { limit?: string; 'min-size'?: string; 'min-count'?: string }
31
+ ): Promise<AnalyzeClustersResult> {
32
+ const rpcArgs = {
33
+ limit: Number(args.limit ?? 20),
34
+ minSize: Number(args['min-size'] ?? 30),
35
+ minCount: Number(args['min-count'] ?? 2)
36
+ }
44
37
  if (isAppMode(file)) return rpc<AnalyzeClustersResult>('analyze_clusters', rpcArgs)
45
38
  const graph = await loadDocument(requireFile(file))
46
39
  return executeRpcCommand(graph, 'analyze_clusters', rpcArgs) as AnalyzeClustersResult
@@ -49,7 +42,11 @@ async function getData(file: string | undefined, args: { limit?: string; 'min-si
49
42
  export default defineCommand({
50
43
  meta: { description: 'Find repeated design patterns (potential components)' },
51
44
  args: {
52
- file: { type: 'positional', description: '.fig file path (omit to connect to running app)', required: false },
45
+ file: {
46
+ type: 'positional',
47
+ description: '.fig file path (omit to connect to running app)',
48
+ required: false
49
+ },
53
50
  limit: { type: 'string', description: 'Max clusters to show', default: '20' },
54
51
  'min-size': { type: 'string', description: 'Min node size in px', default: '30' },
55
52
  'min-count': { type: 'string', description: 'Min instances to form cluster', default: '2' },
@@ -74,7 +71,7 @@ export default defineCommand({
74
71
 
75
72
  const items = data.clusters.map((c) => {
76
73
  const first = c.nodes[0]
77
- const confidence = calcConfidence(c.nodes)
74
+ const confidence = calcClusterConfidence(c.nodes)
78
75
 
79
76
  const widths = c.nodes.map((n) => n.width)
80
77
  const heights = c.nodes.map((n) => n.height)
@@ -93,7 +90,10 @@ export default defineCommand({
93
90
  details: {
94
91
  size: sizeStr,
95
92
  structure: formatSignature(c.signature),
96
- examples: c.nodes.slice(0, 3).map((n) => n.id).join(', ')
93
+ examples: c.nodes
94
+ .slice(0, 3)
95
+ .map((n) => n.id)
96
+ .join(', ')
97
97
  }
98
98
  }
99
99
  })
@@ -102,11 +102,13 @@ export default defineCommand({
102
102
 
103
103
  const clusteredNodes = data.clusters.reduce((sum, c) => sum + c.nodes.length, 0)
104
104
  console.log('')
105
- console.log(fmtSummary({
106
- clusters: data.clusters.length,
107
- 'total nodes': data.totalNodes,
108
- clustered: clusteredNodes
109
- }))
105
+ console.log(
106
+ fmtSummary({
107
+ clusters: data.clusters.length,
108
+ 'total nodes': data.totalNodes,
109
+ clustered: clusteredNodes
110
+ })
111
+ )
110
112
  console.log('')
111
113
  }
112
114
  })
@@ -1,13 +1,17 @@
1
1
  import { defineCommand } from 'citty'
2
2
 
3
- import { loadDocument } from '../../headless'
3
+ import { executeRpcCommand } from '@open-pencil/core'
4
+
4
5
  import { isAppMode, requireFile, rpc } from '../../app-client'
5
6
  import { bold, fmtHistogram, fmtList, fmtSummary } from '../../format'
6
- import { executeRpcCommand } from '@open-pencil/core'
7
+ import { loadDocument } from '../../headless'
7
8
 
8
9
  import type { AnalyzeColorsResult } from '@open-pencil/core'
9
10
 
10
- async function getData(file: string | undefined, args: { threshold?: string; similar?: boolean }): Promise<AnalyzeColorsResult> {
11
+ async function getData(
12
+ file: string | undefined,
13
+ args: { threshold?: string; similar?: boolean }
14
+ ): Promise<AnalyzeColorsResult> {
11
15
  const rpcArgs = { threshold: Number(args.threshold ?? 15), similar: args.similar }
12
16
  if (isAppMode(file)) return rpc<AnalyzeColorsResult>('analyze_colors', rpcArgs)
13
17
  const graph = await loadDocument(requireFile(file))
@@ -17,9 +21,17 @@ async function getData(file: string | undefined, args: { threshold?: string; sim
17
21
  export default defineCommand({
18
22
  meta: { description: 'Analyze color palette usage' },
19
23
  args: {
20
- file: { type: 'positional', description: '.fig file path (omit to connect to running app)', required: false },
24
+ file: {
25
+ type: 'positional',
26
+ description: '.fig file path (omit to connect to running app)',
27
+ required: false
28
+ },
21
29
  limit: { type: 'string', description: 'Max colors to show', default: '30' },
22
- threshold: { type: 'string', description: 'Distance threshold for clustering similar colors (0–50)', default: '15' },
30
+ threshold: {
31
+ type: 'string',
32
+ description: 'Distance threshold for clustering similar colors (0–50)',
33
+ default: '15'
34
+ },
23
35
  similar: { type: 'boolean', description: 'Show similar color clusters' },
24
36
  json: { type: 'boolean', description: 'Output as JSON' }
25
37
  },
@@ -57,7 +69,11 @@ export default defineCommand({
57
69
 
58
70
  console.log('')
59
71
  console.log(
60
- fmtSummary({ 'unique colors': data.colors.length, 'from variables': fromVars.length, hardcoded: hardcoded.length })
72
+ fmtSummary({
73
+ 'unique colors': data.colors.length,
74
+ 'from variables': fromVars.length,
75
+ hardcoded: hardcoded.length
76
+ })
61
77
  )
62
78
 
63
79
  if (args.similar && data.clusters.length > 0) {
@@ -1,9 +1,9 @@
1
1
  import { defineCommand } from 'citty'
2
2
 
3
+ import clusters from './clusters'
3
4
  import colors from './colors'
4
- import typography from './typography'
5
5
  import spacing from './spacing'
6
- import clusters from './clusters'
6
+ import typography from './typography'
7
7
 
8
8
  export default defineCommand({
9
9
  meta: { description: 'Analyze design tokens and patterns' },
@@ -1,9 +1,10 @@
1
1
  import { defineCommand } from 'citty'
2
2
 
3
- import { loadDocument } from '../../headless'
3
+ import { executeRpcCommand } from '@open-pencil/core'
4
+
4
5
  import { isAppMode, requireFile, rpc } from '../../app-client'
5
6
  import { bold, kv, fmtHistogram, fmtSummary } from '../../format'
6
- import { executeRpcCommand } from '@open-pencil/core'
7
+ import { loadDocument } from '../../headless'
7
8
 
8
9
  import type { AnalyzeSpacingResult } from '@open-pencil/core'
9
10
 
@@ -16,7 +17,11 @@ async function getData(file?: string): Promise<AnalyzeSpacingResult> {
16
17
  export default defineCommand({
17
18
  meta: { description: 'Analyze spacing values (gap, padding)' },
18
19
  args: {
19
- file: { type: 'positional', description: '.fig file path (omit to connect to running app)', required: false },
20
+ file: {
21
+ type: 'positional',
22
+ description: '.fig file path (omit to connect to running app)',
23
+ required: false
24
+ },
20
25
  grid: { type: 'string', description: 'Base grid size to check against', default: '8' },
21
26
  json: { type: 'boolean', description: 'Output as JSON' }
22
27
  },
@@ -67,7 +72,9 @@ export default defineCommand({
67
72
  return
68
73
  }
69
74
 
70
- console.log(fmtSummary({ 'gap values': data.gaps.length, 'padding values': data.paddings.length }))
75
+ console.log(
76
+ fmtSummary({ 'gap values': data.gaps.length, 'padding values': data.paddings.length })
77
+ )
71
78
 
72
79
  const offGridGaps = data.gaps.filter((g) => g.value % gridSize !== 0)
73
80
  const offGridPaddings = data.paddings.filter((p) => p.value % gridSize !== 0)
@@ -1,9 +1,10 @@
1
1
  import { defineCommand } from 'citty'
2
2
 
3
- import { loadDocument } from '../../headless'
3
+ import { executeRpcCommand } from '@open-pencil/core'
4
+
4
5
  import { isAppMode, requireFile, rpc } from '../../app-client'
5
6
  import { bold, fmtHistogram, fmtSummary } from '../../format'
6
- import { executeRpcCommand } from '@open-pencil/core'
7
+ import { loadDocument } from '../../headless'
7
8
 
8
9
  import type { AnalyzeTypographyResult } from '@open-pencil/core'
9
10
 
@@ -28,8 +29,15 @@ async function getData(file?: string): Promise<AnalyzeTypographyResult> {
28
29
  export default defineCommand({
29
30
  meta: { description: 'Analyze typography usage' },
30
31
  args: {
31
- file: { type: 'positional', description: '.fig file path (omit to connect to running app)', required: false },
32
- 'group-by': { type: 'string', description: 'Group by: family, size, weight (default: show all styles)' },
32
+ file: {
33
+ type: 'positional',
34
+ description: '.fig file path (omit to connect to running app)',
35
+ required: false
36
+ },
37
+ 'group-by': {
38
+ type: 'string',
39
+ description: 'Group by: family, size, weight (default: show all styles)'
40
+ },
33
41
  limit: { type: 'string', description: 'Max styles to show', default: '30' },
34
42
  json: { type: 'boolean', description: 'Output as JSON' }
35
43
  },
@@ -57,7 +65,9 @@ export default defineCommand({
57
65
  console.log('')
58
66
  console.log(
59
67
  fmtHistogram(
60
- [...byFamily.entries()].sort((a, b) => b[1] - a[1]).map(([family, count]) => ({ label: family, value: count }))
68
+ [...byFamily.entries()]
69
+ .sort((a, b) => b[1] - a[1])
70
+ .map(([family, count]) => ({ label: family, value: count }))
61
71
  )
62
72
  )
63
73
  } else if (groupBy === 'size') {
@@ -67,7 +77,9 @@ export default defineCommand({
67
77
  console.log('')
68
78
  console.log(
69
79
  fmtHistogram(
70
- [...bySize.entries()].sort((a, b) => a[0] - b[0]).map(([size, count]) => ({ label: `${size}px`, value: count }))
80
+ [...bySize.entries()]
81
+ .sort((a, b) => a[0] - b[0])
82
+ .map(([size, count]) => ({ label: `${size}px`, value: count }))
71
83
  )
72
84
  )
73
85
  } else if (groupBy === 'weight') {
@@ -77,7 +89,9 @@ export default defineCommand({
77
89
  console.log('')
78
90
  console.log(
79
91
  fmtHistogram(
80
- [...byWeight.entries()].sort((a, b) => b[1] - a[1]).map(([weight, count]) => ({ label: `${weight} ${weightName(weight)}`, value: count }))
92
+ [...byWeight.entries()]
93
+ .sort((a, b) => b[1] - a[1])
94
+ .map(([weight, count]) => ({ label: `${weight} ${weightName(weight)}`, value: count }))
81
95
  )
82
96
  )
83
97
  } else {
@@ -91,7 +105,9 @@ export default defineCommand({
91
105
  }
92
106
 
93
107
  console.log('')
94
- console.log(fmtSummary({ 'unique styles': data.styles.length, 'text nodes': data.totalTextNodes }))
108
+ console.log(
109
+ fmtSummary({ 'unique styles': data.styles.length, 'text nodes': data.totalTextNodes })
110
+ )
95
111
  console.log('')
96
112
  }
97
113
  })
@@ -0,0 +1,56 @@
1
+ import { basename, extname, resolve } from 'node:path'
2
+
3
+ import { defineCommand } from 'citty'
4
+
5
+ import { BUILTIN_IO_FORMATS, IORegistry } from '@open-pencil/core'
6
+
7
+ import { requireFile } from '../app-client'
8
+ import { ok, printError } from '../format'
9
+ import { loadDocument } from '../headless'
10
+
11
+ const io = new IORegistry(BUILTIN_IO_FORMATS)
12
+ const WRITABLE_FORMATS = ['FIG'] as const
13
+
14
+ type WritableFormat = (typeof WRITABLE_FORMATS)[number]
15
+
16
+ function defaultOutput(file: string, format: WritableFormat): string {
17
+ const base = basename(file, extname(file))
18
+ return resolve(`${base}.${format.toLowerCase()}`)
19
+ }
20
+
21
+ export default defineCommand({
22
+ meta: { description: 'Convert a document to another writable format' },
23
+ args: {
24
+ file: {
25
+ type: 'positional',
26
+ description: 'Input document file path',
27
+ required: true
28
+ },
29
+ output: {
30
+ type: 'string',
31
+ alias: 'o',
32
+ description: 'Output file path (default: <name>.<format>)',
33
+ required: false
34
+ },
35
+ format: {
36
+ type: 'string',
37
+ alias: 'f',
38
+ description: 'Output format: fig (default: fig)',
39
+ default: 'fig'
40
+ }
41
+ },
42
+ async run({ args }) {
43
+ const format = args.format.toUpperCase() as WritableFormat
44
+ if (!WRITABLE_FORMATS.includes(format)) {
45
+ printError(`Invalid format "${args.format}". Use fig.`)
46
+ process.exit(1)
47
+ }
48
+
49
+ const file = requireFile(args.file)
50
+ const graph = await loadDocument(file)
51
+ const result = await io.writeDocument(format.toLowerCase(), graph)
52
+ const output = args.output ? resolve(args.output) : defaultOutput(file, format)
53
+ await Bun.write(output, result.data as Uint8Array)
54
+ console.log(ok(`Converted ${file} → ${output}`))
55
+ }
56
+ })
@@ -2,9 +2,9 @@ import { defineCommand } from 'citty'
2
2
 
3
3
  import { FigmaAPI } from '@open-pencil/core'
4
4
 
5
- import { loadDocument } from '../headless'
6
5
  import { isAppMode, requireFile, rpc } from '../app-client'
7
6
  import { printError } from '../format'
7
+ import { loadDocument } from '../headless'
8
8
 
9
9
  function printResult(value: unknown, json: boolean) {
10
10
  if (json || !process.stdout.isTTY) {
@@ -26,13 +26,22 @@ function serializeResult(value: unknown): unknown {
26
26
  export default defineCommand({
27
27
  meta: { description: 'Execute JavaScript with Figma plugin API' },
28
28
  args: {
29
- file: { type: 'positional', description: '.fig file path (omit to connect to running app)', required: false },
29
+ file: {
30
+ type: 'positional',
31
+ description: 'Document file path (omit to connect to running app)',
32
+ required: false
33
+ },
30
34
  code: { type: 'string', alias: 'c', description: 'JavaScript code to execute' },
31
35
  stdin: { type: 'boolean', description: 'Read code from stdin' },
32
36
  write: { type: 'boolean', alias: 'w', description: 'Write changes back to the input file' },
33
- output: { type: 'string', alias: 'o', description: 'Write to a different file', required: false },
37
+ output: {
38
+ type: 'string',
39
+ alias: 'o',
40
+ description: 'Write to a different file',
41
+ required: false
42
+ },
34
43
  json: { type: 'boolean', description: 'Output as JSON' },
35
- quiet: { type: 'boolean', alias: 'q', description: 'Suppress output' },
44
+ quiet: { type: 'boolean', alias: 'q', description: 'Suppress output' }
36
45
  },
37
46
  async run({ args }) {
38
47
  let code = args.code
@@ -80,13 +89,14 @@ export default defineCommand({
80
89
  }
81
90
 
82
91
  if (args.write || args.output) {
83
- const { exportFigFile } = await import('@open-pencil/core')
92
+ const { BUILTIN_IO_FORMATS, IORegistry } = await import('@open-pencil/core')
93
+ const io = new IORegistry(BUILTIN_IO_FORMATS)
84
94
  const outPath = args.output ? args.output : file
85
- const data = await exportFigFile(graph)
86
- await Bun.write(outPath, new Uint8Array(data))
95
+ const result = await io.writeDocument('fig', graph)
96
+ await Bun.write(outPath, result.data as Uint8Array)
87
97
  if (!args.quiet) {
88
98
  console.error(`Written to ${outPath}`)
89
99
  }
90
100
  }
91
- },
101
+ }
92
102
  })
@@ -1,15 +1,18 @@
1
- import { defineCommand } from 'citty'
2
1
  import { basename, extname, resolve } from 'node:path'
3
2
 
4
- import { renderNodesToSVG, sceneNodeToJSX, selectionToJSX } from '@open-pencil/core'
3
+ import { defineCommand } from 'citty'
4
+
5
+ import { BUILTIN_IO_FORMATS, IORegistry } from '@open-pencil/core'
5
6
 
6
- import { loadDocument, exportNodes, exportThumbnail } from '../headless'
7
7
  import { isAppMode, requireFile, rpc } from '../app-client'
8
8
  import { ok, printError } from '../format'
9
- import type { ExportFormat, JSXFormat } from '@open-pencil/core'
9
+ import { loadDocument } from '../headless'
10
10
 
11
+ import type { RasterExportFormat } from '@open-pencil/core'
12
+
13
+ const io = new IORegistry(BUILTIN_IO_FORMATS)
11
14
  const RASTER_FORMATS = ['PNG', 'JPG', 'WEBP']
12
- const ALL_FORMATS = [...RASTER_FORMATS, 'SVG', 'JSX']
15
+ const ALL_FORMATS = [...RASTER_FORMATS, 'SVG', 'JSX', 'FIG']
13
16
  const JSX_STYLES = ['openpencil', 'tailwind']
14
17
 
15
18
  interface ExportArgs {
@@ -34,17 +37,21 @@ async function writeAndLog(path: string, content: string | Uint8Array) {
34
37
 
35
38
  async function exportViaApp(format: string, args: ExportArgs) {
36
39
  if (format === 'SVG') {
37
- const result = await rpc<{ svg: string }>('tool', { name: 'export_svg', args: { ids: args.node ? [args.node] : undefined } })
38
- if (!result.svg) { printError('Nothing to export.'); process.exit(1) }
40
+ const result = await rpc<{ svg: string }>('tool', {
41
+ name: 'export_svg',
42
+ args: { ids: args.node ? [args.node] : undefined }
43
+ })
44
+ if (!result.svg) {
45
+ printError('Nothing to export.')
46
+ process.exit(1)
47
+ }
39
48
  await writeAndLog(resolve(args.output ?? 'export.svg'), result.svg)
40
49
  return
41
50
  }
42
51
 
43
- if (format === 'JSX') {
44
- const result = await rpc<{ jsx: string }>('export_jsx', { nodeIds: args.node ? [args.node] : undefined, style: args.style })
45
- if (!result.jsx) { printError('Nothing to export.'); process.exit(1) }
46
- await writeAndLog(resolve(args.output ?? 'export.jsx'), result.jsx)
47
- return
52
+ if (format === 'JSX' || format === 'FIG') {
53
+ printError(`${format} export is only available in file mode right now.`)
54
+ process.exit(1)
48
55
  }
49
56
 
50
57
  const result = await rpc<{ base64: string }>('export', {
@@ -57,72 +64,122 @@ async function exportViaApp(format: string, args: ExportArgs) {
57
64
  await writeAndLog(resolve(args.output ?? `export.${ext}`), data)
58
65
  }
59
66
 
67
+ function exportFileName(defaultName: string, extension: string, scale?: number): string {
68
+ return scale ? `${defaultName}@${scale}x.${extension}` : `${defaultName}.${extension}`
69
+ }
70
+
71
+ function targetLabel(pageName?: string, nodeId?: string): string {
72
+ if (nodeId) return `node ${nodeId}`
73
+ return pageName ? `page "${pageName}"` : 'first page'
74
+ }
75
+
60
76
  async function exportFromFile(format: string, args: ExportArgs) {
61
77
  const file = requireFile(args.file)
62
78
  const graph = await loadDocument(file)
63
79
 
64
80
  const pages = graph.getPages()
65
81
  const page = args.page ? pages.find((p) => p.name === args.page) : pages[0]
66
- if (!page) { printError(`Page "${args.page}" not found.`); process.exit(1) }
82
+ if (!page) {
83
+ const available = pages.map((p) => `"${p.name}"`).join(', ')
84
+ printError(
85
+ args.page
86
+ ? `Page "${args.page}" not found. Available pages: ${available || 'none'}.`
87
+ : 'Document has no pages.'
88
+ )
89
+ process.exit(1)
90
+ }
67
91
 
68
92
  const defaultName = basename(file, extname(file))
69
93
 
70
- if (format === 'JSX') {
71
- const nodeIds = args.node ? [args.node] : page.childIds
72
- const jsxStr = nodeIds.length === 1
73
- ? sceneNodeToJSX(nodeIds[0], graph, args.style as JSXFormat)
74
- : selectionToJSX(nodeIds, graph, args.style as JSXFormat)
75
- if (!jsxStr) { printError('Nothing to export (empty page or no visible nodes).'); process.exit(1) }
76
- await writeAndLog(resolve(args.output ?? `${defaultName}.jsx`), jsxStr)
77
- return
94
+ if (args.page && args.node) {
95
+ printError('--page and --node cannot be used together.')
96
+ process.exit(1)
78
97
  }
79
98
 
80
- const ext = format.toLowerCase() === 'jpg' ? 'jpg' : format.toLowerCase()
81
- const output = resolve(args.output ?? `${defaultName}.${ext}`)
99
+ const target = args.node
100
+ ? { scope: 'node' as const, nodeId: args.node }
101
+ : { scope: 'page' as const, pageId: page.id }
82
102
 
83
- if (format === 'SVG') {
84
- const nodeIds = args.node ? [args.node] : page.childIds
85
- const svgStr = renderNodesToSVG(graph, page.id, nodeIds)
86
- if (!svgStr) { printError('Nothing to export (empty page or no visible nodes).'); process.exit(1) }
87
- await writeAndLog(output, svgStr)
88
- return
103
+ if (args.thumbnail) {
104
+ printError('Thumbnail export is not supported by the shared file export path yet.')
105
+ process.exit(1)
89
106
  }
90
107
 
91
- let data: Uint8Array | null
92
- if (args.thumbnail) {
93
- data = await exportThumbnail(graph, page.id, Number(args.width), Number(args.height))
94
- } else {
95
- const nodeIds = args.node ? [args.node] : page.childIds
96
- data = await exportNodes(graph, page.id, nodeIds, {
108
+ const formatId = format.toLowerCase()
109
+ let options: { format?: string; scale?: number; quality?: number } | undefined
110
+ if (format === 'JSX') {
111
+ options = { format: args.style }
112
+ } else if (format === 'PNG' || format === 'JPG' || format === 'WEBP') {
113
+ options = {
114
+ format,
97
115
  scale: Number(args.scale),
98
- format: format as ExportFormat,
99
116
  quality: args.quality ? Number(args.quality) : undefined
100
- })
117
+ }
101
118
  }
102
119
 
103
- if (!data) { printError('Nothing to export (empty page or no visible nodes).'); process.exit(1) }
104
- await writeAndLog(output, data)
120
+ const result = await io.exportContent(formatId, { graph, target }, options)
121
+ const output = resolve(
122
+ args.output ??
123
+ exportFileName(
124
+ defaultName,
125
+ result.extension,
126
+ format === 'PNG' || format === 'JPG' || format === 'WEBP' ? Number(args.scale) : undefined
127
+ )
128
+ )
129
+ await writeAndLog(output, result.data as string | Uint8Array)
130
+ console.log(ok(`Target: ${targetLabel(args.page, args.node)}`))
105
131
  }
106
132
 
107
133
  export default defineCommand({
108
- meta: { description: 'Export a .fig file to PNG, JPG, WEBP, SVG, or JSX' },
134
+ meta: { description: 'Export a document to PNG, JPG, WEBP, SVG, JSX, or .fig' },
109
135
  args: {
110
- file: { type: 'positional', description: '.fig file path (omit to connect to running app)', required: false },
111
- output: { type: 'string', alias: 'o', description: 'Output file path (default: <name>.<format>)', required: false },
112
- format: { type: 'string', alias: 'f', description: 'Export format: png, jpg, webp, svg, jsx (default: png)', default: 'png' },
136
+ file: {
137
+ type: 'positional',
138
+ description: 'Document file path (omit to connect to running app)',
139
+ required: false
140
+ },
141
+ output: {
142
+ type: 'string',
143
+ alias: 'o',
144
+ description: 'Output file path (default: <name>.<format>)',
145
+ required: false
146
+ },
147
+ format: {
148
+ type: 'string',
149
+ alias: 'f',
150
+ description: 'Export format: png, jpg, webp, svg, jsx, fig (default: png)',
151
+ default: 'png'
152
+ },
113
153
  scale: { type: 'string', alias: 's', description: 'Export scale (default: 1)', default: '1' },
114
- quality: { type: 'string', alias: 'q', description: 'Quality 0-100 for JPG/WEBP (default: 90)', required: false },
115
- page: { type: 'string', description: 'Page name (default: first page)', required: false },
116
- node: { type: 'string', description: 'Node ID to export (default: all top-level nodes)', required: false },
117
- style: { type: 'string', description: 'JSX style: openpencil, tailwind (default: openpencil)', default: 'openpencil' },
154
+ quality: {
155
+ type: 'string',
156
+ alias: 'q',
157
+ description: 'Quality 0-100 for JPG/WEBP (default: 90)',
158
+ required: false
159
+ },
160
+ page: {
161
+ type: 'string',
162
+ description: 'Export a specific page by name (default: first page)',
163
+ required: false
164
+ },
165
+ node: {
166
+ type: 'string',
167
+ description: 'Export a specific node by ID (cannot be combined with --page)',
168
+ required: false
169
+ },
170
+ style: {
171
+ type: 'string',
172
+ description: 'JSX style: openpencil, tailwind (default: openpencil)',
173
+ default: 'openpencil'
174
+ },
118
175
  thumbnail: { type: 'boolean', description: 'Export page thumbnail instead of full render' },
119
176
  width: { type: 'string', description: 'Thumbnail width (default: 1920)', default: '1920' },
120
177
  height: { type: 'string', description: 'Thumbnail height (default: 1080)', default: '1080' }
121
178
  },
122
179
  async run({ args }) {
123
- const format = args.format.toUpperCase() as ExportFormat | 'JSX'
180
+ const format = args.format.toUpperCase() as RasterExportFormat | 'SVG' | 'JSX' | 'FIG'
124
181
  if (!ALL_FORMATS.includes(format)) {
125
- printError(`Invalid format "${args.format}". Use png, jpg, webp, svg, or jsx.`)
182
+ printError(`Invalid format "${args.format}". Use png, jpg, webp, svg, jsx, or fig.`)
126
183
  process.exit(1)
127
184
  }
128
185