@open-pencil/cli 0.1.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 +27 -0
- package/src/commands/analyze/clusters.ts +171 -0
- package/src/commands/analyze/colors.ts +203 -0
- package/src/commands/analyze/index.ts +16 -0
- package/src/commands/analyze/spacing.ts +119 -0
- package/src/commands/analyze/typography.ts +139 -0
- package/src/commands/eval.ts +78 -0
- package/src/commands/export.ts +67 -0
- package/src/commands/find.ts +69 -0
- package/src/commands/info.ts +58 -0
- package/src/commands/node.ts +83 -0
- package/src/commands/pages.ts +53 -0
- package/src/commands/tree.ts +63 -0
- package/src/commands/variables.ts +95 -0
- package/src/format.ts +156 -0
- package/src/headless.ts +76 -0
- package/src/index.ts +33 -0
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { defineCommand } from 'citty'
|
|
2
|
+
|
|
3
|
+
import { FigmaAPI } from '@open-pencil/core'
|
|
4
|
+
|
|
5
|
+
import { loadDocument } from '../headless'
|
|
6
|
+
import { printError } from '../format'
|
|
7
|
+
|
|
8
|
+
function serializeResult(value: unknown): unknown {
|
|
9
|
+
if (value === undefined || value === null) return value
|
|
10
|
+
if (typeof value === 'object' && value !== null && 'toJSON' in value && typeof value.toJSON === 'function') {
|
|
11
|
+
return value.toJSON()
|
|
12
|
+
}
|
|
13
|
+
if (Array.isArray(value)) return value.map(serializeResult)
|
|
14
|
+
return value
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export default defineCommand({
|
|
18
|
+
meta: { description: 'Execute JavaScript with Figma plugin API' },
|
|
19
|
+
args: {
|
|
20
|
+
file: { type: 'positional', description: '.fig file path', required: true },
|
|
21
|
+
code: { type: 'string', alias: 'c', description: 'JavaScript code to execute' },
|
|
22
|
+
stdin: { type: 'boolean', description: 'Read code from stdin' },
|
|
23
|
+
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' },
|
|
25
|
+
json: { type: 'boolean', description: 'Output as JSON' },
|
|
26
|
+
quiet: { type: 'boolean', alias: 'q', description: 'Suppress output' },
|
|
27
|
+
},
|
|
28
|
+
async run({ args }) {
|
|
29
|
+
let code = args.code
|
|
30
|
+
|
|
31
|
+
if (args.stdin) {
|
|
32
|
+
const chunks: Buffer[] = []
|
|
33
|
+
for await (const chunk of process.stdin) chunks.push(chunk as Buffer)
|
|
34
|
+
code = Buffer.concat(chunks).toString('utf-8')
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
if (!code) {
|
|
38
|
+
printError('Provide code via --code or --stdin')
|
|
39
|
+
process.exit(1)
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const graph = await loadDocument(args.file)
|
|
43
|
+
const figma = new FigmaAPI(graph)
|
|
44
|
+
|
|
45
|
+
const AsyncFunction = Object.getPrototypeOf(async function () {}).constructor
|
|
46
|
+
const wrappedCode = code.trim().startsWith('return')
|
|
47
|
+
? code
|
|
48
|
+
: `return (async () => { ${code} })()`
|
|
49
|
+
|
|
50
|
+
let result: unknown
|
|
51
|
+
try {
|
|
52
|
+
const fn = new AsyncFunction('figma', wrappedCode)
|
|
53
|
+
result = await fn(figma)
|
|
54
|
+
} catch (err) {
|
|
55
|
+
printError(err instanceof Error ? err.message : String(err))
|
|
56
|
+
process.exit(1)
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
if (!args.quiet && result !== undefined) {
|
|
60
|
+
const serialized = serializeResult(result)
|
|
61
|
+
if (args.json || !process.stdout.isTTY) {
|
|
62
|
+
console.log(JSON.stringify(serialized, null, 2))
|
|
63
|
+
} else {
|
|
64
|
+
console.log(serialized)
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
if (args.write || args.output) {
|
|
69
|
+
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)
|
|
73
|
+
if (!args.quiet) {
|
|
74
|
+
console.error(`Written to ${outPath}`)
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
},
|
|
78
|
+
})
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { defineCommand } from 'citty'
|
|
2
|
+
import { basename, extname, resolve } from 'node:path'
|
|
3
|
+
|
|
4
|
+
import { loadDocument, loadFonts, exportNodes, exportThumbnail } from '../headless'
|
|
5
|
+
import { ok, printError } from '../format'
|
|
6
|
+
import type { ExportFormat } from '@open-pencil/core'
|
|
7
|
+
|
|
8
|
+
export default defineCommand({
|
|
9
|
+
meta: { description: 'Export a .fig file to PNG, JPG, or WEBP' },
|
|
10
|
+
args: {
|
|
11
|
+
file: { type: 'positional', description: '.fig file path', required: true },
|
|
12
|
+
output: { type: 'string', alias: 'o', description: 'Output file path (default: <name>.<format>)' },
|
|
13
|
+
format: { type: 'string', alias: 'f', description: 'Export format: png, jpg, webp (default: png)', default: 'png' },
|
|
14
|
+
scale: { type: 'string', alias: 's', description: 'Export scale (default: 1)', default: '1' },
|
|
15
|
+
quality: { type: 'string', alias: 'q', description: 'Quality 0-100 for JPG/WEBP (default: 90)' },
|
|
16
|
+
page: { type: 'string', description: 'Page name (default: first page)' },
|
|
17
|
+
node: { type: 'string', description: 'Node ID to export (default: all top-level nodes)' },
|
|
18
|
+
thumbnail: { type: 'boolean', description: 'Export page thumbnail instead of full render' },
|
|
19
|
+
width: { type: 'string', description: 'Thumbnail width (default: 1920)', default: '1920' },
|
|
20
|
+
height: { type: 'string', description: 'Thumbnail height (default: 1080)', default: '1080' }
|
|
21
|
+
},
|
|
22
|
+
async run({ args }) {
|
|
23
|
+
const format = args.format.toUpperCase() as ExportFormat
|
|
24
|
+
if (!['PNG', 'JPG', 'WEBP'].includes(format)) {
|
|
25
|
+
printError(`Invalid format "${args.format}". Use png, jpg, or webp.`)
|
|
26
|
+
process.exit(1)
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const graph = await loadDocument(args.file)
|
|
30
|
+
await loadFonts(graph)
|
|
31
|
+
|
|
32
|
+
const pages = graph.getPages()
|
|
33
|
+
const page = args.page
|
|
34
|
+
? pages.find((p) => p.name === args.page)
|
|
35
|
+
: pages[0]
|
|
36
|
+
|
|
37
|
+
if (!page) {
|
|
38
|
+
printError(`Page "${args.page}" not found.`)
|
|
39
|
+
process.exit(1)
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const ext = format.toLowerCase() === 'jpg' ? 'jpg' : format.toLowerCase()
|
|
43
|
+
const defaultName = basename(args.file, extname(args.file))
|
|
44
|
+
const output = resolve(args.output ?? `${defaultName}.${ext}`)
|
|
45
|
+
|
|
46
|
+
let data: Uint8Array | null
|
|
47
|
+
|
|
48
|
+
if (args.thumbnail) {
|
|
49
|
+
data = await exportThumbnail(graph, page.id, Number(args.width), Number(args.height))
|
|
50
|
+
} else {
|
|
51
|
+
const nodeIds = args.node ? [args.node] : page.childIds
|
|
52
|
+
data = await exportNodes(graph, page.id, nodeIds, {
|
|
53
|
+
scale: Number(args.scale),
|
|
54
|
+
format,
|
|
55
|
+
quality: args.quality ? Number(args.quality) : undefined
|
|
56
|
+
})
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
if (!data) {
|
|
60
|
+
printError('Nothing to export (empty page or no visible nodes).')
|
|
61
|
+
process.exit(1)
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
await Bun.write(output, data)
|
|
65
|
+
console.log(ok(`Exported ${output} (${(data.length / 1024).toFixed(1)} KB)`))
|
|
66
|
+
}
|
|
67
|
+
})
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { defineCommand } from 'citty'
|
|
2
|
+
|
|
3
|
+
import { loadDocument } from '../headless'
|
|
4
|
+
import { fmtList, nodeToListItem, printError, bold } from '../format'
|
|
5
|
+
import type { SceneNode } from '@open-pencil/core'
|
|
6
|
+
|
|
7
|
+
export default defineCommand({
|
|
8
|
+
meta: { description: 'Find nodes by name or type' },
|
|
9
|
+
args: {
|
|
10
|
+
file: { type: 'positional', description: '.fig file path', required: true },
|
|
11
|
+
name: { type: 'string', description: 'Node name (partial match, case-insensitive)' },
|
|
12
|
+
type: { type: 'string', description: 'Node type: FRAME, TEXT, RECTANGLE, INSTANCE, etc.' },
|
|
13
|
+
page: { type: 'string', description: 'Page name (default: all pages)' },
|
|
14
|
+
limit: { type: 'string', description: 'Max results (default: 100)', default: '100' },
|
|
15
|
+
json: { type: 'boolean', description: 'Output as JSON' }
|
|
16
|
+
},
|
|
17
|
+
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
|
+
}
|
|
49
|
+
|
|
50
|
+
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))
|
|
55
|
+
return
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
if (results.length === 0) {
|
|
59
|
+
console.log('No nodes found.')
|
|
60
|
+
return
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
console.log('')
|
|
64
|
+
console.log(bold(` Found ${results.length} node${results.length > 1 ? 's' : ''}`))
|
|
65
|
+
console.log('')
|
|
66
|
+
console.log(fmtList(results.map(nodeToListItem)))
|
|
67
|
+
console.log('')
|
|
68
|
+
}
|
|
69
|
+
})
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { defineCommand } from 'citty'
|
|
2
|
+
|
|
3
|
+
import { loadDocument } from '../headless'
|
|
4
|
+
import { bold, fmtHistogram, fmtSummary, kv } from '../format'
|
|
5
|
+
import type { SceneNode } from '@open-pencil/core'
|
|
6
|
+
|
|
7
|
+
export default defineCommand({
|
|
8
|
+
meta: { description: 'Show document info (pages, node counts, fonts)' },
|
|
9
|
+
args: {
|
|
10
|
+
file: { type: 'positional', description: '.fig file path', required: true },
|
|
11
|
+
json: { type: 'boolean', description: 'Output as JSON' }
|
|
12
|
+
},
|
|
13
|
+
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
|
+
}
|
|
36
|
+
|
|
37
|
+
if (args.json) {
|
|
38
|
+
console.log(JSON.stringify({ pages: pages.length, totalNodes, types, fonts: [...fonts].sort(), pageCounts }, null, 2))
|
|
39
|
+
return
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
console.log('')
|
|
43
|
+
console.log(bold(` ${pages.length} pages, ${totalNodes} nodes`))
|
|
44
|
+
console.log('')
|
|
45
|
+
|
|
46
|
+
const pageItems = Object.entries(pageCounts).map(([label, value]) => ({ label, value }))
|
|
47
|
+
console.log(fmtHistogram(pageItems, { unit: 'nodes' }))
|
|
48
|
+
|
|
49
|
+
console.log('')
|
|
50
|
+
console.log(fmtSummary(types))
|
|
51
|
+
|
|
52
|
+
if (fonts.size > 0) {
|
|
53
|
+
console.log('')
|
|
54
|
+
console.log(kv('Fonts', [...fonts].sort().join(', ')))
|
|
55
|
+
}
|
|
56
|
+
console.log('')
|
|
57
|
+
}
|
|
58
|
+
})
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { defineCommand } from 'citty'
|
|
2
|
+
|
|
3
|
+
import { loadDocument } from '../headless'
|
|
4
|
+
import { fmtNode, fmtList, nodeToData, nodeDetails, formatType, printError } from '../format'
|
|
5
|
+
import type { SceneNode, SceneGraph } from '@open-pencil/core'
|
|
6
|
+
|
|
7
|
+
function fullNodeDetails(graph: SceneGraph, node: SceneNode): Record<string, unknown> {
|
|
8
|
+
const details = nodeDetails(node)
|
|
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
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export default defineCommand({
|
|
28
|
+
meta: { description: 'Show detailed node properties by ID' },
|
|
29
|
+
args: {
|
|
30
|
+
file: { type: 'positional', description: '.fig file path', required: true },
|
|
31
|
+
id: { type: 'string', description: 'Node ID', required: true },
|
|
32
|
+
json: { type: 'boolean', description: 'Output as JSON' }
|
|
33
|
+
},
|
|
34
|
+
async run({ args }) {
|
|
35
|
+
const graph = await loadDocument(args.file)
|
|
36
|
+
const node = graph.getNode(args.id)
|
|
37
|
+
|
|
38
|
+
if (!node) {
|
|
39
|
+
printError(`Node "${args.id}" not found.`)
|
|
40
|
+
process.exit(1)
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
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
|
+
)
|
|
58
|
+
return
|
|
59
|
+
}
|
|
60
|
+
|
|
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
|
+
}))
|
|
72
|
+
|
|
73
|
+
if (node.childIds.length > 10) {
|
|
74
|
+
children.push({ header: `… and ${node.childIds.length - 10} more` })
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
console.log('')
|
|
78
|
+
console.log(fmtList(children, { compact: true }))
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
console.log('')
|
|
82
|
+
}
|
|
83
|
+
})
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { defineCommand } from 'citty'
|
|
2
|
+
|
|
3
|
+
import { loadDocument } from '../headless'
|
|
4
|
+
import { bold, fmtList, entity, formatType } from '../format'
|
|
5
|
+
|
|
6
|
+
export default defineCommand({
|
|
7
|
+
meta: { description: 'List pages in a .fig file' },
|
|
8
|
+
args: {
|
|
9
|
+
file: { type: 'positional', description: '.fig file path', required: true },
|
|
10
|
+
json: { type: 'boolean', description: 'Output as JSON' }
|
|
11
|
+
},
|
|
12
|
+
async run({ args }) {
|
|
13
|
+
const graph = await loadDocument(args.file)
|
|
14
|
+
const pages = graph.getPages()
|
|
15
|
+
|
|
16
|
+
const countNodes = (pageId: string): number => {
|
|
17
|
+
let count = 0
|
|
18
|
+
const walk = (id: string) => {
|
|
19
|
+
count++
|
|
20
|
+
const n = graph.getNode(id)
|
|
21
|
+
if (n) for (const cid of n.childIds) walk(cid)
|
|
22
|
+
}
|
|
23
|
+
const page = graph.getNode(pageId)
|
|
24
|
+
if (page) for (const cid of page.childIds) walk(cid)
|
|
25
|
+
return count
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
if (args.json) {
|
|
29
|
+
console.log(
|
|
30
|
+
JSON.stringify(
|
|
31
|
+
pages.map((p) => ({ id: p.id, name: p.name, nodes: countNodes(p.id) })),
|
|
32
|
+
null,
|
|
33
|
+
2
|
|
34
|
+
)
|
|
35
|
+
)
|
|
36
|
+
return
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
console.log('')
|
|
40
|
+
console.log(bold(` ${pages.length} page${pages.length !== 1 ? 's' : ''}`))
|
|
41
|
+
console.log('')
|
|
42
|
+
console.log(
|
|
43
|
+
fmtList(
|
|
44
|
+
pages.map((page) => ({
|
|
45
|
+
header: entity(formatType(page.type), page.name, page.id),
|
|
46
|
+
details: { nodes: countNodes(page.id) }
|
|
47
|
+
})),
|
|
48
|
+
{ compact: true }
|
|
49
|
+
)
|
|
50
|
+
)
|
|
51
|
+
console.log('')
|
|
52
|
+
}
|
|
53
|
+
})
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { defineCommand } from 'citty'
|
|
2
|
+
|
|
3
|
+
import { loadDocument } from '../headless'
|
|
4
|
+
import { fmtTree, nodeToTreeNode, printError, entity, formatType } from '../format'
|
|
5
|
+
import type { SceneNode } from '@open-pencil/core'
|
|
6
|
+
|
|
7
|
+
export default defineCommand({
|
|
8
|
+
meta: { description: 'Print the node tree' },
|
|
9
|
+
args: {
|
|
10
|
+
file: { type: 'positional', description: '.fig file path', required: true },
|
|
11
|
+
page: { type: 'string', description: 'Page name (default: first page)' },
|
|
12
|
+
depth: { type: 'string', description: 'Max depth (default: unlimited)' },
|
|
13
|
+
json: { type: 'boolean', description: 'Output as JSON' }
|
|
14
|
+
},
|
|
15
|
+
async run({ args }) {
|
|
16
|
+
const graph = await loadDocument(args.file)
|
|
17
|
+
const pages = graph.getPages()
|
|
18
|
+
const maxDepth = args.depth ? Number(args.depth) : Infinity
|
|
19
|
+
|
|
20
|
+
const page = args.page
|
|
21
|
+
? pages.find((p) => p.name === args.page)
|
|
22
|
+
: pages[0]
|
|
23
|
+
|
|
24
|
+
if (!page) {
|
|
25
|
+
printError(`Page "${args.page}" not found. Available: ${pages.map((p) => p.name).join(', ')}`)
|
|
26
|
+
process.exit(1)
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
if (args.json) {
|
|
30
|
+
const buildJson = (id: string, depth: number): unknown => {
|
|
31
|
+
const node = graph.getNode(id)
|
|
32
|
+
if (!node) return null
|
|
33
|
+
const result: Record<string, unknown> = {
|
|
34
|
+
id: node.id,
|
|
35
|
+
name: node.name,
|
|
36
|
+
type: node.type,
|
|
37
|
+
x: Math.round(node.x),
|
|
38
|
+
y: Math.round(node.y),
|
|
39
|
+
width: Math.round(node.width),
|
|
40
|
+
height: Math.round(node.height)
|
|
41
|
+
}
|
|
42
|
+
if (node.childIds.length > 0 && depth < maxDepth) {
|
|
43
|
+
result.children = node.childIds.map((cid) => buildJson(cid, depth + 1)).filter(Boolean)
|
|
44
|
+
}
|
|
45
|
+
return result
|
|
46
|
+
}
|
|
47
|
+
console.log(JSON.stringify(page.childIds.map((id) => buildJson(id, 0)), null, 2))
|
|
48
|
+
return
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const root = {
|
|
52
|
+
header: entity(formatType(page.type), page.name, page.id),
|
|
53
|
+
children: page.childIds
|
|
54
|
+
.map((id) => graph.getNode(id))
|
|
55
|
+
.filter((n): n is SceneNode => n !== undefined)
|
|
56
|
+
.map((child) => nodeToTreeNode(graph, child, maxDepth))
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
console.log('')
|
|
60
|
+
console.log(fmtTree(root, { maxDepth }))
|
|
61
|
+
console.log('')
|
|
62
|
+
}
|
|
63
|
+
})
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import { defineCommand } from 'citty'
|
|
2
|
+
|
|
3
|
+
import { loadDocument } from '../headless'
|
|
4
|
+
import { bold, fmtList, fmtSummary } from '../format'
|
|
5
|
+
import type { SceneGraph, Variable } from '@open-pencil/core'
|
|
6
|
+
|
|
7
|
+
function formatValue(variable: Variable, graph: SceneGraph): string {
|
|
8
|
+
const modeId = graph.getActiveModeId(variable.collectionId)
|
|
9
|
+
const raw = variable.valuesByMode[modeId]
|
|
10
|
+
if (raw === undefined) return '–'
|
|
11
|
+
|
|
12
|
+
if (typeof raw === 'object' && raw !== null && 'aliasId' in raw) {
|
|
13
|
+
const alias = graph.variables.get(raw.aliasId)
|
|
14
|
+
return alias ? `→ ${alias.name}` : `→ ${raw.aliasId}`
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
if (typeof raw === 'object' && 'r' in raw) {
|
|
18
|
+
const { r, g, b } = raw as { r: number; g: number; b: number }
|
|
19
|
+
return (
|
|
20
|
+
'#' +
|
|
21
|
+
[r, g, b]
|
|
22
|
+
.map((c) =>
|
|
23
|
+
Math.round(c * 255)
|
|
24
|
+
.toString(16)
|
|
25
|
+
.padStart(2, '0')
|
|
26
|
+
)
|
|
27
|
+
.join('')
|
|
28
|
+
)
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
return String(raw)
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export default defineCommand({
|
|
35
|
+
meta: { description: 'List design variables and collections' },
|
|
36
|
+
args: {
|
|
37
|
+
file: { type: 'positional', description: '.fig file path', required: true },
|
|
38
|
+
collection: { type: 'string', description: 'Filter by collection name' },
|
|
39
|
+
type: { type: 'string', description: 'Filter by type: COLOR, FLOAT, STRING, BOOLEAN' },
|
|
40
|
+
json: { type: 'boolean', description: 'Output as JSON' }
|
|
41
|
+
},
|
|
42
|
+
async run({ args }) {
|
|
43
|
+
const graph = await loadDocument(args.file)
|
|
44
|
+
|
|
45
|
+
const collections = [...graph.variableCollections.values()]
|
|
46
|
+
const variables = [...graph.variables.values()]
|
|
47
|
+
|
|
48
|
+
if (variables.length === 0) {
|
|
49
|
+
console.log('No variables found.')
|
|
50
|
+
return
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
if (args.json) {
|
|
54
|
+
console.log(JSON.stringify({ collections, variables }, null, 2))
|
|
55
|
+
return
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const typeFilter = args.type?.toUpperCase()
|
|
59
|
+
const collFilter = args.collection?.toLowerCase()
|
|
60
|
+
|
|
61
|
+
console.log('')
|
|
62
|
+
|
|
63
|
+
for (const coll of collections) {
|
|
64
|
+
if (collFilter && !coll.name.toLowerCase().includes(collFilter)) continue
|
|
65
|
+
|
|
66
|
+
const collVars = graph
|
|
67
|
+
.getVariablesForCollection(coll.id)
|
|
68
|
+
.filter((v) => !typeFilter || v.type === typeFilter)
|
|
69
|
+
|
|
70
|
+
if (collVars.length === 0) continue
|
|
71
|
+
|
|
72
|
+
const modes = coll.modes.map((m) => m.name).join(', ')
|
|
73
|
+
console.log(bold(` ${coll.name}`) + ` (${modes})`)
|
|
74
|
+
console.log('')
|
|
75
|
+
console.log(
|
|
76
|
+
fmtList(
|
|
77
|
+
collVars.map((v) => ({
|
|
78
|
+
header: v.name,
|
|
79
|
+
details: { value: formatValue(v, graph), type: v.type.toLowerCase() }
|
|
80
|
+
})),
|
|
81
|
+
{ compact: true }
|
|
82
|
+
)
|
|
83
|
+
)
|
|
84
|
+
console.log('')
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
console.log(
|
|
88
|
+
fmtSummary({
|
|
89
|
+
variables: variables.length,
|
|
90
|
+
collections: collections.length
|
|
91
|
+
})
|
|
92
|
+
)
|
|
93
|
+
console.log('')
|
|
94
|
+
}
|
|
95
|
+
})
|