@open-pencil/cli 0.7.0 → 0.8.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 }) + ` from ${data.totalTextNodes} text nodes`)
137
95
  console.log('')
138
96
  }
139
97
  })
@@ -3,6 +3,7 @@ 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
 
8
9
  function serializeResult(value: unknown): unknown {
@@ -17,7 +18,7 @@ function serializeResult(value: unknown): unknown {
17
18
  export default defineCommand({
18
19
  meta: { description: 'Execute JavaScript with Figma plugin API' },
19
20
  args: {
20
- file: { type: 'positional', description: '.fig file path', required: true },
21
+ file: { type: 'positional', description: '.fig file path (omit to connect to running app)', required: false },
21
22
  code: { type: 'string', alias: 'c', description: 'JavaScript code to execute' },
22
23
  stdin: { type: 'boolean', description: 'Read code from stdin' },
23
24
  write: { type: 'boolean', alias: 'w', description: 'Write changes back to the input file' },
@@ -39,7 +40,20 @@ export default defineCommand({
39
40
  process.exit(1)
40
41
  }
41
42
 
42
- const graph = await loadDocument(args.file)
43
+ if (isAppMode(args.file)) {
44
+ const result = await rpc<unknown>('eval', { code })
45
+ if (!args.quiet && result !== undefined && result !== null) {
46
+ if (args.json || !process.stdout.isTTY) {
47
+ console.log(JSON.stringify(result, null, 2))
48
+ } else {
49
+ console.log(result)
50
+ }
51
+ }
52
+ return
53
+ }
54
+
55
+ const file = requireFile(args.file)
56
+ const graph = await loadDocument(file)
43
57
  const figma = new FigmaAPI(graph)
44
58
 
45
59
  const AsyncFunction = Object.getPrototypeOf(async function () {}).constructor
@@ -67,9 +81,9 @@ export default defineCommand({
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 ?? 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,36 +1,87 @@
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']
11
14
 
12
15
  export default defineCommand({
13
- meta: { description: 'Export a .fig file to PNG, JPG, WEBP, or SVG' },
16
+ meta: { description: 'Export a .fig file to PNG, JPG, WEBP, SVG, or JSX' },
14
17
  args: {
15
- file: { type: 'positional', description: '.fig file path', required: true },
18
+ file: { type: 'positional', description: '.fig file path (omit to connect to running app)', required: false },
16
19
  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' },
20
+ format: { type: 'string', alias: 'f', description: 'Export format: png, jpg, webp, svg, jsx (default: png)', default: 'png' },
18
21
  scale: { type: 'string', alias: 's', description: 'Export scale (default: 1)', default: '1' },
19
22
  quality: { type: 'string', alias: 'q', description: 'Quality 0-100 for JPG/WEBP (default: 90)' },
20
23
  page: { type: 'string', description: 'Page name (default: first page)' },
21
24
  node: { type: 'string', description: 'Node ID to export (default: all top-level nodes)' },
25
+ style: { type: 'string', description: 'JSX style: openpencil, tailwind (default: openpencil)', default: 'openpencil' },
22
26
  thumbnail: { type: 'boolean', description: 'Export page thumbnail instead of full render' },
23
27
  width: { type: 'string', description: 'Thumbnail width (default: 1920)', default: '1920' },
24
28
  height: { type: 'string', description: 'Thumbnail height (default: 1080)', default: '1080' }
25
29
  },
26
30
  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.`)
31
+ const format = args.format.toUpperCase() as ExportFormat | 'JSX'
32
+ if (!ALL_FORMATS.includes(format)) {
33
+ printError(`Invalid format "${args.format}". Use png, jpg, webp, svg, or jsx.`)
30
34
  process.exit(1)
31
35
  }
32
36
 
33
- const graph = await loadDocument(args.file)
37
+ if (format === 'JSX' && !JSX_STYLES.includes(args.style)) {
38
+ printError(`Invalid JSX style "${args.style}". Use openpencil or tailwind.`)
39
+ process.exit(1)
40
+ }
41
+
42
+ if (isAppMode(args.file)) {
43
+ if (format === 'SVG') {
44
+ const result = await rpc<{ svg: string }>('tool', { name: 'export_svg', args: { ids: args.node ? [args.node] : undefined } })
45
+ if (!result.svg) {
46
+ printError('Nothing to export.')
47
+ process.exit(1)
48
+ }
49
+ const output = resolve(args.output ?? 'export.svg')
50
+ await Bun.write(output, result.svg)
51
+ console.log(ok(`Exported ${output} (${(result.svg.length / 1024).toFixed(1)} KB)`))
52
+ return
53
+ }
54
+
55
+ if (format === 'JSX') {
56
+ const result = await rpc<{ jsx: string }>('export_jsx', {
57
+ nodeIds: args.node ? [args.node] : undefined,
58
+ style: args.style
59
+ })
60
+ if (!result.jsx) {
61
+ printError('Nothing to export.')
62
+ process.exit(1)
63
+ }
64
+ const output = resolve(args.output ?? 'export.jsx')
65
+ await Bun.write(output, result.jsx)
66
+ console.log(ok(`Exported ${output} (${(result.jsx.length / 1024).toFixed(1)} KB)`))
67
+ return
68
+ }
69
+
70
+ const result = await rpc<{ base64: string }>('export', {
71
+ nodeIds: args.node ? [args.node] : undefined,
72
+ scale: Number(args.scale),
73
+ format: format.toLowerCase()
74
+ })
75
+ const data = Uint8Array.from(atob(result.base64), (c) => c.charCodeAt(0))
76
+ const ext = format.toLowerCase() === 'jpg' ? 'jpg' : format.toLowerCase()
77
+ const output = resolve(args.output ?? `export.${ext}`)
78
+ await Bun.write(output, data)
79
+ console.log(ok(`Exported ${output} (${(data.length / 1024).toFixed(1)} KB)`))
80
+ return
81
+ }
82
+
83
+ const file = requireFile(args.file)
84
+ const graph = await loadDocument(file)
34
85
  await loadFonts(graph)
35
86
 
36
87
  const pages = graph.getPages()
@@ -43,8 +94,27 @@ export default defineCommand({
43
94
  process.exit(1)
44
95
  }
45
96
 
97
+ const defaultName = basename(file, extname(file))
98
+
99
+ if (format === 'JSX') {
100
+ const jsxFormat = args.style as JSXFormat
101
+ const nodeIds = args.node ? [args.node] : page.childIds
102
+ const jsxStr = nodeIds.length === 1
103
+ ? sceneNodeToJSX(nodeIds[0], graph, jsxFormat)
104
+ : selectionToJSX(nodeIds, graph, jsxFormat)
105
+
106
+ if (!jsxStr) {
107
+ printError('Nothing to export (empty page or no visible nodes).')
108
+ process.exit(1)
109
+ }
110
+
111
+ const output = resolve(args.output ?? `${defaultName}.jsx`)
112
+ await Bun.write(output, jsxStr)
113
+ console.log(ok(`Exported ${output} (${(jsxStr.length / 1024).toFixed(1)} KB)`))
114
+ return
115
+ }
116
+
46
117
  const ext = format.toLowerCase() === 'jpg' ? 'jpg' : format.toLowerCase()
47
- const defaultName = basename(args.file, extname(args.file))
48
118
  const output = resolve(args.output ?? `${defaultName}.${ext}`)
49
119
 
50
120
  if (format === 'SVG') {
@@ -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, printError, 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
  }
@@ -1,83 +1,73 @@
1
1
  import { defineCommand } from 'citty'
2
2
 
3
3
  import { loadDocument } from '../headless'
4
- import { fmtNode, fmtList, nodeToData, nodeDetails, formatType, printError } from '../format'
5
- import type { SceneNode, SceneGraph } from '@open-pencil/core'
4
+ import { isAppMode, requireFile, rpc } from '../app-client'
5
+ import { fmtNode, printError, formatType } from '../format'
6
+ import { executeRpcCommand, colorToHex } from '@open-pencil/core'
6
7
 
7
- function fullNodeDetails(graph: SceneGraph, node: SceneNode): Record<string, unknown> {
8
- const details = nodeDetails(node)
8
+ import type { NodeResult } from '@open-pencil/core'
9
9
 
10
- const parent = node.parentId ? graph.getNode(node.parentId) : undefined
11
- if (parent) details.parent = `${parent.name} (${parent.id})`
12
-
13
- if (node.text) {
14
- details.text = node.text.length > 80 ? node.text.slice(0, 80) + '…' : node.text
15
- }
16
-
17
- if (node.childIds.length > 0) details.children = node.childIds.length
18
-
19
- for (const [field, varId] of Object.entries(node.boundVariables)) {
20
- const variable = graph.variables.get(varId)
21
- details[`var:${field}`] = variable?.name ?? varId
22
- }
23
-
24
- return details
10
+ async function getData(file: string | undefined, id: string): Promise<NodeResult | { error: string }> {
11
+ if (isAppMode(file)) return rpc<NodeResult>('node', { id })
12
+ const graph = await loadDocument(requireFile(file))
13
+ return executeRpcCommand(graph, 'node', { id }) as NodeResult | { error: string }
25
14
  }
26
15
 
27
16
  export default defineCommand({
28
17
  meta: { description: 'Show detailed node properties by ID' },
29
18
  args: {
30
- 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 },
31
20
  id: { type: 'string', description: 'Node ID', required: true },
32
21
  json: { type: 'boolean', description: 'Output as JSON' }
33
22
  },
34
23
  async run({ args }) {
35
- const graph = await loadDocument(args.file)
36
- const node = graph.getNode(args.id)
24
+ const data = await getData(args.file, args.id)
37
25
 
38
- if (!node) {
39
- printError(`Node "${args.id}" not found.`)
26
+ if ('error' in data) {
27
+ printError(data.error)
40
28
  process.exit(1)
41
29
  }
42
30
 
43
31
  if (args.json) {
44
- const { childIds, parentId, ...rest } = node
45
- const children = childIds.length
46
- const parent = parentId ? graph.getNode(parentId) : undefined
47
- console.log(
48
- JSON.stringify(
49
- {
50
- ...rest,
51
- parent: parent ? { id: parent.id, name: parent.name, type: parent.type } : null,
52
- children
53
- },
54
- null,
55
- 2
56
- )
57
- )
32
+ console.log(JSON.stringify(data, null, 2))
58
33
  return
59
34
  }
60
35
 
61
- console.log('')
62
- console.log(fmtNode(nodeToData(node), fullNodeDetails(graph, node)))
63
-
64
- if (node.childIds.length > 0) {
65
- const children = node.childIds
66
- .map((id) => graph.getNode(id))
67
- .filter((n): n is SceneNode => n !== undefined)
68
- .slice(0, 10)
69
- .map((child) => ({
70
- header: `[${formatType(child.type)}] "${child.name}" (${child.id})`
71
- }))
36
+ const nodeData = {
37
+ type: formatType(data.type),
38
+ name: data.name,
39
+ id: data.id,
40
+ width: data.width,
41
+ height: data.height,
42
+ x: data.x,
43
+ y: data.y
44
+ }
72
45
 
73
- if (node.childIds.length > 10) {
74
- children.push({ header: `… and ${node.childIds.length - 10} more` })
46
+ const details: Record<string, unknown> = {}
47
+ if (data.parent) details.parent = `${data.parent.name} (${data.parent.id})`
48
+ if (data.text) details.text = data.text
49
+ if (data.fills.length > 0) {
50
+ const solid = (data.fills as Array<{ type: string; visible: boolean; color: { r: number; g: number; b: number; a: number }; opacity: number }>)
51
+ .find((f) => f.type === 'SOLID' && f.visible)
52
+ if (solid) {
53
+ const hex = colorToHex(solid.color)
54
+ details.fill = solid.opacity < 1 ? `${hex} ${Math.round(solid.opacity * 100)}%` : hex
75
55
  }
76
-
77
- console.log('')
78
- console.log(fmtList(children, { compact: true }))
56
+ }
57
+ if (data.cornerRadius) details.radius = `${data.cornerRadius}px`
58
+ if (data.rotation) details.rotate = `${Math.round(data.rotation)}°`
59
+ if (data.opacity < 1) details.opacity = data.opacity
60
+ if (!data.visible) details.visible = false
61
+ if (data.locked) details.locked = true
62
+ if (data.fontFamily) details.font = `${data.fontSize}px ${data.fontFamily}`
63
+ if (data.layoutMode !== 'NONE') details.layout = data.layoutMode.toLowerCase()
64
+ if (data.children > 0) details.children = data.children
65
+ for (const [field, name] of Object.entries(data.boundVariables)) {
66
+ details[`var:${field}`] = name
79
67
  }
80
68
 
81
69
  console.log('')
70
+ console.log(fmtNode(nodeData, details))
71
+ console.log('')
82
72
  }
83
73
  })