@open-pencil/cli 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json ADDED
@@ -0,0 +1,27 @@
1
+ {
2
+ "name": "@open-pencil/cli",
3
+ "version": "0.1.0",
4
+ "type": "module",
5
+ "bin": {
6
+ "open-pencil": "./src/index.ts"
7
+ },
8
+ "files": ["src"],
9
+ "repository": {
10
+ "type": "git",
11
+ "url": "git+https://github.com/open-pencil/open-pencil.git",
12
+ "directory": "packages/cli"
13
+ },
14
+ "publishConfig": {
15
+ "access": "public",
16
+ "provenance": true
17
+ },
18
+ "dependencies": {
19
+ "@open-pencil/core": "workspace:*",
20
+ "agentfmt": "^0.1.3",
21
+ "canvaskit-wasm": "^0.40.0",
22
+ "citty": "^0.1.6"
23
+ },
24
+ "devDependencies": {
25
+ "@types/bun": "^1.2.9"
26
+ }
27
+ }
@@ -0,0 +1,171 @@
1
+ import { defineCommand } from 'citty'
2
+
3
+ import { loadDocument } from '../../headless'
4
+ import { bold, fmtList, fmtSummary } from '../../format'
5
+ import type { SceneNode, SceneGraph } from '@open-pencil/core'
6
+
7
+ interface ClusterNode {
8
+ id: string
9
+ name: string
10
+ type: string
11
+ width: number
12
+ height: number
13
+ childCount: number
14
+ }
15
+
16
+ interface Cluster {
17
+ signature: string
18
+ nodes: ClusterNode[]
19
+ }
20
+
21
+ function buildSignature(graph: SceneGraph, node: SceneNode): string {
22
+ const childTypes = new Map<string, number>()
23
+ for (const childId of node.childIds) {
24
+ const child = graph.getNode(childId)
25
+ if (!child) continue
26
+ childTypes.set(child.type, (childTypes.get(child.type) ?? 0) + 1)
27
+ }
28
+ const childPart = [...childTypes.entries()]
29
+ .sort((a, b) => a[0].localeCompare(b[0]))
30
+ .map(([t, c]) => `${t}:${c}`)
31
+ .join(',')
32
+
33
+ const w = Math.round(node.width / 10) * 10
34
+ const h = Math.round(node.height / 10) * 10
35
+ return `${node.type}:${w}x${h}|${childPart}`
36
+ }
37
+
38
+ function calcConfidence(nodes: ClusterNode[]): number {
39
+ if (nodes.length < 2) return 100
40
+ const base = nodes[0]!
41
+ let score = 0
42
+ for (const node of nodes.slice(1)) {
43
+ const sizeDiff = Math.abs(node.width - base.width) + Math.abs(node.height - base.height)
44
+ const childDiff = Math.abs(node.childCount - base.childCount)
45
+ if (sizeDiff <= 4 && childDiff === 0) score++
46
+ else if (sizeDiff <= 10 && childDiff <= 1) score += 0.8
47
+ else if (sizeDiff <= 20 && childDiff <= 2) score += 0.6
48
+ else score += 0.4
49
+ }
50
+ return Math.round((score / (nodes.length - 1)) * 100)
51
+ }
52
+
53
+ function formatSignature(sig: string): string {
54
+ const [typeSize, children] = sig.split('|')
55
+ const type = typeSize?.split(':')[0]
56
+ if (!type) return sig
57
+ const typeName = type.charAt(0) + type.slice(1).toLowerCase()
58
+ if (!children) return typeName
59
+
60
+ const childParts = children.split(',').map((c) => {
61
+ const [t, count] = c.split(':')
62
+ if (!t) return ''
63
+ const name = t.charAt(0) + t.slice(1).toLowerCase()
64
+ return Number(count) > 1 ? `${name}×${count}` : name
65
+ })
66
+
67
+ return `${typeName} > [${childParts.join(', ')}]`
68
+ }
69
+
70
+ function findClusters(
71
+ graph: SceneGraph,
72
+ minSize: number,
73
+ minCount: number
74
+ ): { clusters: Cluster[]; totalNodes: number } {
75
+ const sigMap = new Map<string, ClusterNode[]>()
76
+ let totalNodes = 0
77
+
78
+ for (const node of graph.getAllNodes()) {
79
+ if (node.type === 'CANVAS') continue
80
+ totalNodes++
81
+ if (node.width < minSize || node.height < minSize) continue
82
+ if (node.childIds.length === 0) continue
83
+
84
+ const sig = buildSignature(graph, node)
85
+ const arr = sigMap.get(sig) ?? []
86
+ arr.push({
87
+ id: node.id,
88
+ name: node.name,
89
+ type: node.type,
90
+ width: Math.round(node.width),
91
+ height: Math.round(node.height),
92
+ childCount: node.childIds.length
93
+ })
94
+ sigMap.set(sig, arr)
95
+ }
96
+
97
+ const clusters = [...sigMap.entries()]
98
+ .filter(([, nodes]) => nodes.length >= minCount)
99
+ .map(([signature, nodes]) => ({ signature, nodes }))
100
+ .sort((a, b) => b.nodes.length - a.nodes.length)
101
+
102
+ return { clusters, totalNodes }
103
+ }
104
+
105
+ export default defineCommand({
106
+ meta: { description: 'Find repeated design patterns (potential components)' },
107
+ args: {
108
+ file: { type: 'positional', description: '.fig file path', required: true },
109
+ limit: { type: 'string', description: 'Max clusters to show', default: '20' },
110
+ 'min-size': { type: 'string', description: 'Min node size in px', default: '30' },
111
+ 'min-count': { type: 'string', description: 'Min instances to form cluster', default: '2' },
112
+ json: { type: 'boolean', description: 'Output as JSON' }
113
+ },
114
+ async run({ args }) {
115
+ const graph = await loadDocument(args.file)
116
+ const limit = Number(args.limit)
117
+ const minSize = Number(args['min-size'])
118
+ const minCount = Number(args['min-count'])
119
+ const { clusters, totalNodes } = findClusters(graph, minSize, minCount)
120
+
121
+ if (args.json) {
122
+ console.log(JSON.stringify({ clusters: clusters.slice(0, limit), totalNodes }, null, 2))
123
+ return
124
+ }
125
+
126
+ if (clusters.length === 0) {
127
+ console.log('No repeated patterns found.')
128
+ return
129
+ }
130
+
131
+ console.log('')
132
+ console.log(bold(' Repeated patterns'))
133
+ console.log('')
134
+
135
+ const items = clusters.slice(0, limit).map((c) => {
136
+ const first = c.nodes[0]!
137
+ const confidence = calcConfidence(c.nodes)
138
+
139
+ const widths = c.nodes.map((n) => n.width)
140
+ const heights = c.nodes.map((n) => n.height)
141
+ const wRange = Math.max(...widths) - Math.min(...widths)
142
+ const hRange = Math.max(...heights) - Math.min(...heights)
143
+ const avgW = Math.round(widths.reduce((a, b) => a + b, 0) / widths.length)
144
+ const avgH = Math.round(heights.reduce((a, b) => a + b, 0) / heights.length)
145
+
146
+ const sizeStr =
147
+ wRange <= 4 && hRange <= 4
148
+ ? `${avgW}×${avgH}`
149
+ : `${avgW}×${avgH} (±${Math.max(wRange, hRange)}px)`
150
+
151
+ return {
152
+ header: `${c.nodes.length}× ${first.type.toLowerCase()} "${first.name}" (${confidence}% match)`,
153
+ details: {
154
+ size: sizeStr,
155
+ structure: formatSignature(c.signature),
156
+ examples: c.nodes.slice(0, 3).map((n) => n.id).join(', ')
157
+ }
158
+ }
159
+ })
160
+
161
+ console.log(fmtList(items, { numbered: true }))
162
+
163
+ const clusteredNodes = clusters.reduce((sum, c) => sum + c.nodes.length, 0)
164
+ console.log('')
165
+ console.log(
166
+ fmtSummary({ clusters: clusters.length }) +
167
+ ` from ${totalNodes} nodes (${clusteredNodes} clustered)`
168
+ )
169
+ console.log('')
170
+ }
171
+ })
@@ -0,0 +1,203 @@
1
+ import { defineCommand } from 'citty'
2
+
3
+ import { loadDocument } from '../../headless'
4
+ import { bold, fmtHistogram, fmtList, fmtSummary } from '../../format'
5
+ import type { SceneGraph } from '@open-pencil/core'
6
+
7
+ interface ColorInfo {
8
+ hex: string
9
+ count: number
10
+ variableName: string | null
11
+ }
12
+
13
+ function toHex(r: number, g: number, b: number): string {
14
+ return (
15
+ '#' +
16
+ [r, g, b]
17
+ .map((c) =>
18
+ Math.round(c * 255)
19
+ .toString(16)
20
+ .padStart(2, '0')
21
+ )
22
+ .join('')
23
+ )
24
+ }
25
+
26
+ function hexToRgb(hex: string): [number, number, number] {
27
+ const clean = hex.replace('#', '')
28
+ return [
29
+ parseInt(clean.slice(0, 2), 16),
30
+ parseInt(clean.slice(2, 4), 16),
31
+ parseInt(clean.slice(4, 6), 16)
32
+ ]
33
+ }
34
+
35
+ function colorDistance(hex1: string, hex2: string): number {
36
+ const [r1, g1, b1] = hexToRgb(hex1)
37
+ const [r2, g2, b2] = hexToRgb(hex2)
38
+ return Math.sqrt((r1 - r2) ** 2 + (g1 - g2) ** 2 + (b1 - b2) ** 2)
39
+ }
40
+
41
+ interface Cluster {
42
+ colors: ColorInfo[]
43
+ suggestedHex: string
44
+ totalCount: number
45
+ }
46
+
47
+ function clusterColors(colors: ColorInfo[], threshold: number): Cluster[] {
48
+ const clusters: Cluster[] = []
49
+ const used = new Set<string>()
50
+ const sorted = [...colors].sort((a, b) => b.count - a.count)
51
+
52
+ for (const color of sorted) {
53
+ if (used.has(color.hex)) continue
54
+
55
+ const cluster: Cluster = {
56
+ colors: [color],
57
+ suggestedHex: color.hex,
58
+ totalCount: color.count
59
+ }
60
+ used.add(color.hex)
61
+
62
+ for (const other of sorted) {
63
+ if (used.has(other.hex)) continue
64
+ if (colorDistance(color.hex, other.hex) <= threshold) {
65
+ cluster.colors.push(other)
66
+ cluster.totalCount += other.count
67
+ used.add(other.hex)
68
+ }
69
+ }
70
+
71
+ if (cluster.colors.length > 1) clusters.push(cluster)
72
+ }
73
+
74
+ return clusters.sort((a, b) => b.colors.length - a.colors.length)
75
+ }
76
+
77
+ function collectColors(graph: SceneGraph): { colors: ColorInfo[]; totalNodes: number } {
78
+ const colorMap = new Map<string, ColorInfo>()
79
+ let totalNodes = 0
80
+
81
+ const addColor = (hex: string, variableName: string | null) => {
82
+ const existing = colorMap.get(hex)
83
+ if (existing) {
84
+ existing.count++
85
+ if (variableName && !existing.variableName) existing.variableName = variableName
86
+ } else {
87
+ colorMap.set(hex, { hex, count: 1, variableName })
88
+ }
89
+ }
90
+
91
+ for (const node of graph.getAllNodes()) {
92
+ if (node.type === 'CANVAS') continue
93
+ totalNodes++
94
+
95
+ for (const fill of node.fills) {
96
+ if (!fill.visible || fill.type !== 'SOLID') continue
97
+ const hex = toHex(fill.color.r, fill.color.g, fill.color.b)
98
+ addColor(hex, null)
99
+ }
100
+
101
+ for (const stroke of node.strokes) {
102
+ if (!stroke.visible) continue
103
+ const hex = toHex(stroke.color.r, stroke.color.g, stroke.color.b)
104
+ addColor(hex, null)
105
+ }
106
+
107
+ for (const effect of node.effects) {
108
+ if (!effect.visible) continue
109
+ const hex = toHex(effect.color.r, effect.color.g, effect.color.b)
110
+ addColor(hex, null)
111
+ }
112
+
113
+ for (const [field, varId] of Object.entries(node.boundVariables)) {
114
+ if (!field.includes('fill') && !field.includes('stroke') && !field.includes('color'))
115
+ continue
116
+ const variable = graph.variables.get(varId)
117
+ if (variable) {
118
+ const resolvedColor = graph.resolveColorVariable(varId)
119
+ if (resolvedColor) {
120
+ const hex = toHex(resolvedColor.r, resolvedColor.g, resolvedColor.b)
121
+ const existing = colorMap.get(hex)
122
+ if (existing) existing.variableName = variable.name
123
+ }
124
+ }
125
+ }
126
+ }
127
+
128
+ return { colors: [...colorMap.values()], totalNodes }
129
+ }
130
+
131
+ export default defineCommand({
132
+ meta: { description: 'Analyze color palette usage' },
133
+ args: {
134
+ file: { type: 'positional', description: '.fig file path', required: true },
135
+ limit: { type: 'string', description: 'Max colors to show', default: '30' },
136
+ threshold: {
137
+ type: 'string',
138
+ description: 'Distance threshold for clustering similar colors (0–50)',
139
+ default: '15'
140
+ },
141
+ similar: { type: 'boolean', description: 'Show similar color clusters' },
142
+ json: { type: 'boolean', description: 'Output as JSON' }
143
+ },
144
+ async run({ args }) {
145
+ const graph = await loadDocument(args.file)
146
+ const limit = Number(args.limit)
147
+ const threshold = Number(args.threshold)
148
+ const { colors, totalNodes } = collectColors(graph)
149
+
150
+ if (args.json) {
151
+ const clusters = args.similar ? clusterColors(colors, threshold) : []
152
+ console.log(JSON.stringify({ colors, totalNodes, clusters }, null, 2))
153
+ return
154
+ }
155
+
156
+ if (colors.length === 0) {
157
+ console.log('No colors found.')
158
+ return
159
+ }
160
+
161
+ const sorted = colors.sort((a, b) => b.count - a.count).slice(0, limit)
162
+
163
+ console.log('')
164
+ console.log(bold(' Colors by usage'))
165
+ console.log('')
166
+ console.log(
167
+ fmtHistogram(
168
+ sorted.map((c) => ({
169
+ label: c.hex,
170
+ value: c.count,
171
+ tag: c.variableName ? `$${c.variableName}` : undefined
172
+ }))
173
+ )
174
+ )
175
+
176
+ const hardcoded = colors.filter((c) => !c.variableName)
177
+ const fromVars = colors.filter((c) => c.variableName)
178
+
179
+ console.log('')
180
+ console.log(
181
+ fmtSummary({ 'unique colors': colors.length, 'from variables': fromVars.length, hardcoded: hardcoded.length })
182
+ )
183
+
184
+ if (args.similar) {
185
+ const clusters = clusterColors(hardcoded, threshold)
186
+ if (clusters.length > 0) {
187
+ console.log('')
188
+ console.log(bold(' Similar colors (consider merging)'))
189
+ console.log('')
190
+ console.log(
191
+ fmtList(
192
+ clusters.slice(0, 10).map((cluster) => ({
193
+ header: cluster.colors.map((c) => c.hex).join(', '),
194
+ details: { suggest: cluster.suggestedHex, total: `${cluster.totalCount}×` }
195
+ }))
196
+ )
197
+ )
198
+ }
199
+ }
200
+
201
+ console.log('')
202
+ }
203
+ })
@@ -0,0 +1,16 @@
1
+ import { defineCommand } from 'citty'
2
+
3
+ import colors from './colors'
4
+ import typography from './typography'
5
+ import spacing from './spacing'
6
+ import clusters from './clusters'
7
+
8
+ export default defineCommand({
9
+ meta: { description: 'Analyze design tokens and patterns' },
10
+ subCommands: {
11
+ colors,
12
+ typography,
13
+ spacing,
14
+ clusters
15
+ }
16
+ })
@@ -0,0 +1,119 @@
1
+ import { defineCommand } from 'citty'
2
+
3
+ import { loadDocument } from '../../headless'
4
+ import { bold, kv, fmtHistogram, fmtSummary } from '../../format'
5
+ import type { SceneGraph } from '@open-pencil/core'
6
+
7
+ interface SpacingValue {
8
+ value: number
9
+ count: number
10
+ }
11
+
12
+ function collectSpacing(graph: SceneGraph): {
13
+ gaps: SpacingValue[]
14
+ paddings: SpacingValue[]
15
+ totalNodes: number
16
+ } {
17
+ const gapMap = new Map<number, number>()
18
+ const paddingMap = new Map<number, number>()
19
+ let totalNodes = 0
20
+
21
+ for (const node of graph.getAllNodes()) {
22
+ if (node.type === 'CANVAS') continue
23
+ if (node.layoutMode === 'NONE') continue
24
+ totalNodes++
25
+
26
+ if (node.itemSpacing > 0) {
27
+ gapMap.set(node.itemSpacing, (gapMap.get(node.itemSpacing) ?? 0) + 1)
28
+ }
29
+ if (node.counterAxisSpacing > 0) {
30
+ gapMap.set(node.counterAxisSpacing, (gapMap.get(node.counterAxisSpacing) ?? 0) + 1)
31
+ }
32
+
33
+ for (const pad of [node.paddingTop, node.paddingRight, node.paddingBottom, node.paddingLeft]) {
34
+ if (pad > 0) paddingMap.set(pad, (paddingMap.get(pad) ?? 0) + 1)
35
+ }
36
+ }
37
+
38
+ const toValues = (map: Map<number, number>) =>
39
+ [...map.entries()]
40
+ .map(([value, count]) => ({ value, count }))
41
+ .sort((a, b) => b.count - a.count)
42
+
43
+ return { gaps: toValues(gapMap), paddings: toValues(paddingMap), totalNodes }
44
+ }
45
+
46
+ export default defineCommand({
47
+ meta: { description: 'Analyze spacing values (gap, padding)' },
48
+ args: {
49
+ file: { type: 'positional', description: '.fig file path', required: true },
50
+ grid: { type: 'string', description: 'Base grid size to check against', default: '8' },
51
+ json: { type: 'boolean', description: 'Output as JSON' }
52
+ },
53
+ async run({ args }) {
54
+ const graph = await loadDocument(args.file)
55
+ const gridSize = Number(args.grid)
56
+ const { gaps, paddings, totalNodes } = collectSpacing(graph)
57
+
58
+ if (args.json) {
59
+ console.log(JSON.stringify({ gaps, paddings, totalNodes }, null, 2))
60
+ return
61
+ }
62
+
63
+ console.log('')
64
+
65
+ if (gaps.length > 0) {
66
+ console.log(bold(' Gap values'))
67
+ console.log('')
68
+ console.log(
69
+ fmtHistogram(
70
+ gaps.slice(0, 15).map((g) => ({
71
+ label: `${String(g.value).padStart(4)}px`,
72
+ value: g.count,
73
+ suffix: g.value % gridSize !== 0 ? '⚠' : undefined
74
+ }))
75
+ )
76
+ )
77
+ console.log('')
78
+ }
79
+
80
+ if (paddings.length > 0) {
81
+ console.log(bold(' Padding values'))
82
+ console.log('')
83
+ console.log(
84
+ fmtHistogram(
85
+ paddings.slice(0, 15).map((p) => ({
86
+ label: `${String(p.value).padStart(4)}px`,
87
+ value: p.count,
88
+ suffix: p.value % gridSize !== 0 ? '⚠' : undefined
89
+ }))
90
+ )
91
+ )
92
+ console.log('')
93
+ }
94
+
95
+ if (gaps.length === 0 && paddings.length === 0) {
96
+ console.log('No auto-layout nodes with spacing found.')
97
+ console.log('')
98
+ return
99
+ }
100
+
101
+ console.log(fmtSummary({ 'gap values': gaps.length, 'padding values': paddings.length }))
102
+
103
+ const offGridGaps = gaps.filter((g) => g.value % gridSize !== 0)
104
+ const offGridPaddings = paddings.filter((p) => p.value % gridSize !== 0)
105
+
106
+ if (offGridGaps.length > 0 || offGridPaddings.length > 0) {
107
+ console.log('')
108
+ console.log(bold(` ⚠ Off-grid values (not ÷${gridSize}px)`))
109
+ if (offGridGaps.length > 0) {
110
+ console.log(kv('Gaps', offGridGaps.map((g) => `${g.value}px`).join(', ')))
111
+ }
112
+ if (offGridPaddings.length > 0) {
113
+ console.log(kv('Paddings', offGridPaddings.map((p) => `${p.value}px`).join(', ')))
114
+ }
115
+ }
116
+
117
+ console.log('')
118
+ }
119
+ })
@@ -0,0 +1,139 @@
1
+ import { defineCommand } from 'citty'
2
+
3
+ import { loadDocument } from '../../headless'
4
+ import { bold, fmtHistogram, fmtSummary } from '../../format'
5
+ import type { SceneGraph } from '@open-pencil/core'
6
+
7
+ interface TypographyStyle {
8
+ family: string
9
+ size: number
10
+ weight: number
11
+ lineHeight: string
12
+ count: number
13
+ }
14
+
15
+ function collectTypography(graph: SceneGraph): { styles: TypographyStyle[]; totalTextNodes: number } {
16
+ const styleMap = new Map<string, TypographyStyle>()
17
+ let totalTextNodes = 0
18
+
19
+ for (const node of graph.getAllNodes()) {
20
+ if (node.type !== 'TEXT') continue
21
+ totalTextNodes++
22
+
23
+ const lh = node.lineHeight === null ? 'auto' : `${node.lineHeight}px`
24
+ const key = `${node.fontFamily}|${node.fontSize}|${node.fontWeight}|${lh}`
25
+
26
+ const existing = styleMap.get(key)
27
+ if (existing) {
28
+ existing.count++
29
+ } else {
30
+ styleMap.set(key, {
31
+ family: node.fontFamily,
32
+ size: node.fontSize,
33
+ weight: node.fontWeight,
34
+ lineHeight: lh,
35
+ count: 1
36
+ })
37
+ }
38
+ }
39
+
40
+ return { styles: [...styleMap.values()], totalTextNodes }
41
+ }
42
+
43
+ function weightName(w: number): string {
44
+ if (w <= 100) return 'Thin'
45
+ if (w <= 200) return 'ExtraLight'
46
+ if (w <= 300) return 'Light'
47
+ if (w <= 400) return 'Regular'
48
+ if (w <= 500) return 'Medium'
49
+ if (w <= 600) return 'SemiBold'
50
+ if (w <= 700) return 'Bold'
51
+ if (w <= 800) return 'ExtraBold'
52
+ return 'Black'
53
+ }
54
+
55
+ export default defineCommand({
56
+ meta: { description: 'Analyze typography usage' },
57
+ args: {
58
+ file: { type: 'positional', description: '.fig file path', required: true },
59
+ 'group-by': {
60
+ type: 'string',
61
+ description: 'Group by: family, size, weight (default: show all styles)'
62
+ },
63
+ limit: { type: 'string', description: 'Max styles to show', default: '30' },
64
+ json: { type: 'boolean', description: 'Output as JSON' }
65
+ },
66
+ async run({ args }) {
67
+ const graph = await loadDocument(args.file)
68
+ const limit = Number(args.limit)
69
+ const groupBy = args['group-by']
70
+ const { styles, totalTextNodes } = collectTypography(graph)
71
+
72
+ if (args.json) {
73
+ console.log(JSON.stringify({ styles, totalTextNodes }, null, 2))
74
+ return
75
+ }
76
+
77
+ if (styles.length === 0) {
78
+ console.log('No text nodes found.')
79
+ return
80
+ }
81
+
82
+ const sorted = styles.sort((a, b) => b.count - a.count)
83
+
84
+ console.log('')
85
+
86
+ if (groupBy === 'family') {
87
+ const byFamily = new Map<string, number>()
88
+ for (const s of sorted) byFamily.set(s.family, (byFamily.get(s.family) ?? 0) + s.count)
89
+ console.log(bold(' Font families'))
90
+ console.log('')
91
+ console.log(
92
+ fmtHistogram(
93
+ [...byFamily.entries()]
94
+ .sort((a, b) => b[1] - a[1])
95
+ .map(([family, count]) => ({ label: family, value: count }))
96
+ )
97
+ )
98
+ } else if (groupBy === 'size') {
99
+ const bySize = new Map<number, number>()
100
+ for (const s of sorted) bySize.set(s.size, (bySize.get(s.size) ?? 0) + s.count)
101
+ console.log(bold(' Font sizes'))
102
+ console.log('')
103
+ console.log(
104
+ fmtHistogram(
105
+ [...bySize.entries()]
106
+ .sort((a, b) => a[0] - b[0])
107
+ .map(([size, count]) => ({ label: `${size}px`, value: count }))
108
+ )
109
+ )
110
+ } else if (groupBy === 'weight') {
111
+ const byWeight = new Map<number, number>()
112
+ for (const s of sorted) byWeight.set(s.weight, (byWeight.get(s.weight) ?? 0) + s.count)
113
+ console.log(bold(' Font weights'))
114
+ console.log('')
115
+ console.log(
116
+ fmtHistogram(
117
+ [...byWeight.entries()]
118
+ .sort((a, b) => b[1] - a[1])
119
+ .map(([weight, count]) => ({ label: `${weight} ${weightName(weight)}`, value: count }))
120
+ )
121
+ )
122
+ } else {
123
+ console.log(bold(' Typography styles'))
124
+ console.log('')
125
+ const items = sorted.slice(0, limit).map((s) => {
126
+ const lh = s.lineHeight !== 'auto' ? ` / ${s.lineHeight}` : ''
127
+ return {
128
+ label: `${s.family} ${s.size}px ${weightName(s.weight)}${lh}`,
129
+ value: s.count
130
+ }
131
+ })
132
+ console.log(fmtHistogram(items))
133
+ }
134
+
135
+ console.log('')
136
+ console.log(fmtSummary({ 'unique styles': styles.length }) + ` from ${totalTextNodes} text nodes`)
137
+ console.log('')
138
+ }
139
+ })