Skip to content

Commit 80acc9d

Browse files
feat: add cli (#34)
* feat: add cli for toon * docs: use npx in the readme * feat: overhaul and refactor --------- Co-authored-by: Johann Schopplich <[email protected]>
1 parent 28896e1 commit 80acc9d

File tree

8 files changed

+275
-1
lines changed

8 files changed

+275
-1
lines changed

README.md

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -411,6 +411,54 @@ pnpm add @byjohann/toon
411411
yarn add @byjohann/toon
412412
```
413413

414+
## CLI
415+
416+
Command-line tool for converting between JSON and TOON formats.
417+
418+
### Usage
419+
420+
```bash
421+
npx @byjohann/toon <input> [options]
422+
```
423+
424+
**Auto-detection:** The CLI automatically detects the operation based on file extension (`.json` → encode, `.toon` → decode).
425+
426+
```bash
427+
# Encode JSON to TOON (auto-detected)
428+
toon input.json -o output.toon
429+
430+
# Decode TOON to JSON (auto-detected)
431+
toon data.toon -o output.json
432+
433+
# Output to stdout
434+
toon input.json
435+
```
436+
437+
### Options
438+
439+
| Option | Description |
440+
| ------ | ----------- |
441+
| `-o, --output <file>` | Output file path (prints to stdout if omitted) |
442+
| `-e, --encode` | Force encode mode (overrides auto-detection) |
443+
| `-d, --decode` | Force decode mode (overrides auto-detection) |
444+
| `--delimiter <char>` | Array delimiter: `,` (comma), `\t` (tab), `\|` (pipe) |
445+
| `--indent <number>` | Indentation size (default: `2`) |
446+
| `--length-marker` | Add `#` prefix to array lengths (e.g., `items[#3]`) |
447+
| `--no-strict` | Disable strict validation when decoding |
448+
449+
### Examples
450+
451+
```bash
452+
# Tab-separated output (often more token-efficient)
453+
toon data.json --delimiter "\t" -o output.toon
454+
455+
# Pipe-separated with length markers
456+
toon data.json --delimiter "|" --length-marker -o output.toon
457+
458+
# Lenient decoding (skip validation)
459+
toon data.toon --no-strict -o output.json
460+
```
461+
414462
## Quick Start
415463

416464
```ts

bin/toon.mjs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
#!/usr/bin/env node
2+
'use strict'
3+
import('../dist/cli/index.js')

cli/package.json

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
{
2+
"name": "@toon/cli",
3+
"type": "module",
4+
"private": true,
5+
"scripts": {
6+
"dev": "tsx ./src/index.ts"
7+
},
8+
"dependencies": {
9+
"citty": "^0.1.6",
10+
"consola": "^3.4.2"
11+
}
12+
}

cli/src/index.ts

Lines changed: 197 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,197 @@
1+
import type { DecodeOptions, EncodeOptions } from '../../src'
2+
import * as fsp from 'node:fs/promises'
3+
import * as path from 'node:path'
4+
import process from 'node:process'
5+
import { defineCommand, runMain } from 'citty'
6+
import { consola } from 'consola'
7+
import { version } from '../../package.json' with { type: 'json' }
8+
import { decode, DELIMITERS, encode } from '../../src'
9+
10+
const main = defineCommand({
11+
meta: {
12+
name: 'toon',
13+
description: 'TOON CLI — Convert between JSON and TOON formats',
14+
version,
15+
},
16+
args: {
17+
input: {
18+
type: 'positional',
19+
description: 'Input file path',
20+
required: true,
21+
},
22+
output: {
23+
type: 'string',
24+
description: 'Output file path',
25+
alias: 'o',
26+
},
27+
encode: {
28+
type: 'boolean',
29+
description: 'Encode JSON to TOON (auto-detected by default)',
30+
alias: 'e',
31+
},
32+
decode: {
33+
type: 'boolean',
34+
description: 'Decode TOON to JSON (auto-detected by default)',
35+
alias: 'd',
36+
},
37+
delimiter: {
38+
type: 'string',
39+
description: 'Delimiter for arrays: comma (,), tab (\\t), or pipe (|)',
40+
default: ',',
41+
},
42+
indent: {
43+
type: 'string',
44+
description: 'Indentation size',
45+
default: '2',
46+
},
47+
lengthMarker: {
48+
type: 'boolean',
49+
description: 'Use length marker (#) for arrays',
50+
default: false,
51+
},
52+
strict: {
53+
type: 'boolean',
54+
description: 'Enable strict mode for decoding',
55+
default: true,
56+
},
57+
},
58+
async run({ args }) {
59+
const input = args.input || args._[0]
60+
if (!input) {
61+
throw new Error('Input file path is required')
62+
}
63+
64+
const inputPath = path.resolve(input)
65+
const outputPath = args.output ? path.resolve(args.output) : undefined
66+
67+
// Parse and validate indent
68+
const indent = Number.parseInt(args.indent || '2', 10)
69+
if (Number.isNaN(indent) || indent < 0) {
70+
throw new Error(`Invalid indent value: ${args.indent}`)
71+
}
72+
73+
// Validate delimiter
74+
const delimiter = args.delimiter || ','
75+
if (!Object.values(DELIMITERS).includes(delimiter as any)) {
76+
throw new Error(`Invalid delimiter "${delimiter}". Valid delimiters are: comma (,), tab (\\t), pipe (|)`)
77+
}
78+
79+
const mode = detectMode(inputPath, args.encode, args.decode)
80+
81+
try {
82+
if (mode === 'encode') {
83+
await encodeToToon({
84+
input: inputPath,
85+
output: outputPath,
86+
delimiter: delimiter as ',' | '\t' | '|',
87+
indent,
88+
lengthMarker: args.lengthMarker === true ? '#' : false,
89+
})
90+
}
91+
else {
92+
await decodeToJson({
93+
input: inputPath,
94+
output: outputPath,
95+
indent,
96+
strict: args.strict !== false,
97+
})
98+
}
99+
}
100+
catch (error) {
101+
consola.error(error)
102+
process.exit(1)
103+
}
104+
},
105+
})
106+
107+
function detectMode(
108+
inputFile: string,
109+
encodeFlag?: boolean,
110+
decodeFlag?: boolean,
111+
): 'encode' | 'decode' {
112+
// Explicit flags take precedence
113+
if (encodeFlag)
114+
return 'encode'
115+
if (decodeFlag)
116+
return 'decode'
117+
118+
// Auto-detect based on file extension
119+
if (inputFile.endsWith('.json'))
120+
return 'encode'
121+
if (inputFile.endsWith('.toon'))
122+
return 'decode'
123+
124+
// Default to encode
125+
return 'encode'
126+
}
127+
128+
async function encodeToToon(config: {
129+
input: string
130+
output?: string
131+
delimiter: ',' | '\t' | '|'
132+
indent: number
133+
lengthMarker: '#' | false
134+
}) {
135+
const jsonContent = await fsp.readFile(config.input, 'utf-8')
136+
137+
let data: unknown
138+
try {
139+
data = JSON.parse(jsonContent)
140+
}
141+
catch (error) {
142+
throw new Error(`Failed to parse JSON: ${error instanceof Error ? error.message : String(error)}`)
143+
}
144+
145+
const encodeOptions: EncodeOptions = {
146+
delimiter: config.delimiter,
147+
indent: config.indent,
148+
lengthMarker: config.lengthMarker,
149+
}
150+
151+
const toonOutput = encode(data, encodeOptions)
152+
153+
if (config.output) {
154+
await fsp.writeFile(config.output, toonOutput, 'utf-8')
155+
const relativeInputPath = path.relative(process.cwd(), config.input)
156+
const relativeOutputPath = path.relative(process.cwd(), config.output)
157+
consola.success(`Encoded \`${relativeInputPath}\` → \`${relativeOutputPath}\``)
158+
}
159+
else {
160+
console.log(toonOutput)
161+
}
162+
}
163+
164+
async function decodeToJson(config: {
165+
input: string
166+
output?: string
167+
indent: number
168+
strict: boolean
169+
}) {
170+
const toonContent = await fsp.readFile(config.input, 'utf-8')
171+
172+
let data: unknown
173+
try {
174+
const decodeOptions: DecodeOptions = {
175+
indent: config.indent,
176+
strict: config.strict,
177+
}
178+
data = decode(toonContent, decodeOptions)
179+
}
180+
catch (error) {
181+
throw new Error(`Failed to decode TOON: ${error instanceof Error ? error.message : String(error)}`)
182+
}
183+
184+
const jsonOutput = JSON.stringify(data, undefined, config.indent)
185+
186+
if (config.output) {
187+
await fsp.writeFile(config.output, jsonOutput, 'utf-8')
188+
const relativeInputPath = path.relative(process.cwd(), config.input)
189+
const relativeOutputPath = path.relative(process.cwd(), config.output)
190+
consola.success(`Decoded \`${relativeInputPath}\` → \`${relativeOutputPath}\``)
191+
}
192+
else {
193+
console.log(jsonOutput)
194+
}
195+
}
196+
197+
runMain(main)

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@
2323
},
2424
"types": "./dist/index.d.ts",
2525
"files": [
26+
"bin",
2627
"dist"
2728
],
2829
"scripts": {

pnpm-lock.yaml

Lines changed: 9 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

pnpm-workspace.yaml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,3 @@
11
packages:
22
- benchmarks
3+
- cli

tsdown.config.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,10 @@ import type { UserConfig, UserConfigFn } from 'tsdown/config'
22
import { defineConfig } from 'tsdown/config'
33

44
const config: UserConfig | UserConfigFn = defineConfig({
5-
entry: ['src/index.ts'],
5+
entry: {
6+
'index': 'src/index.ts',
7+
'cli/index': 'cli/src/index.ts',
8+
},
69
dts: true,
710
})
811

0 commit comments

Comments
 (0)