diff --git a/AGENTS.md b/AGENTS.md index 460a59847..bf6d16ede 100644 --- a/AGENTS.md +++ b/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 ` — document stats - `bun open-pencil tree ` — node tree - `bun open-pencil find ` — search nodes +- `bun open-pencil node --id ` — detailed node properties +- `bun open-pencil pages ` — list pages +- `bun open-pencil variables ` — list design variables - `bun open-pencil export ` — headless render to PNG/JPG/WEBP +- `bun open-pencil analyze colors ` — color palette usage +- `bun open-pencil analyze typography ` — font/size/weight stats +- `bun open-pencil analyze spacing ` — gap/padding values +- `bun open-pencil analyze clusters ` — 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 diff --git a/package.json b/package.json index b00937eea..49b618058 100644 --- a/package.json +++ b/package.json @@ -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": { diff --git a/packages/cli/src/commands/analyze/clusters.ts b/packages/cli/src/commands/analyze/clusters.ts new file mode 100644 index 000000000..530bb67f0 --- /dev/null +++ b/packages/cli/src/commands/analyze/clusters.ts @@ -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() + 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() + 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('') + } +}) diff --git a/packages/cli/src/commands/analyze/colors.ts b/packages/cli/src/commands/analyze/colors.ts new file mode 100644 index 000000000..ecc482892 --- /dev/null +++ b/packages/cli/src/commands/analyze/colors.ts @@ -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() + 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() + 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('') + } +}) diff --git a/packages/cli/src/commands/analyze/index.ts b/packages/cli/src/commands/analyze/index.ts new file mode 100644 index 000000000..54d2aebce --- /dev/null +++ b/packages/cli/src/commands/analyze/index.ts @@ -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 + } +}) diff --git a/packages/cli/src/commands/analyze/spacing.ts b/packages/cli/src/commands/analyze/spacing.ts new file mode 100644 index 000000000..3a091b48a --- /dev/null +++ b/packages/cli/src/commands/analyze/spacing.ts @@ -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() + const paddingMap = new Map() + 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) => + [...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('') + } +}) diff --git a/packages/cli/src/commands/analyze/typography.ts b/packages/cli/src/commands/analyze/typography.ts new file mode 100644 index 000000000..113599fd2 --- /dev/null +++ b/packages/cli/src/commands/analyze/typography.ts @@ -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() + 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() + 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() + 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() + 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('') + } +}) diff --git a/packages/cli/src/commands/node.ts b/packages/cli/src/commands/node.ts new file mode 100644 index 000000000..18fe247a6 --- /dev/null +++ b/packages/cli/src/commands/node.ts @@ -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 { + 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('') + } +}) diff --git a/packages/cli/src/commands/pages.ts b/packages/cli/src/commands/pages.ts new file mode 100644 index 000000000..102b3a678 --- /dev/null +++ b/packages/cli/src/commands/pages.ts @@ -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('') + } +}) diff --git a/packages/cli/src/commands/variables.ts b/packages/cli/src/commands/variables.ts new file mode 100644 index 000000000..4bb292cba --- /dev/null +++ b/packages/cli/src/commands/variables.ts @@ -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('') + } +}) diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index 19a8e9059..5b2cfbe61 100755 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -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 } })