@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.
- package/package.json +5 -3
- package/src/app-client.ts +56 -0
- package/src/commands/analyze/clusters.ts +23 -82
- 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 +27 -13
- package/src/commands/export.ts +114 -56
- 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 +16 -25
- package/src/commands/query.ts +90 -0
- package/src/commands/tree.ts +30 -34
- package/src/commands/variables.ts +19 -51
- package/src/format.ts +2 -2
- package/src/headless.ts +2 -2
- package/src/index.ts +3 -1
|
@@ -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
|
|
94
|
+
console.log(fmtSummary({ 'unique styles': data.styles.length, 'text nodes': data.totalTextNodes }))
|
|
137
95
|
console.log('')
|
|
138
96
|
}
|
|
139
97
|
})
|
package/src/commands/eval.ts
CHANGED
|
@@ -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' &&
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
}
|
package/src/commands/export.ts
CHANGED
|
@@ -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
|
|
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:
|
|
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 (!
|
|
29
|
-
printError(`Invalid format "${args.format}". Use png, jpg, webp, or
|
|
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
|
-
|
|
34
|
-
|
|
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
|
-
|
|
47
|
-
|
|
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
|
-
|
|
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
|
})
|
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, 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
|
}
|