Merge cli-commands: analyze, node, pages, variables commands
This commit is contained in:
commit
7dcc5822f8
15
AGENTS.md
15
AGENTS.md
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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": {
|
||||
|
|
|
|||
171
packages/cli/src/commands/analyze/clusters.ts
Normal file
171
packages/cli/src/commands/analyze/clusters.ts
Normal 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('')
|
||||
}
|
||||
})
|
||||
203
packages/cli/src/commands/analyze/colors.ts
Normal file
203
packages/cli/src/commands/analyze/colors.ts
Normal 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 (0–50)',
|
||||
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('')
|
||||
}
|
||||
})
|
||||
16
packages/cli/src/commands/analyze/index.ts
Normal file
16
packages/cli/src/commands/analyze/index.ts
Normal 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
|
||||
}
|
||||
})
|
||||
119
packages/cli/src/commands/analyze/spacing.ts
Normal file
119
packages/cli/src/commands/analyze/spacing.ts
Normal 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('')
|
||||
}
|
||||
})
|
||||
139
packages/cli/src/commands/analyze/typography.ts
Normal file
139
packages/cli/src/commands/analyze/typography.ts
Normal 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('')
|
||||
}
|
||||
})
|
||||
83
packages/cli/src/commands/node.ts
Normal file
83
packages/cli/src/commands/node.ts
Normal 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('')
|
||||
}
|
||||
})
|
||||
53
packages/cli/src/commands/pages.ts
Normal file
53
packages/cli/src/commands/pages.ts
Normal 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('')
|
||||
}
|
||||
})
|
||||
95
packages/cli/src/commands/variables.ts
Normal file
95
packages/cli/src/commands/variables.ts
Normal 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('')
|
||||
}
|
||||
})
|
||||
|
|
@ -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
|
||||
}
|
||||
})
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue