Merge cli-commands: analyze, node, pages, variables commands

This commit is contained in:
Danila Poyarkov 2026-03-01 10:19:46 +03:00
commit 7dcc5822f8
11 changed files with 906 additions and 3 deletions

View file

@ -2,6 +2,8 @@
Vue 3 + CanvasKit (Skia WASM) + Yoga WASM design editor. Tauri v2 desktop, also runs in browser.
**Roadmap:** `plan.md` — phases, tech stack, CLI architecture, test strategy, keyboard shortcuts.
## Monorepo
Bun workspace with two packages:
@ -21,7 +23,20 @@ The root app (`src/`) is the Tauri/Vite desktop editor. Its `src/engine/` files
- `bun open-pencil info <file>` — document stats
- `bun open-pencil tree <file>` — node tree
- `bun open-pencil find <file>` — search nodes
- `bun open-pencil node <file> --id <id>` — detailed node properties
- `bun open-pencil pages <file>` — list pages
- `bun open-pencil variables <file>` — list design variables
- `bun open-pencil export <file>` — headless render to PNG/JPG/WEBP
- `bun open-pencil analyze colors <file>` — color palette usage
- `bun open-pencil analyze typography <file>` — font/size/weight stats
- `bun open-pencil analyze spacing <file>` — gap/padding values
- `bun open-pencil analyze clusters <file>` — repeated patterns
## CLI
- All CLI output must use `agentfmt` formatters — `fmtList`, `fmtHistogram`, `fmtSummary`, `fmtNode`, `fmtTree`, `kv`, `entity`, `bold`, `dim`, etc.
- Don't hand-roll `console.log` formatting — use the helpers from `packages/cli/src/format.ts` which re-exports agentfmt with project-specific adapters (`nodeToData`, `nodeDetails`, `nodeToTreeNode`, `nodeToListItem`)
- Every command supports `--json` for machine-readable output
## Code conventions

View file

@ -18,6 +18,7 @@
"test:figma": "playwright test --project=figma",
"figma:debug": "open -a Figma --args --remote-debugging-port=9222",
"test:unit": "bun test ./tests/engine",
"test:coverage": "bun test --coverage ./tests/engine",
"open-pencil": "bun packages/cli/src/index.ts"
},
"dependencies": {

View file

@ -0,0 +1,171 @@
import { defineCommand } from 'citty'
import { loadDocument } from '../../headless'
import { bold, fmtList, fmtSummary } from '../../format'
import type { SceneNode, SceneGraph } from '@open-pencil/core'
interface ClusterNode {
id: string
name: string
type: string
width: number
height: number
childCount: number
}
interface Cluster {
signature: string
nodes: ClusterNode[]
}
function buildSignature(graph: SceneGraph, node: SceneNode): string {
const childTypes = new Map<string, number>()
for (const childId of node.childIds) {
const child = graph.getNode(childId)
if (!child) continue
childTypes.set(child.type, (childTypes.get(child.type) ?? 0) + 1)
}
const childPart = [...childTypes.entries()]
.sort((a, b) => a[0].localeCompare(b[0]))
.map(([t, c]) => `${t}:${c}`)
.join(',')
const w = Math.round(node.width / 10) * 10
const h = Math.round(node.height / 10) * 10
return `${node.type}:${w}x${h}|${childPart}`
}
function calcConfidence(nodes: ClusterNode[]): number {
if (nodes.length < 2) return 100
const base = nodes[0]!
let score = 0
for (const node of nodes.slice(1)) {
const sizeDiff = Math.abs(node.width - base.width) + Math.abs(node.height - base.height)
const childDiff = Math.abs(node.childCount - base.childCount)
if (sizeDiff <= 4 && childDiff === 0) score++
else if (sizeDiff <= 10 && childDiff <= 1) score += 0.8
else if (sizeDiff <= 20 && childDiff <= 2) score += 0.6
else score += 0.4
}
return Math.round((score / (nodes.length - 1)) * 100)
}
function formatSignature(sig: string): string {
const [typeSize, children] = sig.split('|')
const type = typeSize?.split(':')[0]
if (!type) return sig
const typeName = type.charAt(0) + type.slice(1).toLowerCase()
if (!children) return typeName
const childParts = children.split(',').map((c) => {
const [t, count] = c.split(':')
if (!t) return ''
const name = t.charAt(0) + t.slice(1).toLowerCase()
return Number(count) > 1 ? `${name}×${count}` : name
})
return `${typeName} > [${childParts.join(', ')}]`
}
function findClusters(
graph: SceneGraph,
minSize: number,
minCount: number
): { clusters: Cluster[]; totalNodes: number } {
const sigMap = new Map<string, ClusterNode[]>()
let totalNodes = 0
for (const node of graph.getAllNodes()) {
if (node.type === 'CANVAS') continue
totalNodes++
if (node.width < minSize || node.height < minSize) continue
if (node.childIds.length === 0) continue
const sig = buildSignature(graph, node)
const arr = sigMap.get(sig) ?? []
arr.push({
id: node.id,
name: node.name,
type: node.type,
width: Math.round(node.width),
height: Math.round(node.height),
childCount: node.childIds.length
})
sigMap.set(sig, arr)
}
const clusters = [...sigMap.entries()]
.filter(([, nodes]) => nodes.length >= minCount)
.map(([signature, nodes]) => ({ signature, nodes }))
.sort((a, b) => b.nodes.length - a.nodes.length)
return { clusters, totalNodes }
}
export default defineCommand({
meta: { description: 'Find repeated design patterns (potential components)' },
args: {
file: { type: 'positional', description: '.fig file path', required: true },
limit: { type: 'string', description: 'Max clusters to show', default: '20' },
'min-size': { type: 'string', description: 'Min node size in px', default: '30' },
'min-count': { type: 'string', description: 'Min instances to form cluster', default: '2' },
json: { type: 'boolean', description: 'Output as JSON' }
},
async run({ args }) {
const graph = await loadDocument(args.file)
const limit = Number(args.limit)
const minSize = Number(args['min-size'])
const minCount = Number(args['min-count'])
const { clusters, totalNodes } = findClusters(graph, minSize, minCount)
if (args.json) {
console.log(JSON.stringify({ clusters: clusters.slice(0, limit), totalNodes }, null, 2))
return
}
if (clusters.length === 0) {
console.log('No repeated patterns found.')
return
}
console.log('')
console.log(bold(' Repeated patterns'))
console.log('')
const items = clusters.slice(0, limit).map((c) => {
const first = c.nodes[0]!
const confidence = calcConfidence(c.nodes)
const widths = c.nodes.map((n) => n.width)
const heights = c.nodes.map((n) => n.height)
const wRange = Math.max(...widths) - Math.min(...widths)
const hRange = Math.max(...heights) - Math.min(...heights)
const avgW = Math.round(widths.reduce((a, b) => a + b, 0) / widths.length)
const avgH = Math.round(heights.reduce((a, b) => a + b, 0) / heights.length)
const sizeStr =
wRange <= 4 && hRange <= 4
? `${avgW}×${avgH}`
: `${avgW}×${avgH}${Math.max(wRange, hRange)}px)`
return {
header: `${c.nodes.length}× ${first.type.toLowerCase()} "${first.name}" (${confidence}% match)`,
details: {
size: sizeStr,
structure: formatSignature(c.signature),
examples: c.nodes.slice(0, 3).map((n) => n.id).join(', ')
}
}
})
console.log(fmtList(items, { numbered: true }))
const clusteredNodes = clusters.reduce((sum, c) => sum + c.nodes.length, 0)
console.log('')
console.log(
fmtSummary({ clusters: clusters.length }) +
` from ${totalNodes} nodes (${clusteredNodes} clustered)`
)
console.log('')
}
})

View file

@ -0,0 +1,203 @@
import { defineCommand } from 'citty'
import { loadDocument } from '../../headless'
import { bold, fmtHistogram, fmtList, fmtSummary } from '../../format'
import type { SceneGraph } from '@open-pencil/core'
interface ColorInfo {
hex: string
count: number
variableName: string | null
}
function toHex(r: number, g: number, b: number): string {
return (
'#' +
[r, g, b]
.map((c) =>
Math.round(c * 255)
.toString(16)
.padStart(2, '0')
)
.join('')
)
}
function hexToRgb(hex: string): [number, number, number] {
const clean = hex.replace('#', '')
return [
parseInt(clean.slice(0, 2), 16),
parseInt(clean.slice(2, 4), 16),
parseInt(clean.slice(4, 6), 16)
]
}
function colorDistance(hex1: string, hex2: string): number {
const [r1, g1, b1] = hexToRgb(hex1)
const [r2, g2, b2] = hexToRgb(hex2)
return Math.sqrt((r1 - r2) ** 2 + (g1 - g2) ** 2 + (b1 - b2) ** 2)
}
interface Cluster {
colors: ColorInfo[]
suggestedHex: string
totalCount: number
}
function clusterColors(colors: ColorInfo[], threshold: number): Cluster[] {
const clusters: Cluster[] = []
const used = new Set<string>()
const sorted = [...colors].sort((a, b) => b.count - a.count)
for (const color of sorted) {
if (used.has(color.hex)) continue
const cluster: Cluster = {
colors: [color],
suggestedHex: color.hex,
totalCount: color.count
}
used.add(color.hex)
for (const other of sorted) {
if (used.has(other.hex)) continue
if (colorDistance(color.hex, other.hex) <= threshold) {
cluster.colors.push(other)
cluster.totalCount += other.count
used.add(other.hex)
}
}
if (cluster.colors.length > 1) clusters.push(cluster)
}
return clusters.sort((a, b) => b.colors.length - a.colors.length)
}
function collectColors(graph: SceneGraph): { colors: ColorInfo[]; totalNodes: number } {
const colorMap = new Map<string, ColorInfo>()
let totalNodes = 0
const addColor = (hex: string, variableName: string | null) => {
const existing = colorMap.get(hex)
if (existing) {
existing.count++
if (variableName && !existing.variableName) existing.variableName = variableName
} else {
colorMap.set(hex, { hex, count: 1, variableName })
}
}
for (const node of graph.getAllNodes()) {
if (node.type === 'CANVAS') continue
totalNodes++
for (const fill of node.fills) {
if (!fill.visible || fill.type !== 'SOLID') continue
const hex = toHex(fill.color.r, fill.color.g, fill.color.b)
addColor(hex, null)
}
for (const stroke of node.strokes) {
if (!stroke.visible) continue
const hex = toHex(stroke.color.r, stroke.color.g, stroke.color.b)
addColor(hex, null)
}
for (const effect of node.effects) {
if (!effect.visible) continue
const hex = toHex(effect.color.r, effect.color.g, effect.color.b)
addColor(hex, null)
}
for (const [field, varId] of Object.entries(node.boundVariables)) {
if (!field.includes('fill') && !field.includes('stroke') && !field.includes('color'))
continue
const variable = graph.variables.get(varId)
if (variable) {
const resolvedColor = graph.resolveColorVariable(varId)
if (resolvedColor) {
const hex = toHex(resolvedColor.r, resolvedColor.g, resolvedColor.b)
const existing = colorMap.get(hex)
if (existing) existing.variableName = variable.name
}
}
}
}
return { colors: [...colorMap.values()], totalNodes }
}
export default defineCommand({
meta: { description: 'Analyze color palette usage' },
args: {
file: { type: 'positional', description: '.fig file path', required: true },
limit: { type: 'string', description: 'Max colors to show', default: '30' },
threshold: {
type: 'string',
description: 'Distance threshold for clustering similar colors (050)',
default: '15'
},
similar: { type: 'boolean', description: 'Show similar color clusters' },
json: { type: 'boolean', description: 'Output as JSON' }
},
async run({ args }) {
const graph = await loadDocument(args.file)
const limit = Number(args.limit)
const threshold = Number(args.threshold)
const { colors, totalNodes } = collectColors(graph)
if (args.json) {
const clusters = args.similar ? clusterColors(colors, threshold) : []
console.log(JSON.stringify({ colors, totalNodes, clusters }, null, 2))
return
}
if (colors.length === 0) {
console.log('No colors found.')
return
}
const sorted = colors.sort((a, b) => b.count - a.count).slice(0, limit)
console.log('')
console.log(bold(' Colors by usage'))
console.log('')
console.log(
fmtHistogram(
sorted.map((c) => ({
label: c.hex,
value: c.count,
tag: c.variableName ? `$${c.variableName}` : undefined
}))
)
)
const hardcoded = colors.filter((c) => !c.variableName)
const fromVars = colors.filter((c) => c.variableName)
console.log('')
console.log(
fmtSummary({ 'unique colors': colors.length, 'from variables': fromVars.length, hardcoded: hardcoded.length })
)
if (args.similar) {
const clusters = clusterColors(hardcoded, threshold)
if (clusters.length > 0) {
console.log('')
console.log(bold(' Similar colors (consider merging)'))
console.log('')
console.log(
fmtList(
clusters.slice(0, 10).map((cluster) => ({
header: cluster.colors.map((c) => c.hex).join(', '),
details: { suggest: cluster.suggestedHex, total: `${cluster.totalCount}×` }
}))
)
)
}
}
console.log('')
}
})

View file

@ -0,0 +1,16 @@
import { defineCommand } from 'citty'
import colors from './colors'
import typography from './typography'
import spacing from './spacing'
import clusters from './clusters'
export default defineCommand({
meta: { description: 'Analyze design tokens and patterns' },
subCommands: {
colors,
typography,
spacing,
clusters
}
})

View file

@ -0,0 +1,119 @@
import { defineCommand } from 'citty'
import { loadDocument } from '../../headless'
import { bold, kv, fmtHistogram, fmtSummary } from '../../format'
import type { SceneGraph } from '@open-pencil/core'
interface SpacingValue {
value: number
count: number
}
function collectSpacing(graph: SceneGraph): {
gaps: SpacingValue[]
paddings: SpacingValue[]
totalNodes: number
} {
const gapMap = new Map<number, number>()
const paddingMap = new Map<number, number>()
let totalNodes = 0
for (const node of graph.getAllNodes()) {
if (node.type === 'CANVAS') continue
if (node.layoutMode === 'NONE') continue
totalNodes++
if (node.itemSpacing > 0) {
gapMap.set(node.itemSpacing, (gapMap.get(node.itemSpacing) ?? 0) + 1)
}
if (node.counterAxisSpacing > 0) {
gapMap.set(node.counterAxisSpacing, (gapMap.get(node.counterAxisSpacing) ?? 0) + 1)
}
for (const pad of [node.paddingTop, node.paddingRight, node.paddingBottom, node.paddingLeft]) {
if (pad > 0) paddingMap.set(pad, (paddingMap.get(pad) ?? 0) + 1)
}
}
const toValues = (map: Map<number, number>) =>
[...map.entries()]
.map(([value, count]) => ({ value, count }))
.sort((a, b) => b.count - a.count)
return { gaps: toValues(gapMap), paddings: toValues(paddingMap), totalNodes }
}
export default defineCommand({
meta: { description: 'Analyze spacing values (gap, padding)' },
args: {
file: { type: 'positional', description: '.fig file path', required: true },
grid: { type: 'string', description: 'Base grid size to check against', default: '8' },
json: { type: 'boolean', description: 'Output as JSON' }
},
async run({ args }) {
const graph = await loadDocument(args.file)
const gridSize = Number(args.grid)
const { gaps, paddings, totalNodes } = collectSpacing(graph)
if (args.json) {
console.log(JSON.stringify({ gaps, paddings, totalNodes }, null, 2))
return
}
console.log('')
if (gaps.length > 0) {
console.log(bold(' Gap values'))
console.log('')
console.log(
fmtHistogram(
gaps.slice(0, 15).map((g) => ({
label: `${String(g.value).padStart(4)}px`,
value: g.count,
suffix: g.value % gridSize !== 0 ? '⚠' : undefined
}))
)
)
console.log('')
}
if (paddings.length > 0) {
console.log(bold(' Padding values'))
console.log('')
console.log(
fmtHistogram(
paddings.slice(0, 15).map((p) => ({
label: `${String(p.value).padStart(4)}px`,
value: p.count,
suffix: p.value % gridSize !== 0 ? '⚠' : undefined
}))
)
)
console.log('')
}
if (gaps.length === 0 && paddings.length === 0) {
console.log('No auto-layout nodes with spacing found.')
console.log('')
return
}
console.log(fmtSummary({ 'gap values': gaps.length, 'padding values': paddings.length }))
const offGridGaps = gaps.filter((g) => g.value % gridSize !== 0)
const offGridPaddings = paddings.filter((p) => p.value % gridSize !== 0)
if (offGridGaps.length > 0 || offGridPaddings.length > 0) {
console.log('')
console.log(bold(` ⚠ Off-grid values (not ÷${gridSize}px)`))
if (offGridGaps.length > 0) {
console.log(kv('Gaps', offGridGaps.map((g) => `${g.value}px`).join(', ')))
}
if (offGridPaddings.length > 0) {
console.log(kv('Paddings', offGridPaddings.map((p) => `${p.value}px`).join(', ')))
}
}
console.log('')
}
})

View file

@ -0,0 +1,139 @@
import { defineCommand } from 'citty'
import { loadDocument } from '../../headless'
import { bold, fmtHistogram, fmtSummary } from '../../format'
import type { SceneGraph } from '@open-pencil/core'
interface TypographyStyle {
family: string
size: number
weight: number
lineHeight: string
count: number
}
function collectTypography(graph: SceneGraph): { styles: TypographyStyle[]; totalTextNodes: number } {
const styleMap = new Map<string, TypographyStyle>()
let totalTextNodes = 0
for (const node of graph.getAllNodes()) {
if (node.type !== 'TEXT') continue
totalTextNodes++
const lh = node.lineHeight === null ? 'auto' : `${node.lineHeight}px`
const key = `${node.fontFamily}|${node.fontSize}|${node.fontWeight}|${lh}`
const existing = styleMap.get(key)
if (existing) {
existing.count++
} else {
styleMap.set(key, {
family: node.fontFamily,
size: node.fontSize,
weight: node.fontWeight,
lineHeight: lh,
count: 1
})
}
}
return { styles: [...styleMap.values()], totalTextNodes }
}
function weightName(w: number): string {
if (w <= 100) return 'Thin'
if (w <= 200) return 'ExtraLight'
if (w <= 300) return 'Light'
if (w <= 400) return 'Regular'
if (w <= 500) return 'Medium'
if (w <= 600) return 'SemiBold'
if (w <= 700) return 'Bold'
if (w <= 800) return 'ExtraBold'
return 'Black'
}
export default defineCommand({
meta: { description: 'Analyze typography usage' },
args: {
file: { type: 'positional', description: '.fig file path', required: true },
'group-by': {
type: 'string',
description: 'Group by: family, size, weight (default: show all styles)'
},
limit: { type: 'string', description: 'Max styles to show', default: '30' },
json: { type: 'boolean', description: 'Output as JSON' }
},
async run({ args }) {
const graph = await loadDocument(args.file)
const limit = Number(args.limit)
const groupBy = args['group-by']
const { styles, totalTextNodes } = collectTypography(graph)
if (args.json) {
console.log(JSON.stringify({ styles, totalTextNodes }, null, 2))
return
}
if (styles.length === 0) {
console.log('No text nodes found.')
return
}
const sorted = styles.sort((a, b) => b.count - a.count)
console.log('')
if (groupBy === 'family') {
const byFamily = new Map<string, number>()
for (const s of sorted) byFamily.set(s.family, (byFamily.get(s.family) ?? 0) + s.count)
console.log(bold(' Font families'))
console.log('')
console.log(
fmtHistogram(
[...byFamily.entries()]
.sort((a, b) => b[1] - a[1])
.map(([family, count]) => ({ label: family, value: count }))
)
)
} else if (groupBy === 'size') {
const bySize = new Map<number, number>()
for (const s of sorted) bySize.set(s.size, (bySize.get(s.size) ?? 0) + s.count)
console.log(bold(' Font sizes'))
console.log('')
console.log(
fmtHistogram(
[...bySize.entries()]
.sort((a, b) => a[0] - b[0])
.map(([size, count]) => ({ label: `${size}px`, value: count }))
)
)
} else if (groupBy === 'weight') {
const byWeight = new Map<number, number>()
for (const s of sorted) byWeight.set(s.weight, (byWeight.get(s.weight) ?? 0) + s.count)
console.log(bold(' Font weights'))
console.log('')
console.log(
fmtHistogram(
[...byWeight.entries()]
.sort((a, b) => b[1] - a[1])
.map(([weight, count]) => ({ label: `${weight} ${weightName(weight)}`, value: count }))
)
)
} else {
console.log(bold(' Typography styles'))
console.log('')
const items = sorted.slice(0, limit).map((s) => {
const lh = s.lineHeight !== 'auto' ? ` / ${s.lineHeight}` : ''
return {
label: `${s.family} ${s.size}px ${weightName(s.weight)}${lh}`,
value: s.count
}
})
console.log(fmtHistogram(items))
}
console.log('')
console.log(fmtSummary({ 'unique styles': styles.length }) + ` from ${totalTextNodes} text nodes`)
console.log('')
}
})

View file

@ -0,0 +1,83 @@
import { defineCommand } from 'citty'
import { loadDocument } from '../headless'
import { fmtNode, fmtList, nodeToData, nodeDetails, formatType, printError } from '../format'
import type { SceneNode, SceneGraph } from '@open-pencil/core'
function fullNodeDetails(graph: SceneGraph, node: SceneNode): Record<string, unknown> {
const details = nodeDetails(node)
const parent = node.parentId ? graph.getNode(node.parentId) : undefined
if (parent) details.parent = `${parent.name} (${parent.id})`
if (node.text) {
details.text = node.text.length > 80 ? node.text.slice(0, 80) + '…' : node.text
}
if (node.childIds.length > 0) details.children = node.childIds.length
for (const [field, varId] of Object.entries(node.boundVariables)) {
const variable = graph.variables.get(varId)
details[`var:${field}`] = variable?.name ?? varId
}
return details
}
export default defineCommand({
meta: { description: 'Show detailed node properties by ID' },
args: {
file: { type: 'positional', description: '.fig file path', required: true },
id: { type: 'string', description: 'Node ID', required: true },
json: { type: 'boolean', description: 'Output as JSON' }
},
async run({ args }) {
const graph = await loadDocument(args.file)
const node = graph.getNode(args.id)
if (!node) {
printError(`Node "${args.id}" not found.`)
process.exit(1)
}
if (args.json) {
const { childIds, parentId, ...rest } = node
const children = childIds.length
const parent = parentId ? graph.getNode(parentId) : undefined
console.log(
JSON.stringify(
{
...rest,
parent: parent ? { id: parent.id, name: parent.name, type: parent.type } : null,
children
},
null,
2
)
)
return
}
console.log('')
console.log(fmtNode(nodeToData(node), fullNodeDetails(graph, node)))
if (node.childIds.length > 0) {
const children = node.childIds
.map((id) => graph.getNode(id))
.filter((n): n is SceneNode => n !== undefined)
.slice(0, 10)
.map((child) => ({
header: `[${formatType(child.type)}] "${child.name}" (${child.id})`
}))
if (node.childIds.length > 10) {
children.push({ header: `… and ${node.childIds.length - 10} more` })
}
console.log('')
console.log(fmtList(children, { compact: true }))
}
console.log('')
}
})

View file

@ -0,0 +1,53 @@
import { defineCommand } from 'citty'
import { loadDocument } from '../headless'
import { bold, fmtList, entity, formatType } from '../format'
export default defineCommand({
meta: { description: 'List pages in a .fig file' },
args: {
file: { type: 'positional', description: '.fig file path', required: true },
json: { type: 'boolean', description: 'Output as JSON' }
},
async run({ args }) {
const graph = await loadDocument(args.file)
const pages = graph.getPages()
const countNodes = (pageId: string): number => {
let count = 0
const walk = (id: string) => {
count++
const n = graph.getNode(id)
if (n) for (const cid of n.childIds) walk(cid)
}
const page = graph.getNode(pageId)
if (page) for (const cid of page.childIds) walk(cid)
return count
}
if (args.json) {
console.log(
JSON.stringify(
pages.map((p) => ({ id: p.id, name: p.name, nodes: countNodes(p.id) })),
null,
2
)
)
return
}
console.log('')
console.log(bold(` ${pages.length} page${pages.length !== 1 ? 's' : ''}`))
console.log('')
console.log(
fmtList(
pages.map((page) => ({
header: entity(formatType(page.type), page.name, page.id),
details: { nodes: countNodes(page.id) }
})),
{ compact: true }
)
)
console.log('')
}
})

View file

@ -0,0 +1,95 @@
import { defineCommand } from 'citty'
import { loadDocument } from '../headless'
import { bold, fmtList, fmtSummary } from '../format'
import type { SceneGraph, Variable } from '@open-pencil/core'
function formatValue(variable: Variable, graph: SceneGraph): string {
const modeId = graph.getActiveModeId(variable.collectionId)
const raw = variable.valuesByMode[modeId]
if (raw === undefined) return ''
if (typeof raw === 'object' && raw !== null && 'aliasId' in raw) {
const alias = graph.variables.get(raw.aliasId)
return alias ? `${alias.name}` : `${raw.aliasId}`
}
if (typeof raw === 'object' && 'r' in raw) {
const { r, g, b } = raw as { r: number; g: number; b: number }
return (
'#' +
[r, g, b]
.map((c) =>
Math.round(c * 255)
.toString(16)
.padStart(2, '0')
)
.join('')
)
}
return String(raw)
}
export default defineCommand({
meta: { description: 'List design variables and collections' },
args: {
file: { type: 'positional', description: '.fig file path', required: true },
collection: { type: 'string', description: 'Filter by collection name' },
type: { type: 'string', description: 'Filter by type: COLOR, FLOAT, STRING, BOOLEAN' },
json: { type: 'boolean', description: 'Output as JSON' }
},
async run({ args }) {
const graph = await loadDocument(args.file)
const collections = [...graph.variableCollections.values()]
const variables = [...graph.variables.values()]
if (variables.length === 0) {
console.log('No variables found.')
return
}
if (args.json) {
console.log(JSON.stringify({ collections, variables }, null, 2))
return
}
const typeFilter = args.type?.toUpperCase()
const collFilter = args.collection?.toLowerCase()
console.log('')
for (const coll of collections) {
if (collFilter && !coll.name.toLowerCase().includes(collFilter)) continue
const collVars = graph
.getVariablesForCollection(coll.id)
.filter((v) => !typeFilter || v.type === typeFilter)
if (collVars.length === 0) continue
const modes = coll.modes.map((m) => m.name).join(', ')
console.log(bold(` ${coll.name}`) + ` (${modes})`)
console.log('')
console.log(
fmtList(
collVars.map((v) => ({
header: v.name,
details: { value: formatValue(v, graph), type: v.type.toLowerCase() }
})),
{ compact: true }
)
)
console.log('')
}
console.log(
fmtSummary({
variables: variables.length,
collections: collections.length
})
)
console.log('')
}
})

View file

@ -1,10 +1,14 @@
#!/usr/bin/env bun
import { defineCommand, runMain } from 'citty'
import analyze from './commands/analyze'
import exportCmd from './commands/export'
import info from './commands/info'
import find from './commands/find'
import info from './commands/info'
import node from './commands/node'
import pages from './commands/pages'
import tree from './commands/tree'
import variables from './commands/variables'
const main = defineCommand({
meta: {
@ -13,10 +17,14 @@ const main = defineCommand({
version: '0.1.0'
},
subCommands: {
analyze,
export: exportCmd,
info,
find,
tree
info,
node,
pages,
tree,
variables
}
})