@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.
@@ -1,83 +1,73 @@
1
1
  import { defineCommand } from 'citty'
2
2
 
3
3
  import { loadDocument } from '../headless'
4
- import { fmtNode, fmtList, nodeToData, nodeDetails, formatType, printError } from '../format'
5
- import type { SceneNode, SceneGraph } from '@open-pencil/core'
4
+ import { isAppMode, requireFile, rpc } from '../app-client'
5
+ import { fmtNode, printError, formatType } from '../format'
6
+ import { executeRpcCommand, colorToHex } from '@open-pencil/core'
6
7
 
7
- function fullNodeDetails(graph: SceneGraph, node: SceneNode): Record<string, unknown> {
8
- const details = nodeDetails(node)
8
+ import type { Color, NodeResult } from '@open-pencil/core'
9
9
 
10
- const parent = node.parentId ? graph.getNode(node.parentId) : undefined
11
- if (parent) details.parent = `${parent.name} (${parent.id})`
12
-
13
- if (node.text) {
14
- details.text = node.text.length > 80 ? node.text.slice(0, 80) + '…' : node.text
15
- }
16
-
17
- if (node.childIds.length > 0) details.children = node.childIds.length
18
-
19
- for (const [field, varId] of Object.entries(node.boundVariables)) {
20
- const variable = graph.variables.get(varId)
21
- details[`var:${field}`] = variable?.name ?? varId
22
- }
23
-
24
- return details
10
+ async function getData(file: string | undefined, id: string): Promise<NodeResult | { error: string }> {
11
+ if (isAppMode(file)) return rpc<NodeResult>('node', { id })
12
+ const graph = await loadDocument(requireFile(file))
13
+ return executeRpcCommand(graph, 'node', { id }) as NodeResult | { error: string }
25
14
  }
26
15
 
27
16
  export default defineCommand({
28
17
  meta: { description: 'Show detailed node properties by ID' },
29
18
  args: {
30
- file: { type: 'positional', description: '.fig file path', required: true },
19
+ file: { type: 'positional', description: '.fig file path (omit to connect to running app)', required: false },
31
20
  id: { type: 'string', description: 'Node ID', required: true },
32
21
  json: { type: 'boolean', description: 'Output as JSON' }
33
22
  },
34
23
  async run({ args }) {
35
- const graph = await loadDocument(args.file)
36
- const node = graph.getNode(args.id)
24
+ const data = await getData(args.file, args.id)
37
25
 
38
- if (!node) {
39
- printError(`Node "${args.id}" not found.`)
26
+ if ('error' in data) {
27
+ printError(data.error)
40
28
  process.exit(1)
41
29
  }
42
30
 
43
31
  if (args.json) {
44
- const { childIds, parentId, ...rest } = node
45
- const children = childIds.length
46
- const parent = parentId ? graph.getNode(parentId) : undefined
47
- console.log(
48
- JSON.stringify(
49
- {
50
- ...rest,
51
- parent: parent ? { id: parent.id, name: parent.name, type: parent.type } : null,
52
- children
53
- },
54
- null,
55
- 2
56
- )
57
- )
32
+ console.log(JSON.stringify(data, null, 2))
58
33
  return
59
34
  }
60
35
 
61
- console.log('')
62
- console.log(fmtNode(nodeToData(node), fullNodeDetails(graph, node)))
63
-
64
- if (node.childIds.length > 0) {
65
- const children = node.childIds
66
- .map((id) => graph.getNode(id))
67
- .filter((n): n is SceneNode => n !== undefined)
68
- .slice(0, 10)
69
- .map((child) => ({
70
- header: `[${formatType(child.type)}] "${child.name}" (${child.id})`
71
- }))
36
+ const nodeData = {
37
+ type: formatType(data.type),
38
+ name: data.name,
39
+ id: data.id,
40
+ width: data.width,
41
+ height: data.height,
42
+ x: data.x,
43
+ y: data.y
44
+ }
72
45
 
73
- if (node.childIds.length > 10) {
74
- children.push({ header: `… and ${node.childIds.length - 10} more` })
46
+ const details: Record<string, unknown> = {}
47
+ if (data.parent) details.parent = `${data.parent.name} (${data.parent.id})`
48
+ if (data.text) details.text = data.text
49
+ if (data.fills.length > 0) {
50
+ const solid = (data.fills as Array<{ type: string; visible: boolean; color: Color; opacity: number }>)
51
+ .find((f) => f.type === 'SOLID' && f.visible)
52
+ if (solid) {
53
+ const hex = colorToHex(solid.color)
54
+ details.fill = solid.opacity < 1 ? `${hex} ${Math.round(solid.opacity * 100)}%` : hex
75
55
  }
76
-
77
- console.log('')
78
- console.log(fmtList(children, { compact: true }))
56
+ }
57
+ if (data.cornerRadius) details.radius = `${data.cornerRadius}px`
58
+ if (data.rotation) details.rotate = `${Math.round(data.rotation)}°`
59
+ if (data.opacity < 1) details.opacity = data.opacity
60
+ if (!data.visible) details.visible = false
61
+ if (data.locked) details.locked = true
62
+ if (data.fontFamily) details.font = `${data.fontSize}px ${data.fontFamily}`
63
+ if (data.layoutMode !== 'NONE') details.layout = data.layoutMode.toLowerCase()
64
+ if (data.children > 0) details.children = data.children
65
+ for (const [field, name] of Object.entries(data.boundVariables)) {
66
+ details[`var:${field}`] = name
79
67
  }
80
68
 
81
69
  console.log('')
70
+ console.log(fmtNode(nodeData, details))
71
+ console.log('')
82
72
  }
83
73
  })
@@ -1,38 +1,29 @@
1
1
  import { defineCommand } from 'citty'
2
2
 
3
3
  import { loadDocument } from '../headless'
4
- import { bold, fmtList, entity, formatType } from '../format'
4
+ import { isAppMode, requireFile, rpc } from '../app-client'
5
+ import { bold, fmtList, entity } from '../format'
6
+
7
+ import type { PageItem } from '@open-pencil/core'
8
+ import { executeRpcCommand } from '@open-pencil/core'
9
+
10
+ async function getData(file?: string): Promise<PageItem[]> {
11
+ if (isAppMode(file)) return rpc<PageItem[]>('pages')
12
+ const graph = await loadDocument(requireFile(file))
13
+ return executeRpcCommand(graph, 'pages', undefined) as PageItem[]
14
+ }
5
15
 
6
16
  export default defineCommand({
7
17
  meta: { description: 'List pages in a .fig file' },
8
18
  args: {
9
- file: { type: 'positional', description: '.fig file path', required: true },
19
+ file: { type: 'positional', description: '.fig file path (omit to connect to running app)', required: false },
10
20
  json: { type: 'boolean', description: 'Output as JSON' }
11
21
  },
12
22
  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
- }
23
+ const pages = await getData(args.file)
27
24
 
28
25
  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
- )
26
+ console.log(JSON.stringify(pages, null, 2))
36
27
  return
37
28
  }
38
29
 
@@ -42,8 +33,8 @@ export default defineCommand({
42
33
  console.log(
43
34
  fmtList(
44
35
  pages.map((page) => ({
45
- header: entity(formatType(page.type), page.name, page.id),
46
- details: { nodes: countNodes(page.id) }
36
+ header: entity('page', page.name, page.id),
37
+ details: { nodes: page.nodes }
47
38
  })),
48
39
  { compact: true }
49
40
  )
@@ -0,0 +1,90 @@
1
+ import { defineCommand } from 'citty'
2
+
3
+ import { loadDocument } from '../headless'
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 { QueryNodeResult } from '@open-pencil/core'
9
+
10
+ async function getData(
11
+ file: string | undefined,
12
+ args: { selector: string; page?: string; limit?: string }
13
+ ): Promise<QueryNodeResult[] | { error: string }> {
14
+ const rpcArgs = {
15
+ selector: args.selector,
16
+ page: args.page,
17
+ limit: args.limit ? Number(args.limit) : undefined
18
+ }
19
+ if (isAppMode(file)) return rpc<QueryNodeResult[]>('query', rpcArgs)
20
+ const graph = await loadDocument(requireFile(file))
21
+ return await executeRpcCommand(graph, 'query', rpcArgs) as QueryNodeResult[] | { error: string }
22
+ }
23
+
24
+ export default defineCommand({
25
+ meta: {
26
+ description: `Query nodes using XPath selectors
27
+
28
+ Examples:
29
+ open-pencil query file.fig "//FRAME" # All frames
30
+ open-pencil query file.fig "//FRAME[@width < 300]" # Frames narrower than 300px
31
+ open-pencil query file.fig "//COMPONENT[starts-with(@name, 'Button')]" # Components starting with Button
32
+ open-pencil query file.fig "//SECTION/FRAME" # Direct frame children of sections
33
+ open-pencil query file.fig "//SECTION//TEXT" # All text inside sections
34
+ open-pencil query file.fig "//*[@cornerRadius > 0]" # Any node with corner radius`
35
+ },
36
+ args: {
37
+ file: {
38
+ type: 'positional',
39
+ description: '.fig file path (omit to connect to running app)',
40
+ required: false
41
+ },
42
+ selector: {
43
+ type: 'positional',
44
+ description:
45
+ 'XPath selector (e.g., //FRAME[@width < 300], //TEXT[contains(@name, "Label")])',
46
+ required: true
47
+ },
48
+ page: { type: 'string', description: 'Page name (default: all pages)' },
49
+ limit: { type: 'string', description: 'Max results (default: 1000)', default: '1000' },
50
+ json: { type: 'boolean', description: 'Output as JSON' }
51
+ },
52
+ async run({ args }) {
53
+ const results = await getData(args.file, {
54
+ selector: args.selector,
55
+ page: args.page,
56
+ limit: args.limit
57
+ })
58
+
59
+ if ('error' in results) {
60
+ printError(results.error)
61
+ process.exit(1)
62
+ }
63
+
64
+ if (args.json) {
65
+ console.log(JSON.stringify(results, null, 2))
66
+ return
67
+ }
68
+
69
+ if (results.length === 0) {
70
+ console.log('No nodes found.')
71
+ return
72
+ }
73
+
74
+ console.log('')
75
+ console.log(bold(` Found ${results.length} node${results.length > 1 ? 's' : ''}`))
76
+ console.log('')
77
+ console.log(
78
+ fmtList(
79
+ results.map((n) => ({
80
+ header: entity(
81
+ formatType(n.type),
82
+ `${n.name} ${n.width}×${n.height}`,
83
+ n.id
84
+ )
85
+ }))
86
+ )
87
+ )
88
+ console.log('')
89
+ }
90
+ })
@@ -1,59 +1,55 @@
1
1
  import { defineCommand } from 'citty'
2
2
 
3
3
  import { loadDocument } from '../headless'
4
- import { fmtTree, nodeToTreeNode, printError, entity, formatType } from '../format'
5
- import type { SceneNode } from '@open-pencil/core'
4
+ import { isAppMode, requireFile, rpc } from '../app-client'
5
+ import { fmtTree, printError, entity, formatType } from '../format'
6
+ import { executeRpcCommand } from '@open-pencil/core'
7
+
8
+ import type { TreeResult, TreeNodeResult } from '@open-pencil/core'
9
+ import type { TreeNode } from 'agentfmt'
10
+
11
+ function toAgentfmtTree(node: TreeNodeResult, maxDepth: number, depth = 0): TreeNode {
12
+ const treeNode: TreeNode = {
13
+ header: entity(formatType(node.type), node.name, node.id)
14
+ }
15
+ if (node.children && depth < maxDepth) {
16
+ treeNode.children = node.children.map((c) => toAgentfmtTree(c, maxDepth, depth + 1))
17
+ }
18
+ return treeNode
19
+ }
20
+
21
+ async function getData(file: string | undefined, args: { page?: string; depth?: string }): Promise<TreeResult | { error: string }> {
22
+ const rpcArgs = { page: args.page, depth: args.depth ? Number(args.depth) : undefined }
23
+ if (isAppMode(file)) return rpc<TreeResult>('tree', rpcArgs)
24
+ const graph = await loadDocument(requireFile(file))
25
+ return executeRpcCommand(graph, 'tree', rpcArgs) as TreeResult | { error: string }
26
+ }
6
27
 
7
28
  export default defineCommand({
8
29
  meta: { description: 'Print the node tree' },
9
30
  args: {
10
- file: { type: 'positional', description: '.fig file path', required: true },
31
+ file: { type: 'positional', description: '.fig file path (omit to connect to running app)', required: false },
11
32
  page: { type: 'string', description: 'Page name (default: first page)' },
12
33
  depth: { type: 'string', description: 'Max depth (default: unlimited)' },
13
34
  json: { type: 'boolean', description: 'Output as JSON' }
14
35
  },
15
36
  async run({ args }) {
16
- const graph = await loadDocument(args.file)
17
- const pages = graph.getPages()
37
+ const data = await getData(args.file, args)
18
38
  const maxDepth = args.depth ? Number(args.depth) : Infinity
19
39
 
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(', ')}`)
40
+ if ('error' in data) {
41
+ printError(data.error)
26
42
  process.exit(1)
27
43
  }
28
44
 
29
45
  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))
46
+ console.log(JSON.stringify(data.children, null, 2))
48
47
  return
49
48
  }
50
49
 
51
50
  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))
51
+ header: entity(formatType(data.page.type), data.page.name, data.page.id),
52
+ children: data.children.map((c) => toAgentfmtTree(c, maxDepth))
57
53
  }
58
54
 
59
55
  console.log('')
@@ -1,82 +1,50 @@
1
1
  import { defineCommand } from 'citty'
2
2
 
3
3
  import { loadDocument } from '../headless'
4
- import { bold, fmtList, fmtSummary } from '../format'
5
- import type { SceneGraph, Variable } from '@open-pencil/core'
4
+ import { isAppMode, requireFile, rpc } from '../app-client'
5
+ import { bold, entity, fmtList, fmtSummary } from '../format'
6
+ import { executeRpcCommand } from '@open-pencil/core'
6
7
 
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 '–'
8
+ import type { VariablesResult } from '@open-pencil/core'
11
9
 
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)
10
+ async function getData(file: string | undefined, args: { collection?: string; type?: string }): Promise<VariablesResult> {
11
+ const rpcArgs = { collection: args.collection, type: args.type }
12
+ if (isAppMode(file)) return rpc<VariablesResult>('variables', rpcArgs)
13
+ const graph = await loadDocument(requireFile(file))
14
+ return executeRpcCommand(graph, 'variables', rpcArgs) as VariablesResult
32
15
  }
33
16
 
34
17
  export default defineCommand({
35
18
  meta: { description: 'List design variables and collections' },
36
19
  args: {
37
- file: { type: 'positional', description: '.fig file path', required: true },
20
+ file: { type: 'positional', description: '.fig file path (omit to connect to running app)', required: false },
38
21
  collection: { type: 'string', description: 'Filter by collection name' },
39
22
  type: { type: 'string', description: 'Filter by type: COLOR, FLOAT, STRING, BOOLEAN' },
40
23
  json: { type: 'boolean', description: 'Output as JSON' }
41
24
  },
42
25
  async run({ args }) {
43
- const graph = await loadDocument(args.file)
26
+ const data = await getData(args.file, args)
44
27
 
45
- const collections = [...graph.variableCollections.values()]
46
- const variables = [...graph.variables.values()]
47
-
48
- if (variables.length === 0) {
28
+ if (data.totalVariables === 0) {
49
29
  console.log('No variables found.')
50
30
  return
51
31
  }
52
32
 
53
33
  if (args.json) {
54
- console.log(JSON.stringify({ collections, variables }, null, 2))
34
+ console.log(JSON.stringify(data, null, 2))
55
35
  return
56
36
  }
57
37
 
58
- const typeFilter = args.type?.toUpperCase()
59
- const collFilter = args.collection?.toLowerCase()
60
-
61
38
  console.log('')
62
39
 
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})`)
40
+ for (const coll of data.collections) {
41
+ console.log(bold(entity(coll.name, coll.modes.join(', '))))
74
42
  console.log('')
75
43
  console.log(
76
44
  fmtList(
77
- collVars.map((v) => ({
45
+ coll.variables.map((v) => ({
78
46
  header: v.name,
79
- details: { value: formatValue(v, graph), type: v.type.toLowerCase() }
47
+ details: { value: v.value, type: v.type.toLowerCase() }
80
48
  })),
81
49
  { compact: true }
82
50
  )
@@ -86,8 +54,8 @@ export default defineCommand({
86
54
 
87
55
  console.log(
88
56
  fmtSummary({
89
- variables: variables.length,
90
- collections: collections.length
57
+ variables: data.totalVariables,
58
+ collections: data.totalCollections
91
59
  })
92
60
  )
93
61
  console.log('')
package/src/format.ts CHANGED
@@ -48,7 +48,7 @@ export function formatBox(node: SceneNode): string {
48
48
  function formatFill(node: SceneNode): string | null {
49
49
  if (!node.fills.length) return null
50
50
  const solid = node.fills.find((f) => f.type === 'SOLID' && f.visible)
51
- if (!solid || solid.type !== 'SOLID') return null
51
+ if (!solid?.color) return null
52
52
  const { r, g, b } = solid.color
53
53
  const hex = '#' + [r, g, b].map((c) => Math.round(c * 255).toString(16).padStart(2, '0')).join('')
54
54
  return solid.opacity < 1 ? `${hex} ${Math.round(solid.opacity * 100)}%` : hex
@@ -56,7 +56,7 @@ function formatFill(node: SceneNode): string | null {
56
56
 
57
57
  function formatStroke(node: SceneNode): string | null {
58
58
  if (!node.strokes.length) return null
59
- const s = node.strokes[0]!
59
+ const s = node.strokes[0]
60
60
  const { r, g, b } = s.color
61
61
  const hex = '#' + [r, g, b].map((c) => Math.round(c * 255).toString(16).padStart(2, '0')).join('')
62
62
  return `${hex} ${s.weight}px`
package/src/headless.ts CHANGED
@@ -2,14 +2,14 @@ import CanvasKitInit from 'canvaskit-wasm/full'
2
2
  import type { CanvasKit } from 'canvaskit-wasm'
3
3
  import {
4
4
  parseFigFile,
5
- SceneGraph,
5
+ type SceneGraph,
6
+ type ExportFormat,
6
7
  SkiaRenderer,
7
8
  computeAllLayouts,
8
9
  loadFont,
9
10
  renderNodesToImage,
10
11
  renderThumbnail
11
12
  } from '@open-pencil/core'
12
- import type { ExportFormat } from '@open-pencil/core'
13
13
 
14
14
  let ck: CanvasKit | null = null
15
15
 
package/src/index.ts CHANGED
@@ -6,6 +6,7 @@ import evalCmd from './commands/eval'
6
6
  import exportCmd from './commands/export'
7
7
  import find from './commands/find'
8
8
  import info from './commands/info'
9
+ import query from './commands/query'
9
10
  import node from './commands/node'
10
11
  import pages from './commands/pages'
11
12
  import tree from './commands/tree'
@@ -25,6 +26,7 @@ const main = defineCommand({
25
26
  export: exportCmd,
26
27
  find,
27
28
  info,
29
+ query,
28
30
  node,
29
31
  pages,
30
32
  tree,
@@ -32,4 +34,4 @@ const main = defineCommand({
32
34
  }
33
35
  })
34
36
 
35
- runMain(main)
37
+ void runMain(main)