@open-pencil/cli 0.7.0 → 0.9.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.
@@ -1,44 +1,11 @@
1
1
  import { defineCommand } from 'citty'
2
2
 
3
3
  import { loadDocument } from '../../headless'
4
+ import { isAppMode, requireFile, rpc } from '../../app-client'
4
5
  import { bold, fmtHistogram, fmtSummary } from '../../format'
5
- import type { SceneGraph } from '@open-pencil/core'
6
+ import { executeRpcCommand } from '@open-pencil/core'
6
7
 
7
- interface TypographyStyle {
8
- family: string
9
- size: number
10
- weight: number
11
- lineHeight: string
12
- count: number
13
- }
14
-
15
- function collectTypography(graph: SceneGraph): { styles: TypographyStyle[]; totalTextNodes: number } {
16
- const styleMap = new Map<string, TypographyStyle>()
17
- let totalTextNodes = 0
18
-
19
- for (const node of graph.getAllNodes()) {
20
- if (node.type !== 'TEXT') continue
21
- totalTextNodes++
22
-
23
- const lh = node.lineHeight === null ? 'auto' : `${node.lineHeight}px`
24
- const key = `${node.fontFamily}|${node.fontSize}|${node.fontWeight}|${lh}`
25
-
26
- const existing = styleMap.get(key)
27
- if (existing) {
28
- existing.count++
29
- } else {
30
- styleMap.set(key, {
31
- family: node.fontFamily,
32
- size: node.fontSize,
33
- weight: node.fontWeight,
34
- lineHeight: lh,
35
- count: 1
36
- })
37
- }
38
- }
39
-
40
- return { styles: [...styleMap.values()], totalTextNodes }
41
- }
8
+ import type { AnalyzeTypographyResult } from '@open-pencil/core'
42
9
 
43
10
  function weightName(w: number): string {
44
11
  if (w <= 100) return 'Thin'
@@ -52,88 +19,79 @@ function weightName(w: number): string {
52
19
  return 'Black'
53
20
  }
54
21
 
22
+ async function getData(file?: string): Promise<AnalyzeTypographyResult> {
23
+ if (isAppMode(file)) return rpc<AnalyzeTypographyResult>('analyze_typography')
24
+ const graph = await loadDocument(requireFile(file))
25
+ return executeRpcCommand(graph, 'analyze_typography', {}) as AnalyzeTypographyResult
26
+ }
27
+
55
28
  export default defineCommand({
56
29
  meta: { description: 'Analyze typography usage' },
57
30
  args: {
58
- file: { type: 'positional', description: '.fig file path', required: true },
59
- 'group-by': {
60
- type: 'string',
61
- description: 'Group by: family, size, weight (default: show all styles)'
62
- },
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)' },
63
33
  limit: { type: 'string', description: 'Max styles to show', default: '30' },
64
34
  json: { type: 'boolean', description: 'Output as JSON' }
65
35
  },
66
36
  async run({ args }) {
67
- const graph = await loadDocument(args.file)
37
+ const data = await getData(args.file)
68
38
  const limit = Number(args.limit)
69
39
  const groupBy = args['group-by']
70
- const { styles, totalTextNodes } = collectTypography(graph)
71
40
 
72
41
  if (args.json) {
73
- console.log(JSON.stringify({ styles, totalTextNodes }, null, 2))
42
+ console.log(JSON.stringify(data, null, 2))
74
43
  return
75
44
  }
76
45
 
77
- if (styles.length === 0) {
46
+ if (data.styles.length === 0) {
78
47
  console.log('No text nodes found.')
79
48
  return
80
49
  }
81
50
 
82
- const sorted = styles.sort((a, b) => b.count - a.count)
83
-
84
51
  console.log('')
85
52
 
86
53
  if (groupBy === 'family') {
87
54
  const byFamily = new Map<string, number>()
88
- for (const s of sorted) byFamily.set(s.family, (byFamily.get(s.family) ?? 0) + s.count)
55
+ for (const s of data.styles) byFamily.set(s.family, (byFamily.get(s.family) ?? 0) + s.count)
89
56
  console.log(bold(' Font families'))
90
57
  console.log('')
91
58
  console.log(
92
59
  fmtHistogram(
93
- [...byFamily.entries()]
94
- .sort((a, b) => b[1] - a[1])
95
- .map(([family, count]) => ({ label: family, value: count }))
60
+ [...byFamily.entries()].sort((a, b) => b[1] - a[1]).map(([family, count]) => ({ label: family, value: count }))
96
61
  )
97
62
  )
98
63
  } else if (groupBy === 'size') {
99
64
  const bySize = new Map<number, number>()
100
- for (const s of sorted) bySize.set(s.size, (bySize.get(s.size) ?? 0) + s.count)
65
+ for (const s of data.styles) bySize.set(s.size, (bySize.get(s.size) ?? 0) + s.count)
101
66
  console.log(bold(' Font sizes'))
102
67
  console.log('')
103
68
  console.log(
104
69
  fmtHistogram(
105
- [...bySize.entries()]
106
- .sort((a, b) => a[0] - b[0])
107
- .map(([size, count]) => ({ label: `${size}px`, value: count }))
70
+ [...bySize.entries()].sort((a, b) => a[0] - b[0]).map(([size, count]) => ({ label: `${size}px`, value: count }))
108
71
  )
109
72
  )
110
73
  } else if (groupBy === 'weight') {
111
74
  const byWeight = new Map<number, number>()
112
- for (const s of sorted) byWeight.set(s.weight, (byWeight.get(s.weight) ?? 0) + s.count)
75
+ for (const s of data.styles) byWeight.set(s.weight, (byWeight.get(s.weight) ?? 0) + s.count)
113
76
  console.log(bold(' Font weights'))
114
77
  console.log('')
115
78
  console.log(
116
79
  fmtHistogram(
117
- [...byWeight.entries()]
118
- .sort((a, b) => b[1] - a[1])
119
- .map(([weight, count]) => ({ label: `${weight} ${weightName(weight)}`, value: count }))
80
+ [...byWeight.entries()].sort((a, b) => b[1] - a[1]).map(([weight, count]) => ({ label: `${weight} ${weightName(weight)}`, value: count }))
120
81
  )
121
82
  )
122
83
  } else {
123
84
  console.log(bold(' Typography styles'))
124
85
  console.log('')
125
- const items = sorted.slice(0, limit).map((s) => {
86
+ const items = data.styles.slice(0, limit).map((s) => {
126
87
  const lh = s.lineHeight !== 'auto' ? ` / ${s.lineHeight}` : ''
127
- return {
128
- label: `${s.family} ${s.size}px ${weightName(s.weight)}${lh}`,
129
- value: s.count
130
- }
88
+ return { label: `${s.family} ${s.size}px ${weightName(s.weight)}${lh}`, value: s.count }
131
89
  })
132
90
  console.log(fmtHistogram(items))
133
91
  }
134
92
 
135
93
  console.log('')
136
- console.log(fmtSummary({ 'unique styles': styles.length }) + ` from ${totalTextNodes} text nodes`)
94
+ console.log(fmtSummary({ 'unique styles': data.styles.length, 'text nodes': data.totalTextNodes }))
137
95
  console.log('')
138
96
  }
139
97
  })
@@ -3,11 +3,20 @@ import { defineCommand } from 'citty'
3
3
  import { FigmaAPI } from '@open-pencil/core'
4
4
 
5
5
  import { loadDocument } from '../headless'
6
+ import { isAppMode, requireFile, rpc } from '../app-client'
6
7
  import { printError } from '../format'
7
8
 
9
+ function printResult(value: unknown, json: boolean) {
10
+ if (json || !process.stdout.isTTY) {
11
+ console.log(JSON.stringify(value, null, 2))
12
+ } else {
13
+ console.log(value)
14
+ }
15
+ }
16
+
8
17
  function serializeResult(value: unknown): unknown {
9
18
  if (value === undefined || value === null) return value
10
- if (typeof value === 'object' && value !== null && 'toJSON' in value && typeof value.toJSON === 'function') {
19
+ if (typeof value === 'object' && 'toJSON' in value && typeof value.toJSON === 'function') {
11
20
  return value.toJSON()
12
21
  }
13
22
  if (Array.isArray(value)) return value.map(serializeResult)
@@ -17,11 +26,11 @@ function serializeResult(value: unknown): unknown {
17
26
  export default defineCommand({
18
27
  meta: { description: 'Execute JavaScript with Figma plugin API' },
19
28
  args: {
20
- file: { type: 'positional', description: '.fig file path', required: true },
29
+ file: { type: 'positional', description: '.fig file path (omit to connect to running app)', required: false },
21
30
  code: { type: 'string', alias: 'c', description: 'JavaScript code to execute' },
22
31
  stdin: { type: 'boolean', description: 'Read code from stdin' },
23
32
  write: { type: 'boolean', alias: 'w', description: 'Write changes back to the input file' },
24
- output: { type: 'string', alias: 'o', description: 'Write to a different file' },
33
+ output: { type: 'string', alias: 'o', description: 'Write to a different file', required: false },
25
34
  json: { type: 'boolean', description: 'Output as JSON' },
26
35
  quiet: { type: 'boolean', alias: 'q', description: 'Suppress output' },
27
36
  },
@@ -39,9 +48,19 @@ export default defineCommand({
39
48
  process.exit(1)
40
49
  }
41
50
 
42
- const graph = await loadDocument(args.file)
51
+ if (isAppMode(args.file)) {
52
+ const result = await rpc('eval', { code })
53
+ if (!args.quiet && result !== undefined && result !== null) {
54
+ printResult(result, !!args.json)
55
+ }
56
+ return
57
+ }
58
+
59
+ const file = requireFile(args.file)
60
+ const graph = await loadDocument(file)
43
61
  const figma = new FigmaAPI(graph)
44
62
 
63
+ // eslint-disable-next-line no-empty-function -- needed to get AsyncFunction constructor
45
64
  const AsyncFunction = Object.getPrototypeOf(async function () {}).constructor
46
65
  const wrappedCode = code.trim().startsWith('return')
47
66
  ? code
@@ -57,19 +76,14 @@ export default defineCommand({
57
76
  }
58
77
 
59
78
  if (!args.quiet && result !== undefined) {
60
- const serialized = serializeResult(result)
61
- if (args.json || !process.stdout.isTTY) {
62
- console.log(JSON.stringify(serialized, null, 2))
63
- } else {
64
- console.log(serialized)
65
- }
79
+ printResult(serializeResult(result), !!args.json)
66
80
  }
67
81
 
68
82
  if (args.write || args.output) {
69
83
  const { exportFigFile } = await import('@open-pencil/core')
70
- const outPath = args.output ?? args.file
71
- const data = exportFigFile(graph)
72
- await Bun.write(outPath, data)
84
+ const outPath = args.output ? args.output : file
85
+ const data = await exportFigFile(graph)
86
+ await Bun.write(outPath, new Uint8Array(data))
73
87
  if (!args.quiet) {
74
88
  console.error(`Written to ${outPath}`)
75
89
  }
@@ -1,83 +1,141 @@
1
1
  import { defineCommand } from 'citty'
2
2
  import { basename, extname, resolve } from 'node:path'
3
3
 
4
- import { renderNodesToSVG } from '@open-pencil/core'
4
+ import { renderNodesToSVG, sceneNodeToJSX, selectionToJSX } from '@open-pencil/core'
5
5
 
6
6
  import { loadDocument, loadFonts, exportNodes, exportThumbnail } from '../headless'
7
+ import { isAppMode, requireFile, rpc } from '../app-client'
7
8
  import { ok, printError } from '../format'
8
- import type { ExportFormat } from '@open-pencil/core'
9
+ import type { ExportFormat, JSXFormat } from '@open-pencil/core'
9
10
 
10
11
  const RASTER_FORMATS = ['PNG', 'JPG', 'WEBP']
12
+ const ALL_FORMATS = [...RASTER_FORMATS, 'SVG', 'JSX']
13
+ const JSX_STYLES = ['openpencil', 'tailwind']
14
+
15
+ interface ExportArgs {
16
+ file?: string
17
+ output?: string
18
+ format: string
19
+ scale: string
20
+ quality?: string
21
+ page?: string
22
+ node?: string
23
+ style: string
24
+ thumbnail?: boolean
25
+ width: string
26
+ height: string
27
+ }
28
+
29
+ async function writeAndLog(path: string, content: string | Uint8Array) {
30
+ await Bun.write(path, content)
31
+ const size = typeof content === 'string' ? content.length : content.length
32
+ console.log(ok(`Exported ${path} (${(size / 1024).toFixed(1)} KB)`))
33
+ }
34
+
35
+ async function exportViaApp(format: string, args: ExportArgs) {
36
+ 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) }
39
+ await writeAndLog(resolve(args.output ?? 'export.svg'), result.svg)
40
+ return
41
+ }
42
+
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
48
+ }
49
+
50
+ const result = await rpc<{ base64: string }>('export', {
51
+ nodeIds: args.node ? [args.node] : undefined,
52
+ scale: Number(args.scale),
53
+ format: format.toLowerCase()
54
+ })
55
+ const data = Uint8Array.from(atob(result.base64), (c) => c.charCodeAt(0))
56
+ const ext = format.toLowerCase() === 'jpg' ? 'jpg' : format.toLowerCase()
57
+ await writeAndLog(resolve(args.output ?? `export.${ext}`), data)
58
+ }
59
+
60
+ async function exportFromFile(format: string, args: ExportArgs) {
61
+ const file = requireFile(args.file)
62
+ const graph = await loadDocument(file)
63
+ await loadFonts(graph)
64
+
65
+ const pages = graph.getPages()
66
+ const page = args.page ? pages.find((p) => p.name === args.page) : pages[0]
67
+ if (!page) { printError(`Page "${args.page}" not found.`); process.exit(1) }
68
+
69
+ const defaultName = basename(file, extname(file))
70
+
71
+ if (format === 'JSX') {
72
+ const nodeIds = args.node ? [args.node] : page.childIds
73
+ const jsxStr = nodeIds.length === 1
74
+ ? sceneNodeToJSX(nodeIds[0], graph, args.style as JSXFormat)
75
+ : selectionToJSX(nodeIds, graph, args.style as JSXFormat)
76
+ if (!jsxStr) { printError('Nothing to export (empty page or no visible nodes).'); process.exit(1) }
77
+ await writeAndLog(resolve(args.output ?? `${defaultName}.jsx`), jsxStr)
78
+ return
79
+ }
80
+
81
+ const ext = format.toLowerCase() === 'jpg' ? 'jpg' : format.toLowerCase()
82
+ const output = resolve(args.output ?? `${defaultName}.${ext}`)
83
+
84
+ if (format === 'SVG') {
85
+ const nodeIds = args.node ? [args.node] : page.childIds
86
+ const svgStr = renderNodesToSVG(graph, page.id, nodeIds)
87
+ if (!svgStr) { printError('Nothing to export (empty page or no visible nodes).'); process.exit(1) }
88
+ await writeAndLog(output, svgStr)
89
+ return
90
+ }
91
+
92
+ let data: Uint8Array | null
93
+ if (args.thumbnail) {
94
+ data = await exportThumbnail(graph, page.id, Number(args.width), Number(args.height))
95
+ } else {
96
+ const nodeIds = args.node ? [args.node] : page.childIds
97
+ data = await exportNodes(graph, page.id, nodeIds, {
98
+ scale: Number(args.scale),
99
+ format: format as ExportFormat,
100
+ quality: args.quality ? Number(args.quality) : undefined
101
+ })
102
+ }
103
+
104
+ if (!data) { printError('Nothing to export (empty page or no visible nodes).'); process.exit(1) }
105
+ await writeAndLog(output, data)
106
+ }
11
107
 
12
108
  export default defineCommand({
13
- meta: { description: 'Export a .fig file to PNG, JPG, WEBP, or SVG' },
109
+ meta: { description: 'Export a .fig file to PNG, JPG, WEBP, SVG, or JSX' },
14
110
  args: {
15
- file: { type: 'positional', description: '.fig file path', required: true },
16
- output: { type: 'string', alias: 'o', description: 'Output file path (default: <name>.<format>)' },
17
- format: { type: 'string', alias: 'f', description: 'Export format: png, jpg, webp, svg (default: png)', default: 'png' },
111
+ file: { type: 'positional', description: '.fig file path (omit to connect to running app)', required: false },
112
+ output: { type: 'string', alias: 'o', description: 'Output file path (default: <name>.<format>)', required: false },
113
+ format: { type: 'string', alias: 'f', description: 'Export format: png, jpg, webp, svg, jsx (default: png)', default: 'png' },
18
114
  scale: { type: 'string', alias: 's', description: 'Export scale (default: 1)', default: '1' },
19
- quality: { type: 'string', alias: 'q', description: 'Quality 0-100 for JPG/WEBP (default: 90)' },
20
- page: { type: 'string', description: 'Page name (default: first page)' },
21
- node: { type: 'string', description: 'Node ID to export (default: all top-level nodes)' },
115
+ quality: { type: 'string', alias: 'q', description: 'Quality 0-100 for JPG/WEBP (default: 90)', required: false },
116
+ page: { type: 'string', description: 'Page name (default: first page)', required: false },
117
+ node: { type: 'string', description: 'Node ID to export (default: all top-level nodes)', required: false },
118
+ style: { type: 'string', description: 'JSX style: openpencil, tailwind (default: openpencil)', default: 'openpencil' },
22
119
  thumbnail: { type: 'boolean', description: 'Export page thumbnail instead of full render' },
23
120
  width: { type: 'string', description: 'Thumbnail width (default: 1920)', default: '1920' },
24
121
  height: { type: 'string', description: 'Thumbnail height (default: 1080)', default: '1080' }
25
122
  },
26
123
  async run({ args }) {
27
- const format = args.format.toUpperCase() as ExportFormat
28
- if (![...RASTER_FORMATS, 'SVG'].includes(format)) {
29
- printError(`Invalid format "${args.format}". Use png, jpg, webp, or svg.`)
124
+ const format = args.format.toUpperCase() as ExportFormat | 'JSX'
125
+ if (!ALL_FORMATS.includes(format)) {
126
+ printError(`Invalid format "${args.format}". Use png, jpg, webp, svg, or jsx.`)
30
127
  process.exit(1)
31
128
  }
32
129
 
33
- const graph = await loadDocument(args.file)
34
- await loadFonts(graph)
35
-
36
- const pages = graph.getPages()
37
- const page = args.page
38
- ? pages.find((p) => p.name === args.page)
39
- : pages[0]
40
-
41
- if (!page) {
42
- printError(`Page "${args.page}" not found.`)
130
+ if (format === 'JSX' && !JSX_STYLES.includes(args.style)) {
131
+ printError(`Invalid JSX style "${args.style}". Use openpencil or tailwind.`)
43
132
  process.exit(1)
44
133
  }
45
134
 
46
- const ext = format.toLowerCase() === 'jpg' ? 'jpg' : format.toLowerCase()
47
- const defaultName = basename(args.file, extname(args.file))
48
- const output = resolve(args.output ?? `${defaultName}.${ext}`)
49
-
50
- if (format === 'SVG') {
51
- const nodeIds = args.node ? [args.node] : page.childIds
52
- const svgStr = renderNodesToSVG(graph, page.id, nodeIds)
53
- if (!svgStr) {
54
- printError('Nothing to export (empty page or no visible nodes).')
55
- process.exit(1)
56
- }
57
- await Bun.write(output, svgStr)
58
- console.log(ok(`Exported ${output} (${(svgStr.length / 1024).toFixed(1)} KB)`))
59
- return
60
- }
61
-
62
- let data: Uint8Array | null
63
-
64
- if (args.thumbnail) {
65
- data = await exportThumbnail(graph, page.id, Number(args.width), Number(args.height))
135
+ if (isAppMode(args.file)) {
136
+ await exportViaApp(format, args)
66
137
  } else {
67
- const nodeIds = args.node ? [args.node] : page.childIds
68
- data = await exportNodes(graph, page.id, nodeIds, {
69
- scale: Number(args.scale),
70
- format,
71
- quality: args.quality ? Number(args.quality) : undefined
72
- })
73
- }
74
-
75
- if (!data) {
76
- printError('Nothing to export (empty page or no visible nodes).')
77
- process.exit(1)
138
+ await exportFromFile(format, args)
78
139
  }
79
-
80
- await Bun.write(output, data)
81
- console.log(ok(`Exported ${output} (${(data.length / 1024).toFixed(1)} KB)`))
82
140
  }
83
141
  })
@@ -1,13 +1,23 @@
1
1
  import { defineCommand } from 'citty'
2
2
 
3
3
  import { loadDocument } from '../headless'
4
- import { fmtList, nodeToListItem, printError, bold } from '../format'
5
- import type { SceneNode } from '@open-pencil/core'
4
+ import { isAppMode, requireFile, rpc } from '../app-client'
5
+ import { fmtList, bold, entity, formatType } from '../format'
6
+ import { executeRpcCommand } from '@open-pencil/core'
7
+
8
+ import type { FindNodeResult } from '@open-pencil/core'
9
+
10
+ async function getData(file: string | undefined, args: { name?: string; type?: string; page?: string; limit?: string }): Promise<FindNodeResult[]> {
11
+ const rpcArgs = { name: args.name, type: args.type, page: args.page, limit: args.limit ? Number(args.limit) : undefined }
12
+ if (isAppMode(file)) return rpc<FindNodeResult[]>('find', rpcArgs)
13
+ const graph = await loadDocument(requireFile(file))
14
+ return executeRpcCommand(graph, 'find', rpcArgs) as FindNodeResult[]
15
+ }
6
16
 
7
17
  export default defineCommand({
8
18
  meta: { description: 'Find nodes by name or type' },
9
19
  args: {
10
- file: { type: 'positional', description: '.fig file path', required: true },
20
+ file: { type: 'positional', description: '.fig file path (omit to connect to running app)', required: false },
11
21
  name: { type: 'string', description: 'Node name (partial match, case-insensitive)' },
12
22
  type: { type: 'string', description: 'Node type: FRAME, TEXT, RECTANGLE, INSTANCE, etc.' },
13
23
  page: { type: 'string', description: 'Page name (default: all pages)' },
@@ -15,43 +25,10 @@ export default defineCommand({
15
25
  json: { type: 'boolean', description: 'Output as JSON' }
16
26
  },
17
27
  async run({ args }) {
18
- const graph = await loadDocument(args.file)
19
- const pages = graph.getPages()
20
- const max = Number(args.limit)
21
- const namePattern = args.name?.toLowerCase()
22
- const typeFilter = args.type?.toUpperCase()
23
-
24
- const results: SceneNode[] = []
25
-
26
- const searchPage = (pageNode: SceneNode) => {
27
- const walk = (id: string) => {
28
- if (results.length >= max) return
29
- const node = graph.getNode(id)
30
- if (!node) return
31
- const matchesName = !namePattern || node.name.toLowerCase().includes(namePattern)
32
- const matchesType = !typeFilter || node.type === typeFilter
33
- if (matchesName && matchesType) results.push(node)
34
- for (const childId of node.childIds) walk(childId)
35
- }
36
- for (const childId of pageNode.childIds) walk(childId)
37
- }
38
-
39
- if (args.page) {
40
- const page = pages.find((p) => p.name === args.page)
41
- if (!page) {
42
- printError(`Page "${args.page}" not found.`)
43
- process.exit(1)
44
- }
45
- searchPage(page)
46
- } else {
47
- for (const page of pages) searchPage(page)
48
- }
28
+ const results = await getData(args.file, args)
49
29
 
50
30
  if (args.json) {
51
- console.log(JSON.stringify(results.map((n) => ({
52
- id: n.id, name: n.name, type: n.type,
53
- width: Math.round(n.width), height: Math.round(n.height)
54
- })), null, 2))
31
+ console.log(JSON.stringify(results, null, 2))
55
32
  return
56
33
  }
57
34
 
@@ -63,7 +40,13 @@ export default defineCommand({
63
40
  console.log('')
64
41
  console.log(bold(` Found ${results.length} node${results.length > 1 ? 's' : ''}`))
65
42
  console.log('')
66
- console.log(fmtList(results.map(nodeToListItem)))
43
+ console.log(
44
+ fmtList(
45
+ results.map((n) => ({
46
+ header: entity(formatType(n.type), n.name, n.id)
47
+ }))
48
+ )
49
+ )
67
50
  console.log('')
68
51
  }
69
52
  })
@@ -1,57 +1,45 @@
1
1
  import { defineCommand } from 'citty'
2
2
 
3
3
  import { loadDocument } from '../headless'
4
+ import { isAppMode, requireFile, rpc } from '../app-client'
4
5
  import { bold, fmtHistogram, fmtSummary, kv } from '../format'
5
- import type { SceneNode } from '@open-pencil/core'
6
+
7
+ import type { InfoResult } from '@open-pencil/core'
8
+ import { executeRpcCommand } from '@open-pencil/core'
9
+
10
+ async function getData(file?: string): Promise<InfoResult> {
11
+ if (isAppMode(file)) return rpc<InfoResult>('info')
12
+ const graph = await loadDocument(requireFile(file))
13
+ return executeRpcCommand(graph, 'info', undefined) as InfoResult
14
+ }
6
15
 
7
16
  export default defineCommand({
8
17
  meta: { description: 'Show document info (pages, node counts, fonts)' },
9
18
  args: {
10
- file: { type: 'positional', description: '.fig file path', required: true },
19
+ file: { type: 'positional', description: '.fig file path (omit to connect to running app)', required: false },
11
20
  json: { type: 'boolean', description: 'Output as JSON' }
12
21
  },
13
22
  async run({ args }) {
14
- const graph = await loadDocument(args.file)
15
- const pages = graph.getPages()
16
-
17
- let totalNodes = 0
18
- const types: Record<string, number> = {}
19
- const fonts = new Set<string>()
20
- const pageCounts: Record<string, number> = {}
21
-
22
- for (const page of pages) {
23
- let pageCount = 0
24
- const walk = (id: string) => {
25
- const node = graph.getNode(id) as SceneNode | undefined
26
- if (!node) return
27
- totalNodes++
28
- pageCount++
29
- types[node.type] = (types[node.type] ?? 0) + 1
30
- if (node.fontFamily) fonts.add(node.fontFamily)
31
- for (const childId of node.childIds) walk(childId)
32
- }
33
- for (const childId of page.childIds) walk(childId)
34
- pageCounts[page.name] = pageCount
35
- }
23
+ const data = await getData(args.file)
36
24
 
37
25
  if (args.json) {
38
- console.log(JSON.stringify({ pages: pages.length, totalNodes, types, fonts: [...fonts].sort(), pageCounts }, null, 2))
26
+ console.log(JSON.stringify(data, null, 2))
39
27
  return
40
28
  }
41
29
 
42
30
  console.log('')
43
- console.log(bold(` ${pages.length} pages, ${totalNodes} nodes`))
31
+ console.log(bold(` ${data.pages} pages, ${data.totalNodes} nodes`))
44
32
  console.log('')
45
33
 
46
- const pageItems = Object.entries(pageCounts).map(([label, value]) => ({ label, value }))
34
+ const pageItems = Object.entries(data.pageCounts).map(([label, value]) => ({ label, value }))
47
35
  console.log(fmtHistogram(pageItems, { unit: 'nodes' }))
48
36
 
49
37
  console.log('')
50
- console.log(fmtSummary(types))
38
+ console.log(fmtSummary(data.types))
51
39
 
52
- if (fonts.size > 0) {
40
+ if (data.fonts.length > 0) {
53
41
  console.log('')
54
- console.log(kv('Fonts', [...fonts].sort().join(', ')))
42
+ console.log(kv('Fonts', data.fonts.join(', ')))
55
43
  }
56
44
  console.log('')
57
45
  }