@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.
- package/LICENSE +21 -0
- package/package.json +7 -5
- package/src/app-client.ts +56 -0
- package/src/commands/analyze/clusters.ts +17 -77
- package/src/commands/analyze/colors.ts +28 -152
- package/src/commands/analyze/spacing.ts +18 -49
- package/src/commands/analyze/typography.ts +23 -65
- package/src/commands/eval.ts +19 -5
- package/src/commands/export.ts +80 -10
- package/src/commands/find.ts +22 -39
- package/src/commands/info.ts +18 -30
- package/src/commands/node.ts +44 -54
- package/src/commands/pages.ts +15 -24
- package/src/commands/tree.ts +30 -34
- package/src/commands/variables.ts +18 -50
|
@@ -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
|
|
6
|
+
import { executeRpcCommand } from '@open-pencil/core'
|
|
6
7
|
|
|
7
|
-
|
|
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:
|
|
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
|
|
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(
|
|
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
|
|
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
|
|
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
|
|
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 =
|
|
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
|
})
|
package/src/commands/eval.ts
CHANGED
|
@@ -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:
|
|
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
|
-
|
|
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 ??
|
|
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
|
}
|
package/src/commands/export.ts
CHANGED
|
@@ -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
|
|
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:
|
|
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 (!
|
|
29
|
-
printError(`Invalid format "${args.format}". Use png, jpg, webp, or
|
|
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
|
-
|
|
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') {
|
package/src/commands/find.ts
CHANGED
|
@@ -1,13 +1,23 @@
|
|
|
1
1
|
import { defineCommand } from 'citty'
|
|
2
2
|
|
|
3
3
|
import { loadDocument } from '../headless'
|
|
4
|
-
import {
|
|
5
|
-
import
|
|
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:
|
|
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
|
|
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
|
|
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(
|
|
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
|
})
|
package/src/commands/info.ts
CHANGED
|
@@ -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
|
-
|
|
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:
|
|
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
|
|
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(
|
|
26
|
+
console.log(JSON.stringify(data, null, 2))
|
|
39
27
|
return
|
|
40
28
|
}
|
|
41
29
|
|
|
42
30
|
console.log('')
|
|
43
|
-
console.log(bold(` ${pages
|
|
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.
|
|
40
|
+
if (data.fonts.length > 0) {
|
|
53
41
|
console.log('')
|
|
54
|
-
console.log(kv('Fonts',
|
|
42
|
+
console.log(kv('Fonts', data.fonts.join(', ')))
|
|
55
43
|
}
|
|
56
44
|
console.log('')
|
|
57
45
|
}
|
package/src/commands/node.ts
CHANGED
|
@@ -1,83 +1,73 @@
|
|
|
1
1
|
import { defineCommand } from 'citty'
|
|
2
2
|
|
|
3
3
|
import { loadDocument } from '../headless'
|
|
4
|
-
import {
|
|
5
|
-
import
|
|
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
|
-
|
|
8
|
-
const details = nodeDetails(node)
|
|
8
|
+
import type { NodeResult } from '@open-pencil/core'
|
|
9
9
|
|
|
10
|
-
|
|
11
|
-
if (
|
|
12
|
-
|
|
13
|
-
|
|
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:
|
|
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
|
|
36
|
-
const node = graph.getNode(args.id)
|
|
24
|
+
const data = await getData(args.file, args.id)
|
|
37
25
|
|
|
38
|
-
if (
|
|
39
|
-
printError(
|
|
26
|
+
if ('error' in data) {
|
|
27
|
+
printError(data.error)
|
|
40
28
|
process.exit(1)
|
|
41
29
|
}
|
|
42
30
|
|
|
43
31
|
if (args.json) {
|
|
44
|
-
|
|
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
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
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
|
-
|
|
74
|
-
|
|
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
|
-
|
|
78
|
-
|
|
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
|
})
|