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