Compare commits

..

166 Commits

Author SHA1 Message Date
Phumrapee Limpianchop 1336074d43 fix(decode): prevent false array header detection for unquoted keys c… (#314)
CI / ci (push) Waiting to run
Deploy Docs / Deploy Docs (push) Waiting to run
* fix(decode): prevent false array header detection for unquoted keys containing brackets and colons

* test(decode): pin stream parity for an unquoted bracket-colon scalar

* chore(toon): bump `@toon-format/spec` to ^3.3.2

---------

Co-authored-by: Johann Schopplich <johann@schopplich.com>
2026-07-18 21:15:25 +02:00
Johann Schopplich 352a5b017d chore: release v2.3.1
Release + Publish / Release (push) Waiting to run
2026-07-16 08:06:50 +02:00
Johann Schopplich e2003970dd chore: revert v2.3.1 version bump and quote build filter glob 2026-07-16 08:02:04 +02:00
Johann Schopplich bb9a09ad6c chore: remove pnpm shamefully hoisting 2026-07-16 07:31:14 +02:00
Johann Schopplich 2013a7e09c chore: release v2.3.1 2026-07-16 07:26:53 +02:00
Johann Schopplich 9024af9e6a fix(decode): treat quoted spans as opaque in line classification 2026-07-16 07:22:49 +02:00
Johann Schopplich 27b65c2931 chore: upgrade dependencies 2026-07-15 23:07:17 +02:00
Chris Korhonen 94a2b7560b fix: harden encode and decode against prototype pollution (#316)
* fix: prevent prototype pollution during decode

* fix: preserve __proto__ during encode

* fix: ignore inherited tabular fields

* refactor: consolidate own-property helper and tighten __proto__ regression tests

* perf: fast-path setOwnProperty for non-__proto__ keys

* docs: trim object-utils remarks to rationale

* chore: update comment

---------

Co-authored-by: Johann Schopplich <johann@schopplich.com>
2026-07-15 22:58:34 +02:00
Johann Schopplich 09a7b38f2c test: cover malformed array length marker error context 2026-07-15 18:30:22 +02:00
Johann Schopplich 702301231c fix(decode): slice key-value rest at key token end offset 2026-07-15 18:30:22 +02:00
Johann Schopplich a19a117919 ci: update GitHub Actions to latest versions 2026-06-12 17:56:06 +02:00
Mohammad Raziei a52f2d2815 docs: add Matlab and Python implementations to ecosystem list (CToon Project) (#309)
* docs: add Python (C++ backend) implementation to ecosystem list

Added a new entry for the Python implementation with a C++ backend (ctoon) to the ecosystem documentation, ensuring it appears alongside other language implementations.

* docs: add Matlab implementation to ecosystem implementations list

Add Matlab entry (ctoon) to the ecosystem implementations table, acknowledging the new community implementation and providing its repository link and author.
2026-05-23 10:00:46 +02:00
Johann Schopplich a956757de0 test: align canonical number form titles with file conventions
Drop the (§2 carve-out) suffix from the describe block — no other test in this file references spec sections. Switch it() titles from 'emits X' to 'encodes X as Y' to match the existing converts/normalizes verb pattern.
2026-05-21 10:39:05 +02:00
Johann Schopplich c0af15e592 chore: close v3.3 release cleanup
- docs/guide/format-overview.md: drop duplicated Type Conversions table; link to canonical api.md#type-normalization (drift surface 3 -> 2 tables)
- packages/toon/test/normalization.test.ts: add 7 §2 carve-out boundary tests (encode + round-trip per value); locks current conformance against future String(value) refactors
2026-05-21 10:33:14 +02:00
Johann Schopplich 5b66523e42 chore: align with spec v3.3
- bump @toon-format/spec to ^3.3.0; update SPEC.md pointer and badges
- docs: align number-form prose with §2 carve-out (canonical in [1e-6, 1e21) or zero; exponent form permitted outside)
- docs/reference/spec.md: add lowercase booleans/null to encoder checklist; add Java to Appendix F language list
- docs/reference/efficiency-formalization.md: drop overstated "matching TOON's requirement" claim
- packages/toon/README.md: drop "What's New in v3.2" section (now stale)

All 593 tests pass (packages/toon: 497, packages/cli: 96). Encoder remains conformant by default under the v3.3 §2 carve-out via String(value).
2026-05-21 10:10:20 +02:00
Johann Schopplich e2b5611d37 docs: quote §7.2 timestamps in examples and propagate dd856fe to docs/cli
Two regrade fixes surfaced by a post-v3.2 verification swarm:

* llm-prompts.md and api.md had ISO-8601 timestamps as unquoted TOON
  values, violating §7.2 (strings containing a colon MUST be quoted).
  The spec repo's examples and packages/toon/README.md already quote
  timestamps; the docs repo had drifted.

* docs/cli/index.md is the docs-site twin of packages/cli/README.md.
  The prior v3.2 swarm (dd856fe) updated the README's --no-strict
  options row and Lenient Decoding prose to cite "header delimiter"
  but did not propagate to the parallel paragraphs in docs/cli/index.md
  (lines 159 and 234).
2026-05-20 12:28:18 +02:00
Johann Schopplich 3c66a1bead chore: release v2.3.0
Release + Publish / Release (push) Waiting to run
2026-05-20 11:48:16 +02:00
Johann Schopplich 4b8aa998c3 chore(toon): bump @toon-format/spec to ^3.2.0
Matches the lockfile bumped in d6c64d2; CI's frozen-lockfile install
fails until the manifest catches up. The dep was skip-worktree'd during
the v3.2 development window.
2026-05-20 11:42:26 +02:00
Johann Schopplich 1d23fa36d2 chore: fix linting issues 2026-05-20 11:38:55 +02:00
Johann Schopplich d6c64d23e4 chore: upgrade dependencies 2026-05-20 11:34:51 +02:00
Johann Schopplich dd856fe590 docs: align prose with v3.2 spec wording
- packages/toon/README.md: drop two v3.1 bullets that crept into the
  "What's new in v3.2" list (canonical empty-array form and \uXXXX
  escape both landed in v3.1).
- docs/reference/api.md: rename "Delimiter mismatches" entry to
  "Header delimiter mismatch" and cite §14.2 – the previous wording
  invited the false reading that non-active delimiter chars in row
  data are an error. Add §14.4 cross-ref to the duplicate-key entry.
- docs/reference/spec.md: include \uXXXX in the §7 escape list; soften
  the charset MUST claim to match §17; en-dash the §-ranges in the
  glance table.
- docs/reference/syntax-cheatsheet.md: rename "Strings with Active
  Delimiter" to "Strings Containing Delimiters" and distinguish
  active-delim (array scope) from document-delim (object field
  values) per §11.1.
- docs/guide/format-overview.md: same active-vs-document distinction
  in the delimiter paragraph; drop incorrect "strict mode" qualifier
  from the unknown-escape error claim (§7.1 rejects unconditionally);
  trim redundant "with leading zeros" parenthetical.
- docs/guide/llm-prompts.md: fix two broken anchors
  (/reference/api#decode → #decode-input-options, similar for
  encodelines); use -o instead of --output for CLI example
  consistency.
- packages/cli/README.md: enumerate what --no-strict actually skips
  in the options table; replace "delimiter consistency" with the
  more specific "header delimiter mismatches" in the Lenient Decoding
  prose to avoid the same false reading the api.md fix addresses.
2026-05-20 10:57:48 +02:00
Johann Schopplich d514942fc0 fix(decode): reject whitespace gap in array header per §6
The bracket-to-colon and bracket-to-fields checks previously trimmed
before comparing, so `items[2] :` and `items[2] {a,b}:` slipped through
strict mode. Tighten both checks to reject any non-empty gap and
distinguish whitespace-only gaps in the error message.
2026-05-20 09:32:55 +02:00
Johann Schopplich 979152508d docs(toon): bump SPEC badge to v3.2, add What's new section, fix §17 anchor
The README now flips the shields.io SPEC badge to v3.2 (the conformance
work just landed), adds a six-bullet "What's new in v3.2" subsection
between Key Features and Media Type, and fixes the stale §18.2 anchor
in the Media Type paragraph to point at §17 IANA Considerations.
2026-05-20 07:04:50 +02:00
Johann Schopplich 1446cae85d docs(cli): widen --no-strict description for v3.2 semantics
In v3.2, --no-strict no longer just skips count/indent/delimiter
validation - it also applies last-write-wins on duplicate sibling
keys and tolerates malformed array headers as plain key-value lines.
The previous "faster processing" framing didn't reflect any of this.

- Lenient Decoding sections in cli docs and packages/cli/README spell
  out which checks the decoder stops enforcing
- Options table description shifts from "Disable strict validation"
  to "Skip decode validation; last-write-wins on duplicate keys"
- --strict help text in the CLI reads "Strict decode validation
  (disable with --no-strict)"
- §17 IANA anchor fix in the cli docs media-type note
2026-05-20 07:04:41 +02:00
Johann Schopplich 13110eb975 docs: align guides and API reference with v3.2 spec changes
- Canonical empty-array form (`key: []` / root `[]`) replaces `key[0]:`
  in format-overview, syntax-cheatsheet, and the efficiency-formalization
  worked example. Legacy form still decodes.
- Escape tables grow a `\uXXXX` row covering U+0000-U+001F outside
  \n/\r/\t, with a note rejecting lone-surrogate \uXXXX values.
- "Special characters" lists widened to "any control character (U+0000-U+001F)"
  instead of the LF/HTAB/CR triplet.
- Tabular eligibility note adds the "at least one key" rule so arrays
  containing an empty {} fall back to the expanded list.
- New short example for nested arrays-of-objects as list items (`- [N]:`).
- API reference: Date row gets an info callout noting it's an
  implementation choice the spec leaves open; strict-mode bullet list
  picks up header structure, duplicate sibling keys, path-expansion
  conflicts, and lone-surrogate escapes; Expansion Conflict Resolution
  notes the matching duplicate-key policy.
- §17 IANA anchor fix in getting-started media-type paragraph.
2026-05-20 07:04:27 +02:00
Johann Schopplich 1f53af3582 docs(spec): refresh §17-19 layout, Appendix F, and §14 subsection references
- Drop the removed §17 Interoperability and §19 Core Profile bullets
- Renumber §18 IANA -> §17, add §18 Versioning and §19 IP entries
- Appendix G -> F (Host Type Normalization Examples)
- Update the Sections-at-a-Glance table to the v3.2 layout
- §14.5 -> §14.3 path-expansion reference in the Decoder Checklist
- §14 enumeration covers count/width, syntax/structural, expansion, dup keys
- Encoder Checklist mentions \uXXXX escapes alongside the five base escapes
- Major-version example swapped to v2 -> v3 / v3.1 -> v3.2
- Working Draft date updated to 2026-05-20
2026-05-20 07:04:14 +02:00
Johann Schopplich 1a9c72a6c2 chore: bump spec version references to v3.2 (2026-05-20)
Updates the `$spec.version` VitePress global property and the root SPEC.md
stub to track the published v3.2 working draft.
2026-05-20 07:03:40 +02:00
Johann Schopplich bc1ae4e194 docs: refresh decode/encode JSDoc examples
Updates the JSDoc `@example` blocks for `encode` and `decode` to reflect
v3.2 output: tabular header is `users[2]{id}:` not `users[]:`, and
empty arrays render as `tags: []`.
2026-05-19 21:11:36 +02:00
Johann Schopplich a13dcb5eec feat(decode): strict-mode error coverage for §6/§9.1/§14
Widens decoder behavior to match v3.2 strict-mode normative rules:

- Accepts the canonical `[]` empty-array form on all decode paths
  (root, key-value, list-item-after-hyphen — sync and async)
- §6: parseBracketSegment now uses an anchored regex for the length
  (rejects 0x10, 01, 1e5, -0, etc.) and throws in strict mode on
  malformed bracket segments
- §14.2: parser scans the fields segment for any unquoted candidate
  delimiter that mismatches the bracket-declared one and throws in
  strict mode
- §14.4: assertNoDuplicateKey rejects duplicate sibling keys in strict
  mode at every nested object site (root, child objects, list-item
  objects, tabular-first list items)
- Strict flag now threads through parseArrayHeaderLine so header
  diagnostics surface as decode errors rather than silent fallthrough
- Multi-line root that starts with a bare token now errors in strict
  mode with a clear diagnostic
2026-05-19 21:11:25 +02:00
Johann Schopplich e6d97d232e feat(encode): emit canonical empty-array form key: []
Per §9.1 v3.2, empty arrays MUST emit as `key: []` (and `- []` after a
list-item marker), not as `key[0]:`. The header-only emission is now
reserved for non-empty arrays.
2026-05-19 21:11:10 +02:00
Johann Schopplich 178207636d fix(decode): support \uXXXX escape per §7.1
Per the v3.2 spec, control characters U+0000–U+001F outside of \n, \r, \t
encode as \uXXXX escapes and decode them back. Lone surrogates are rejected.

- escapeString emits \uXXXX for any remaining U+0000–U+001F byte
- unescapeString recognises \uXXXX (4 hex digits) and rejects D800–DFFF
- isSafeUnquoted widens the control-char check to cover the full range
2026-05-19 21:10:53 +02:00
Father of chain caa59ae4dc docs: surface tooner mcp proxy across readme and docs site (#283)
Adds the [Tooner](https://github.com/chaindead/tooner) MCP proxy entry to the Tools & Integration list in the package README and to docs/ecosystem/tools-and-playgrounds.md.
2026-05-17 18:55:28 +02:00
ernestoalconada 269e43e960 docs(ecosystem): add delphi implementation (#273)
Adds the Delphi community port (ernestoalconada/toon) to docs/ecosystem/implementations.md.
2026-05-17 18:47:02 +02:00
Montana Flynn 86006809cd docs: add toon-zig to community implementations (#298)
Adds the Zig community port (LatentEvals/toon-zig) and fixes the Python (Rust backend) row to alphabetical position.
2026-05-17 18:43:47 +02:00
swetasanghi2004-prog 90a04bd34b feat(docs): add YAML input to Playground with tests (#303)
* feat(docs): add YAML input to Playground with tests

- Add input format selector and YAML parsing (YAML 1.2, merge keys off)
- Extract parse/stringify helpers for testability and SPEC-aligned encoding
- Add Vitest for docs package and playground parse tests
- Update Playground copy in package README and ecosystem docs

* refactor(docs): simplify Playground YAML support

- Inline parse/stringify helpers into the component, drop the separate
  parse-input module and its tests
- Rename input state from `jsonInput`/`formattedJson`/`jsonTokens` to
  `inputText`/`formattedInput`/`inputTokens` so names no longer lie when
  YAML is selected
- Hide the JSON Baseline selector in YAML mode and make the token-count
  tooltip conditional on input format
- Store input under `state.input` with a fallback read of legacy
  `state.json` so existing share URLs keep working
- Drop redundant YAML parse options (merge/version match library
  defaults); use lineWidth: 0 for editor display so long scalars do not
  fold mid-sentence
- Remove the duplicated vitest devDep, docs vitest.config.ts, and `test`
  script – the playground is verified through the dev server

* chore(docs): tighten Playground wording and drop empty-string fallback

- "in real-time" → "in real time" (real-time only hyphenates as a
  compound modifier before a noun)
- Header copy now matches the ecosystem page: "Convert JSON or YAML to
  TOON in real time"
- Token-count tooltip unified across input formats; the JSON baseline
  selector is right next to it and already communicates the format
- Drop `?? ''` fallback on state.input — decodeState already gates on
  successful JSON parse; the chain falls through to the legacy
  state.json field

---------

Co-authored-by: Sweta <sweta@Swetas-MacBook-Air.local>
Co-authored-by: Johann Schopplich <johann@schopplich.com>
2026-05-17 18:04:07 +02:00
Johann Schopplich 9ac3f9ed67 docs: remove explicit JSON type values 2026-05-08 23:23:58 +02:00
Johann Schopplich dd70538e0b chore: release v2.2.0
Release + Publish / Release (push) Waiting to run
2026-05-08 23:18:34 +02:00
Johann Schopplich 4819128859 docs: harmonize prose and fix tab delimiter examples 2026-05-08 23:17:10 +02:00
Johann Schopplich 476e4a1784 refactor: variable name 2026-05-08 22:30:55 +02:00
Johann Schopplich 18864cbd1f docs: add error handling guide 2026-05-08 22:27:28 +02:00
Johann Schopplich d826be75d7 chore: upgrade dependencies 2026-05-08 22:23:54 +02:00
Johann Schopplich 3d99b7b9ae feat(cli): render decode error with line context, source, and caret 2026-05-08 22:18:43 +02:00
Johann Schopplich 1181b14bd7 feat: throw ToonDecodeError on error 2026-05-08 22:03:25 +02:00
Johann Schopplich 0f0b1b3217 test: remove unneeded comments 2026-05-08 22:03:03 +02:00
Johann Schopplich 7dabdb303b test: remove duplications 2026-05-08 21:53:18 +02:00
Johann Schopplich ef2f900d2f chore: upgrade to TypeScript 6.0.2 and clean up tsconfig 2026-04-16 11:03:33 +02:00
Johann Schopplich e3458db261 chore: remove redundant TS config options 2026-03-31 10:42:22 +02:00
Alessio Sanfratello d5f50a2ce5 docs: add toons to community implementations (#275) 2026-03-10 19:21:27 +01:00
Johann Schopplich 76c493ceab chore: migrate wrangler config to expanded toml syntax 2026-03-10 17:35:36 +01:00
Johann Schopplich 6edc9ac5e7 chore: upgrade dependencies 2026-03-06 07:52:26 +01:00
Johann Schopplich 5a42db11f7 chore: update ESLint config 2026-03-06 07:42:21 +01:00
Johann Schopplich 4cc24e7710 chore: update ESLint config 2026-03-06 07:41:30 +01:00
Johann Schopplich a3b2f314ee chore: remove @ts-check directive from ESLint config 2026-03-06 07:36:37 +01:00
Johann Schopplich d71dfe8b86 chore: use TS for ESLint config 2026-03-05 17:46:51 +01:00
Johann Schopplich 26c52d26b4 chore(vscode): remove deprecated flat config option 2026-03-05 14:46:35 +01:00
Johann Schopplich 47c6d3d712 chore: update README with benchmarks for Gemini 3 Flash and Grok 4.1 2026-03-03 15:39:56 +01:00
Johann Schopplich 1d947bf9b6 chore(benchmarks): run for Gemini 3 Flash and Grok 4.1 2026-03-03 15:04:59 +01:00
Eugene Bann a42c87bdb6 docs: fix link to full list of implementations (#282) 2026-02-25 15:32:40 +01:00
Johann Schopplich df07261215 chore: add support for additional file types in ESLint validation 2026-02-25 08:02:56 +01:00
Johann Schopplich 6f699bd4f5 fix(decode): reject array headers with trailing content between bracket and colon 2026-02-23 10:27:22 +01:00
Johann Schopplich c6ab05141f fix: preserve empty-string keys in array headers across encode/decode (closes #281) 2026-02-22 19:06:45 +01:00
Johann Schopplich 8073392822 refactor(cli): simplify args typings 2026-02-19 14:26:04 +01:00
Johann Schopplich 932f26189e test(cli): update tests for citty v0.2.x 2026-02-19 14:25:10 +01:00
Johann Schopplich 2a335341cc docs: update implementations section in README 2026-02-19 14:18:15 +01:00
John DeRegnaucourt 9faa898e8f docs: add json-io to community implementations
json-io is a comprehensive Java library for TOON serialization that supports:
- 60+ built-in types (vs ~15 in other Java implementations)
- Any serializable type as Map keys (not just strings)
- EnumSet support
- Cyclic reference handling
- Zero external dependencies (except java-util)

Repository: https://github.com/jdereg/json-io
2026-02-19 14:16:14 +01:00
Johann Schopplich 68f66bb5d6 chore: upgrade dependencies 2026-02-19 14:13:39 +01:00
Johann Schopplich 9bb97ce103 perf: remove tsx usage 2026-02-15 10:16:40 +01:00
Johann Schopplich b1413925b7 chore: simplify tsdown config 2026-01-23 08:17:45 +01:00
Mischa Sigtermans cc0a1b8e69 docs: update Laravel implementation to spec-compliant package (#266) 2026-01-21 16:34:19 +01:00
Johann Schopplich 7b9235560e chore: lint playground docs files 2026-01-21 16:31:59 +01:00
Samson Agbo 30a6d5d93d chore: add ORM for TOON (#264)
* docs: add ToonStore to community implementations

* docs: add ToonStore to community implementations

* docs: add TORM to community implementations

* chore: revert sace

---------

Co-authored-by: Johann Schopplich <mail@johannschopplich.com>
2026-01-21 16:29:22 +01:00
Johann Schopplich 76c6133c8d docs: add ToonStore database information to tools and playgrounds (closes #262) 2026-01-16 19:09:50 +01:00
Yoshifumi Kawai 626ea62889 docs: add one more C# implmentation (ToonEncoder) 2026-01-16 18:57:37 +01:00
Johann Schopplich 69111cca5a docs: add JSON baseline format selector to playground 2026-01-08 17:16:32 +01:00
Davide Usberti f15619de6c docs: add TOONc (TOON library for C) to the community implementations (#255)
* Add C implementation link to community section

* Add C implementation to ecosystem documentation
2026-01-03 18:42:55 +01:00
Okinea Dev aaef97a1ab ci: use slim Ubuntu runners (#256)
It will be more environmentally friendly and cheaper (at least for GitHub, because it's a public project and GitHub pays for it, not you)

GitHub blog post about new 1 vCPU runners: https://github.blog/changelog/2025-10-28-1-vcpu-linux-runner-now-available-in-github-actions-in-public-preview

Pricing (tl;dr: $0.002/min): https://docs.github.com/en/billing/reference/actions-runner-pricing

GitHub-hosted runners reference: https://docs.github.com/en/actions/reference/workflows-and-actions/workflow-syntax#standard-github-hosted-runners-for--private-repositories
2026-01-03 16:12:45 +01:00
Johann Schopplich 189bbfa9bc chore: upgrade dependencies 2025-12-15 14:26:31 +01:00
Johann Schopplich bbd7e8cf58 docs: add Swift implementation to official and community lists 2025-12-07 17:40:02 +01:00
Luke Last 24746854bf docs: add Kotlin community implementations (#236)
* Add ktoon for Kotlin to the community implementations.

* Update packages/toon/README.md

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Add ktoon to the implementations readme

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-12-07 17:37:24 +01:00
Johann Schopplich 19f9a5527f docs: fix CLI options naming (fixes #241) 2025-12-07 13:10:09 +01:00
Johann Schopplich 8a2c7e6715 refactor: consolidate numeric literal pattern definition 2025-12-05 14:15:24 +01:00
Johann Schopplich 19719a136f fix: reject negative numbers with leading zeros 2025-12-05 14:12:55 +01:00
Johann Schopplich 4852a09a76 fix: remove deprecated bare list item decoder logic 2025-12-05 13:39:50 +01:00
Johann Schopplich e02c3aae3a chore: release v2.1.0
Release + Publish / Release (push) Waiting to run
2025-12-04 22:34:07 +01:00
Johann Schopplich c9382a5d53 docs: add Julia implementation to documentation 2025-12-04 14:50:48 +01:00
Viliam Kopecký a4538b48e7 feat: toJSON method support for custom serialization (#237)
* feat: add toJSON method support for custom serialization

* fix: prevent infinite recursion

* test: remove redundant toJSON test cases

* docs: add custom serialization details for toJSON method

* test: fix type issues

---------

Co-authored-by: Johann Schopplich <mail@johannschopplich.com>
2025-12-04 14:08:56 +01:00
Okinea Dev 7ed9701028 ci: automate PR title linting (#229)
* ci: automate PR title linting

This will help with Squash & Merge when you need the squashed commit to have the correct Conventional Commits format so that it can be parsed when generating release notes

(With Squash & Merge the squashed commit message is taken from the PR title)

* chore: minor updates

---------

Co-authored-by: Johann Schopplich <mail@johannschopplich.com>
2025-12-01 20:56:56 +01:00
Johann Schopplich d662c21262 docs: move Tools & Playgrounds sidebar link 2025-12-01 20:36:21 +01:00
Johann Schopplich 2c51932d51 feat: add replacer function for encoding transformations and filtering (closes #209) 2025-12-01 17:15:21 +01:00
Johann Schopplich 0974a58527 docs(playground): add key folding option 2025-12-01 10:31:04 +01:00
Johann Schopplich ef02ebe4c8 docs(playground): update layout 2025-11-30 15:26:15 +01:00
Johann Schopplich 236be77c35 docs(playground): update demo datasets 2025-11-30 10:30:10 +01:00
Johann Schopplich 5c6ea544a0 docs(playground): move copy button to code block 2025-11-29 22:27:03 +01:00
Johann Schopplich 81dfab1d4d docs: update links for streaming decode APIs in README 2025-11-29 22:00:45 +01:00
Johann Schopplich b50d9fd16b docs: add official TOON Playground link 2025-11-29 21:54:34 +01:00
Johann Schopplich 22edbc7bf2 docs: use fflate for state compression 2025-11-29 21:52:51 +01:00
Johann Schopplich 0fff9c07bf docs: add playground 2025-11-29 21:16:21 +01:00
Mateo Lafalce 412ebcb125 docs: add Efficiency Formalization page (#221)
* Add Efficiency Formalization

* update eff.md && bach.md

* update eff.md

* update literal pipes

* docs: overhaul some wording

* docs: credit @mateolafalce as author

* chore: deps update

* docs: updates

---------

Co-authored-by: Johann Schopplich <mail@johannschopplich.com>
2025-11-28 07:51:08 +01:00
Johann Schopplich 690e402a6b chore: release v2.0.1
Release + Publish / Release (push) Waiting to run
2025-11-27 22:07:47 +01:00
Okinea Dev 4190d4cc36 docs(readme): use themed badges (#227)
* chore: move readme to `packages/toon/README.md` and symlink in the project root

* fix paths and exclude `packages/toon/README.md` from `eslint` to avoid double linting

* docs(readme): use themed badges

They will now match the logo theme

* fix license path

* chore: revert changes

---------

Co-authored-by: Johann Schopplich <mail@johannschopplich.com>
2025-11-26 11:28:02 +01:00
Okinea Dev e35d8f71aa chore: move readme to packages/toon/README.md and symlink in the… (#228)
* chore: move readme to `packages/toon/README.md` and symlink in the project root

* fix paths and exclude `packages/toon/README.md` from `eslint` to avoid double linting

* fix: use relative paths

* fix link to readme benchmarks section
2025-11-26 11:21:58 +01:00
Johann Schopplich f882cb1153 docs: update spec page to match latest version and sections 2025-11-25 10:05:12 +01:00
Johann Schopplich b9e3593cd9 docs(benchmarks): improve clarity of efficiency ranking metrics 2025-11-25 09:45:06 +01:00
Johann Schopplich faf3f8d8aa docs: update implementation status for Java, Python, and Rust 2025-11-24 21:42:53 +01:00
Johann Schopplich 5310e8ae3d chore: release v2.0.0
Release + Publish / Release (push) Waiting to run
2025-11-24 17:45:42 +01:00
Johann Schopplich 7e6e180732 chore: upgrade deps 2025-11-24 17:36:44 +01:00
Johann Schopplich a07d633f6a docs: move JToon to official implementation 2025-11-24 16:37:21 +01:00
Johann Schopplich 9a6125424c docs: update benchmarks for v3 list item syntax 2025-11-24 16:35:44 +01:00
Johann Schopplich 14a8cb132e test(cli): mock console and stdout write 2025-11-24 16:21:01 +01:00
Johann Schopplich 8f96b8f0c8 refactor: share event handling in AST builder 2025-11-24 15:36:03 +01:00
Johann Schopplich 05abb99a7e feat!: standardized encoding for list-item objects (spec v3) 2025-11-24 14:40:36 +01:00
Johann Schopplich 7e9fbcfd40 docs: compact yaml example in README 2025-11-24 09:46:12 +01:00
Okinea Dev 103e357def ci: organize and speed up workflows (#215)
* ci: organize and speed up workflows

Reduced `ci.yml` costs and carbon emissions by using only one `job`

---

Part of the https://github.com/Cambridge-ICCS/green-ci initiative 🍃

* remove emojis

* chore: update pnpm name and yaml array syntax

---------

Co-authored-by: Johann Schopplich <mail@johannschopplich.com>
2025-11-24 09:42:30 +01:00
Johann Schopplich 11a089bb86 docs: update token count 2025-11-24 09:13:22 +01:00
Johann Schopplich b03997e887 chore: remove unused TS directive 2025-11-24 08:44:41 +01:00
Johann Schopplich 2b2e26b0ef chore: release v1.4.0
Release / release (push) Waiting to run
2025-11-24 08:42:50 +01:00
Johann Schopplich 327bddae55 feat: parse nested tabular arrays in list items with bare hyphen (spec v2.1) 2025-11-24 08:42:30 +01:00
Johann Schopplich 3389179979 docs: overhaul API reference page 2025-11-23 19:47:37 +01:00
Johann Schopplich 5e8822bea8 docs: refactor API reference page 2025-11-23 19:35:19 +01:00
Johann Schopplich 27c6fc5519 docs: simplify stream decode intructions 2025-11-23 19:05:04 +01:00
Johann Schopplich a200a9fa54 docs: add recommended media type & file extension 2025-11-23 18:51:50 +01:00
Johann Schopplich 7a05d03e73 test(cli) add streaming events coverage 2025-11-22 08:53:47 +01:00
Johann Schopplich 8d9b486485 chore: release v1.3.0
Release / release (push) Waiting to run
2025-11-21 22:38:59 +01:00
Johann Schopplich 6c57a14009 feat: streaming decode functionality with event-based parsing (closes #131) 2025-11-21 22:29:57 +01:00
Johann Schopplich 9ebad53ea3 feat(cli): stream output for both encoding and decoding 2025-11-21 16:52:34 +01:00
Johann Schopplich cfbbb09358 chore: release v1.2.0
Release / release (push) Waiting to run
2025-11-21 14:03:40 +01:00
Johann Schopplich 1c003c6118 feat(cli): memory-efficient streaming for encoding 2025-11-21 14:02:22 +01:00
Johann Schopplich be8bcfe9b2 chore: release v1.1.0
Release / release (push) Waiting to run
2025-11-21 09:36:25 +01:00
Omzee 342327290d docs: add tree-sitter grammar and comprehensive editor support (#200)
* docs: add tree-sitter grammar and editor support documentation

- Add comprehensive Editor Support section to README.md
- Document tree-sitter-toon grammar with usage examples
- Include VS Code extension information
- Add Neovim configuration example for tree-sitter
- Update tools-and-playgrounds.md with detailed editor support
- Add Editor Support to table of contents

Resolves #143
Resolves #99

* docs: simplify

---------

Co-authored-by: Johann Schopplich <mail@johannschopplich.com>
2025-11-21 09:22:11 +01:00
Satoshi Azuma a.k.a. ytnobody cf87cfafff docs: add Perl project link to README (#192)
* Add Perl project link to README

* docs: add to implementations

---------

Co-authored-by: Johann Schopplich <mail@johannschopplich.com>
2025-11-21 09:04:30 +01:00
Johann Schopplich 36ddd2e0ea fix: encode mixed-format arrays at root level (fixes #202) 2025-11-21 09:01:26 +01:00
Johann Schopplich 2e992aed74 feat: encodeLines for streaming encoding to TOON 2025-11-20 18:37:46 +01:00
David Pinchen 660ed21e01 docs: add ApexToon implementation (#197)
* Add ApexToon implementation link to README

* Add Apex implementation to implementations documentation
2025-11-20 07:35:00 +01:00
Johann Schopplich 796b333e75 docs: fix benchmark dataset spacing (closes #196) 2025-11-19 22:06:23 +01:00
Johann Schopplich 9968cd2521 chore: fix linting errors 2025-11-19 09:04:00 +01:00
Okinea Dev 59a7a00ebb docs: add documentation for LLMs (llms.txt) (#189)
Thanks to https://github.com/okineadev/vitepress-plugin-llms
2025-11-19 04:44:57 +01:00
Johann Schopplich c91259a05e docs: improve intro sentence 2025-11-19 04:42:35 +01:00
Johann Schopplich 4c32fda628 docs: improve intro sentence 2025-11-18 22:50:54 +01:00
Johann Schopplich 3d2788df53 docs: update favicon 2025-11-18 18:44:40 +01:00
Majid Mohammadian d339d75f53 docs: add Laravel TOON package (#183)
* added Laravel toon package in readme.md

* docs: add Laravel Framework implementation to community section

---------

Co-authored-by: Johann Schopplich <mail@johannschopplich.com>
2025-11-18 14:03:15 +01:00
Johann Schopplich 0ac629a085 docs(website): highlight benchmarks 2025-11-18 10:14:07 +01:00
Johann Schopplich 9bebbb4070 docs(website): use favicon as header icon 2025-11-18 09:01:58 +01:00
Johann Schopplich 4b4f7c05f9 docs: add dedicated docs website 2025-11-18 07:23:10 +01:00
Johann Schopplich 3e08f3b72b docs: overhaul TOON example 2025-11-16 19:30:06 +01:00
Johann Schopplich 4b9e420506 docs: overhaul TOON example 2025-11-16 19:26:50 +01:00
Johann Schopplich bc9147c4c9 docs: overhaul TOON example 2025-11-16 19:12:55 +01:00
Johann Schopplich ccec8a9cc2 docs: overhaul TOON example 2025-11-16 19:06:59 +01:00
Johann Schopplich 56ca5106c9 docs: overhaul TOON example 2025-11-16 19:03:05 +01:00
Johann Schopplich bfdfbd87c6 docs: overhaul example 2025-11-16 19:00:18 +01:00
Johann Schopplich 1a652d239d docs: update intro sentence 2025-11-16 08:49:01 +01:00
Johann Schopplich c789ebe13c docs: merge notes and limitations section with when to not use TOON section 2025-11-15 22:14:18 +01:00
Johann Schopplich 9d260e46ed chore: update OG image 2025-11-15 07:52:12 +01:00
Johann Schopplich 1eb34e29f4 chore: update OG image 2025-11-14 18:48:18 +01:00
Johann Schopplich 32fb153211 docs: refine intro description 2025-11-14 18:26:46 +01:00
Johann Schopplich 9ee27361f6 chore: add npx quick start example to OG image 2025-11-14 17:08:13 +01:00
Johann Schopplich fe82f74a15 docs: highlight official implementations 2025-11-14 17:07:48 +01:00
Johann Schopplich 7f8c2379fd docs: update playground section 2025-11-13 17:10:32 +01:00
Johann Schopplich 6f3df73077 docs: add dedicated playground section (fixes #80 & #32) 2025-11-13 17:07:37 +01:00
Johann Schopplich a7eebaa9b3 docs: fix YAML example formatting in README 2025-11-13 14:54:25 +01:00
woutervancgi dc983b42ab docs: add YAML comparison example to README (#122)
Added YAML example as well as it's more fair to compare to YAML than to JSON.

Note then when objects or arrays contain more nesting TOON might actually become less efficient than YAML.
2025-11-13 14:52:45 +01:00
cy 95ac0b0cbc ci(test): run tests on packages in ci (#111) 2025-11-13 07:12:12 +01:00
Johann Schopplich c73b9753dc docs: dedicated when not to use TOON section 2025-11-11 18:10:40 +01:00
Johann Schopplich 56298d6739 docs: add performance considerations (closes #110) 2025-11-11 17:48:38 +01:00
cy f798bba095 test(cli): add tests for stdin input (#107)
* test(cli): add tests for stdin input

* test(cli): extract mock stdin to helper function

* test(cli): add comprehensive tests for stdin edge cases and output file handling

* refactor(test): streamline mockStdin function and relocate to utils

* test(cli): remove redundant test for JSON encoding from stdin

* test(cli): restore mocks consistently

* test(cli):  restructured output file tests and modified some assertions

* chore: fix linting issues & remove redundant cleanups

---------

Co-authored-by: mad-cat-lon <113548315+mad-cat-lon@users.noreply.github.com>
Co-authored-by: Johann Schopplich <mail@johannschopplich.com>
2025-11-11 17:35:52 +01:00
Johann Schopplich 0a4c89e496 refactor: move function internally 2025-11-11 17:24:18 +01:00
Johann Schopplich 2e7a27c3c0 chore: upgrade tsdown 2025-11-11 09:31:17 +01:00
122 changed files with 16538 additions and 4513 deletions
BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 33 KiB

After

Width:  |  Height:  |  Size: 38 KiB

+27 -32
View File
@@ -9,55 +9,50 @@ on:
- main
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
permissions:
contents: read
jobs:
lint:
runs-on: ubuntu-latest
ci:
runs-on: ubuntu-slim
timeout-minutes: 10
steps:
- uses: actions/checkout@v5
- name: Checkout
uses: actions/checkout@v6
with:
fetch-depth: 1
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v6
with:
node-version: 24
- name: Get pnpm store directory
id: pnpm-cache
run: echo "pnpm_cache_dir=$(pnpm store path)" >> $GITHUB_OUTPUT
- uses: actions/cache@v4
with:
path: ${{ steps.pnpm-cache.outputs.pnpm_cache_dir }}
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
restore-keys: |
${{ runner.os }}-pnpm-store-
- run: pnpm install --frozen-lockfile
- run: pnpm run lint
persist-credentials: false
typecheck:
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@v5
with:
fetch-depth: 1
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v6
- name: Setup pnpm
uses: pnpm/action-setup@v6
- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version: 24
- name: Get pnpm store directory
id: pnpm-cache
run: echo "pnpm_cache_dir=$(pnpm store path)" >> $GITHUB_OUTPUT
- uses: actions/cache@v4
- name: Cache pnpm dependencies
uses: actions/cache@v5
with:
path: ${{ steps.pnpm-cache.outputs.pnpm_cache_dir }}
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
restore-keys: |
${{ runner.os }}-pnpm-store-
- run: pnpm install --frozen-lockfile
- run: pnpm run test:types
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Lint
run: pnpm run lint
- name: Typecheck
run: pnpm run test:types
- name: Test
run: pnpm run test
+58
View File
@@ -0,0 +1,58 @@
name: Deploy Docs
on:
push:
branches:
- main
paths:
- docs/**
- automd.config.ts
- package.json
- eslint.config.mjs
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
permissions: {}
jobs:
deploy:
name: Deploy Docs
runs-on: ubuntu-slim
steps:
- name: Checkout
uses: actions/checkout@v6
with:
persist-credentials: false
- name: Setup pnpm
uses: pnpm/action-setup@v6
- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version: 24
- name: Get pnpm store directory
id: pnpm-cache
run: echo "pnpm_cache_dir=$(pnpm store path)" >> $GITHUB_OUTPUT
- name: Cache pnpm dependencies
uses: actions/cache@v5
with:
path: ${{ steps.pnpm-cache.outputs.pnpm_cache_dir }}
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
restore-keys: |
${{ runner.os }}-pnpm-store-
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Build docs
run: pnpm run docs:build
- name: Deploy to Cloudflare
run: cd docs && npx wrangler deploy
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
+36
View File
@@ -0,0 +1,36 @@
name: Check PR Title
on:
pull_request:
types: [opened, edited]
permissions:
contents: read
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
jobs:
lint-pr-title:
name: Lint PR title
runs-on: ubuntu-slim
if: ${{ (github.event.action == 'opened' || github.event.changes.title != null) && github.actor != 'renovate[bot]' }}
steps:
- name: Checkout
uses: actions/checkout@v6
with:
persist-credentials: false
# Only fetch the config file from the repository
sparse-checkout-cone-mode: false
sparse-checkout: commitlint.config.ts
- name: Install dependencies
run: npm install -D @commitlint/cli @commitlint/config-conventional
- name: Validate PR title with commitlint
run: echo "$PR_TITLE" | npx commitlint
env:
PR_TITLE: ${{ github.event.pull_request.title }}
+27 -18
View File
@@ -1,43 +1,52 @@
name: Release
name: Release + Publish
on:
push:
tags:
- 'v*'
permissions:
id-token: write
contents: write
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
release:
runs-on: ubuntu-latest
name: Release
runs-on: ubuntu-slim
permissions:
id-token: write
contents: write
steps:
- uses: actions/checkout@v5
- name: Checkout
uses: actions/checkout@v6
with:
fetch-depth: 0
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v6
fetch-depth: 0 # Required for fetching tags and generating release notes
persist-credentials: true
- name: Setup pnpm
uses: pnpm/action-setup@v6
- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version: 24
registry-url: https://registry.npmjs.org/
cache: pnpm
- name: Publish changelog
- name: Generate changelog and create GitHub release
run: npx changelogithub
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- run: pnpm install
- run: pnpm run build
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Copy READMEs for npm packages
run: |
cp README.md packages/toon/README.md
echo "✓ Copied root README to @toon-format/toon"
echo "✓ CLI package uses its own README"
- name: Build packages
run: pnpm run build
- name: Publish to npm
- name: Publish packages to npm
run: npm install -g npm@latest && pnpm -r publish --access public --no-git-checks
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
+2
View File
@@ -2,5 +2,7 @@ dist
node_modules
.DS_Store
.env
docs/.vitepress/dist
docs/.vitepress/cache
packages/toon/test/fixtures/*.json
packages/toon/test/fixtures/*.toon
-1
View File
@@ -1 +0,0 @@
shamefully-hoist=true
+12 -4
View File
@@ -1,7 +1,4 @@
{
// Enable the ESLint flat config support
"eslint.useFlatConfig": true,
// Disable the default formatter, use ESLint instead
"prettier.enable": false,
"editor.formatOnSave": false,
@@ -37,6 +34,17 @@
"markdown",
"json",
"jsonc",
"yaml"
"yaml",
"toml",
"xml",
"gql",
"graphql",
"astro",
"svelte",
"css",
"less",
"scss",
"pcss",
"postcss"
]
}
-1270
View File
File diff suppressed because it is too large Load Diff
Symlink
+1
View File
@@ -0,0 +1 @@
packages/toon/README.md
+1 -1
View File
@@ -4,7 +4,7 @@ The TOON specification has moved to a dedicated repository: [github.com/toon-for
## Current Version
**Version 2.0** (2025-11-10)
**Version 3.3** (2026-05-21)
## Quick Links
+7
View File
@@ -0,0 +1,7 @@
import type { Config } from 'automd'
const config: Config = {
input: ['docs/guide/benchmarks.md'],
}
export default config
+4 -4
View File
@@ -3,7 +3,7 @@
Benchmarks measuring TOON's **token efficiency** and **retrieval accuracy** compared to JSON, XML, YAML, and CSV.
> [!NOTE]
> Results are automatically embedded in the [main README](../README.md#benchmarks). This guide focuses on running the benchmarks locally.
> Results are automatically embedded in the [main README](https://github.com/toon-format/toon/#benchmarks). This guide focuses on running the benchmarks locally.
## Quick Start
@@ -44,11 +44,11 @@ Tests how well LLMs can answer questions about data in different formats (TOON,
1. Edit [`src/evaluate.ts`](./src/evaluate.ts) and add models to the exported `models` array:
```ts
export const models: LanguageModelV2[] = [
export const models: LanguageModelV3[] = [
openai('gpt-5-nano'),
anthropic('claude-haiku-4-5-20251001'),
google('gemini-2.5-flash'),
xai('grok-4-fast-non-reasoning'),
google('gemini-3-flash-preview'),
xai('grok-4-1-fast-non-reasoning'),
// Add your models here
]
```
+18 -18
View File
@@ -3,26 +3,26 @@
"type": "module",
"private": true,
"scripts": {
"benchmark:tokens": "tsx scripts/token-efficiency-benchmark.ts",
"benchmark:accuracy": "tsx --env-file=.env scripts/accuracy-benchmark.ts",
"fetch:github-repos": "tsx scripts/fetch-github-repos.ts"
"benchmark:tokens": "node scripts/token-efficiency-benchmark.ts",
"benchmark:accuracy": "node --env-file=.env scripts/accuracy-benchmark.ts",
"fetch:github-repos": "node scripts/fetch-github-repos.ts"
},
"devDependencies": {
"@ai-sdk/anthropic": "^2.0.40",
"@ai-sdk/google": "^2.0.26",
"@ai-sdk/openai": "^2.0.59",
"@ai-sdk/provider": "^2.0.0",
"@ai-sdk/xai": "^2.0.30",
"@clack/prompts": "^0.11.0",
"@faker-js/faker": "^10.1.0",
"ai": "^5.0.86",
"csv-stringify": "^6.6.0",
"fast-xml-parser": "^5.3.0",
"gpt-tokenizer": "^3.2.0",
"@ai-sdk/anthropic": "^3.0.97",
"@ai-sdk/google": "^3.0.94",
"@ai-sdk/openai": "^3.0.85",
"@ai-sdk/provider": "^3.0.14",
"@ai-sdk/xai": "^3.0.108",
"@clack/prompts": "^1.7.0",
"@faker-js/faker": "^10.5.0",
"ai": "^6.0.228",
"csv-stringify": "^6.8.1",
"fast-xml-parser": "^5.10.0",
"gpt-tokenizer": "^3.4.0",
"ofetch": "^1.5.1",
"p-map": "^7.0.3",
"p-queue": "^9.0.0",
"unstorage": "^1.17.2",
"yaml": "^2.8.1"
"p-map": "^7.0.5",
"p-queue": "^9.3.1",
"unstorage": "^1.17.5",
"yaml": "^2.9.0"
}
}
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+117 -116
View File
@@ -33,17 +33,20 @@ Benchmarks test LLM comprehension across different input formats using 209 data
#### Efficiency Ranking (Accuracy per 1K Tokens)
Each format's overall performance, balancing accuracy against token cost:
Each format ranked by efficiency (accuracy percentage per 1,000 tokens):
```
TOON ████████████████████ 26.9 │ 73.9% acc │ 2,744 tokens
JSON compact █████████████████░░░ 22.9 │ 70.7% acc │ 3,081 tokens
YAML ██████████████░░░░░░ 18.669.0% acc │ 3,719 tokens
JSON ███████████░░░░░░░░ 15.369.7% acc │ 4,545 tokens
XML ██████████░░░░░░░░░░ 13.067.1% acc │ 5,167 tokens
TOON ████████████████████ 27.7 acc%/1K tok │ 76.4% acc │ 2,759 tokens
JSON compact █████████████████░░░ 23.7 acc%/1K tok │ 73.7% acc │ 3,104 tokens
YAML ██████████████░░░░░░ 19.9 acc%/1K tok74.5% acc │ 3,749 tokens
JSON ███████████░░░░░░░░ 16.4 acc%/1K tok75.0% acc │ 4,587 tokens
XML ██████████░░░░░░░░░░ 13.8 acc%/1K tok │ 72.1% acc │ 5,221 tokens
```
TOON achieves **73.9%** accuracy (vs JSON's 69.7%) while using **39.6% fewer tokens**.
*Efficiency score = (Accuracy % ÷ Tokens) × 1,000. Higher is better.*
> [!TIP]
> TOON achieves **76.4%** accuracy (vs JSON's 75.0%) while using **39.9% fewer tokens**.
**Note on CSV:** Excluded from ranking as it only supports 109 of 209 questions (flat tabular data only). While CSV is highly token-efficient for simple tabular data, it cannot represent nested structures that other formats handle.
@@ -60,13 +63,13 @@ claude-haiku-4-5-20251001
JSON compact ███████████░░░░░░░░░ 55.0% (115/209)
CSV ██████████░░░░░░░░░░ 50.5% (55/109)
gemini-2.5-flash
→ TOON ██████████████████░░ 87.6% (183/209)
CSV █████████████████░░86.2% (94/109)
JSON compact ████████████████░░░░ 82.3% (172/209)
YAML ████████████████░░░79.4% (166/209)
XML ████████████████░░░79.4% (166/209)
JSON ███████████████░░░░77.0% (161/209)
gemini-3-flash-preview
XML ████████████████████ 98.1% (205/209)
JSON ███████████████████97.1% (203/209)
YAML ███████████████████░ 97.1% (203/209)
→ TOON ███████████████████96.7% (202/209)
JSON compact ███████████████████96.7% (202/209)
CSV ███████████████████96.3% (105/109)
gpt-5-nano
→ TOON ██████████████████░░ 90.9% (190/209)
@@ -76,29 +79,30 @@ gpt-5-nano
YAML █████████████████░░░ 87.1% (182/209)
XML ████████████████░░░░ 80.9% (169/209)
grok-4-fast-non-reasoning
→ TOON ███████████░░░░░░░░ 57.4% (120/209)
JSON ███████████░░░░░░░░ 55.5% (116/209)
JSON compact ███████████░░░░░░░░░ 54.5% (114/209)
YAML ███████████░░░░░░░░░ 53.6% (112/209)
XML ██████████░░░░░░░░░ 52.6% (110/209)
CSV ██████████░░░░░░░░░░ 52.3% (57/109)
grok-4-1-fast-non-reasoning
→ TOON ███████████░░░░░░░░ 58.4% (122/209)
YAML ███████████░░░░░░░░ 57.9% (121/209)
JSON ███████████░░░░░░░░░ 56.5% (118/209)
XML ███████████░░░░░░░░░ 54.1% (113/209)
JSON compact ██████████░░░░░░░░░ 52.2% (109/209)
CSV ██████████░░░░░░░░░░ 51.4% (56/109)
```
**Key tradeoff:** TOON achieves **73.9% accuracy** (vs JSON's 69.7%) while using **39.6% fewer tokens** on these datasets.
> [!TIP]
> TOON achieves **76.4% accuracy** (vs JSON's 75.0%) while using **39.9% fewer tokens** on these datasets.
<details>
<summary><strong>Performance by dataset, model, and question type</strong></summary>
#### Performance by Question Type
| Question Type | TOON | JSON compact | JSON | CSV | YAML | XML |
| Question Type | TOON | JSON | YAML | JSON compact | XML | CSV |
| ------------- | ---- | ---- | ---- | ---- | ---- | ---- |
| Field Retrieval | 99.6% | 99.3% | 99.3% | 100.0% | 98.2% | 98.9% |
| Aggregation | 54.4% | 47.2% | 48.8% | 44.0% | 47.6% | 41.3% |
| Filtering | 56.3% | 57.3% | 50.5% | 49.1% | 51.0% | 47.9% |
| Structure Awareness | 88.0% | 83.0% | 83.0% | 85.9% | 80.0% | 80.0% |
| Structural Validation | 70.0% | 45.0% | 50.0% | 80.0% | 60.0% | 80.0% |
| Field Retrieval | 99.6% | 99.3% | 98.5% | 98.5% | 98.9% | 100.0% |
| Aggregation | 61.9% | 61.9% | 59.9% | 58.3% | 54.4% | 50.9% |
| Filtering | 56.8% | 53.1% | 56.3% | 55.2% | 51.6% | 50.9% |
| Structure Awareness | 89.0% | 87.0% | 84.0% | 84.0% | 81.0% | 85.9% |
| Structural Validation | 70.0% | 60.0% | 60.0% | 55.0% | 85.0% | 80.0% |
#### Performance by Dataset
@@ -106,119 +110,119 @@ grok-4-fast-non-reasoning
| Format | Accuracy | Tokens | Correct/Total |
| ------ | -------- | ------ | ------------- |
| `csv` | 72.0% | 2,352 | 118/164 |
| `toon` | 73.8% | 2,518 | 121/164 |
| `json-compact` | 69.5% | 3,953 | 114/164 |
| `yaml` | 68.3% | 4,982 | 112/164 |
| `json-pretty` | 68.3% | 6,360 | 112/164 |
| `xml` | 69.5% | 7,324 | 114/164 |
| `csv` | 73.2% | 2,334 | 120/164 |
| `toon` | 73.2% | 2,498 | 120/164 |
| `json-compact` | 73.8% | 3,924 | 121/164 |
| `yaml` | 73.8% | 4,959 | 121/164 |
| `json-pretty` | 73.8% | 6,331 | 121/164 |
| `xml` | 74.4% | 7,296 | 122/164 |
##### E-commerce orders with nested structures
| Format | Accuracy | Tokens | Correct/Total |
| ------ | -------- | ------ | ------------- |
| `toon` | 81.1% | 7,232 | 133/164 |
| `json-compact` | 76.8% | 6,794 | 126/164 |
| `yaml` | 75.6% | 8,347 | 124/164 |
| `json-pretty` | 76.2% | 10,713 | 125/164 |
| `xml` | 74.4% | 12,023 | 122/164 |
| `toon` | 82.3% | 7,458 | 135/164 |
| `json-compact` | 78.7% | 7,110 | 129/164 |
| `yaml` | 79.9% | 8,755 | 131/164 |
| `json-pretty` | 79.3% | 11,234 | 130/164 |
| `xml` | 77.4% | 12,649 | 127/164 |
##### Time-series analytics data
| Format | Accuracy | Tokens | Correct/Total |
| ------ | -------- | ------ | ------------- |
| `csv` | 73.3% | 1,406 | 88/120 |
| `toon` | 72.5% | 1,548 | 87/120 |
| `json-compact` | 71.7% | 2,349 | 86/120 |
| `yaml` | 71.7% | 2,949 | 86/120 |
| `json-pretty` | 68.3% | 3,676 | 82/120 |
| `xml` | 68.3% | 4,384 | 82/120 |
| `csv` | 75.0% | 1,411 | 90/120 |
| `toon` | 78.3% | 1,553 | 94/120 |
| `json-compact` | 74.2% | 2,354 | 89/120 |
| `yaml` | 75.8% | 2,954 | 91/120 |
| `json-pretty` | 75.0% | 3,681 | 90/120 |
| `xml` | 72.5% | 4,389 | 87/120 |
##### Top 100 GitHub repositories
| Format | Accuracy | Tokens | Correct/Total |
| ------ | -------- | ------ | ------------- |
| `toon` | 62.9% | 8,780 | 83/132 |
| `csv` | 61.4% | 8,528 | 81/132 |
| `yaml` | 59.8% | 13,142 | 79/132 |
| `json-compact` | 55.3% | 11,465 | 73/132 |
| `json-pretty` | 56.1% | 15,158 | 74/132 |
| `xml` | 48.5% | 17,105 | 64/132 |
| `csv` | 65.9% | 8,527 | 87/132 |
| `toon` | 66.7% | 8,779 | 88/132 |
| `yaml` | 65.2% | 13,141 | 86/132 |
| `json-compact` | 59.8% | 11,464 | 79/132 |
| `json-pretty` | 63.6% | 15,157 | 84/132 |
| `xml` | 56.1% | 17,105 | 74/132 |
##### Semi-uniform event logs
| Format | Accuracy | Tokens | Correct/Total |
| ------ | -------- | ------ | ------------- |
| `json-compact` | 63.3% | 4,819 | 76/120 |
| `toon` | 57.5% | 5,799 | 69/120 |
| `json-pretty` | 59.2% | 6,797 | 71/120 |
| `yaml` | 48.3% | 5,827 | 58/120 |
| `xml` | 46.7% | 7,709 | 56/120 |
| `json-compact` | 68.3% | 4,839 | 82/120 |
| `toon` | 65.0% | 5,819 | 78/120 |
| `json-pretty` | 69.2% | 6,817 | 83/120 |
| `yaml` | 61.7% | 5,847 | 74/120 |
| `xml` | 58.3% | 7,729 | 70/120 |
##### Deeply nested configuration
| Format | Accuracy | Tokens | Correct/Total |
| ------ | -------- | ------ | ------------- |
| `json-compact` | 92.2% | 574 | 107/116 |
| `toon` | 95.7% | 666 | 111/116 |
| `yaml` | 91.4% | 686 | 106/116 |
| `json-pretty` | 94.0% | 932 | 109/116 |
| `xml` | 92.2% | 1,018 | 107/116 |
| `json-compact` | 90.5% | 568 | 105/116 |
| `toon` | 94.8% | 655 | 110/116 |
| `yaml` | 93.1% | 675 | 108/116 |
| `json-pretty` | 92.2% | 924 | 107/116 |
| `xml` | 91.4% | 1,013 | 106/116 |
##### Valid complete dataset (control)
| Format | Accuracy | Tokens | Correct/Total |
| ------ | -------- | ------ | ------------- |
| `toon` | 100.0% | 544 | 4/4 |
| `json-compact` | 100.0% | 795 | 4/4 |
| `yaml` | 100.0% | 1,003 | 4/4 |
| `json-pretty` | 100.0% | 1,282 | 4/4 |
| `csv` | 25.0% | 492 | 1/4 |
| `xml` | 0.0% | 1,467 | 0/4 |
| `toon` | 100.0% | 535 | 4/4 |
| `json-compact` | 100.0% | 787 | 4/4 |
| `yaml` | 100.0% | 992 | 4/4 |
| `json-pretty` | 100.0% | 1,274 | 4/4 |
| `xml` | 25.0% | 1,462 | 1/4 |
| `csv` | 0.0% | 483 | 0/4 |
##### Array truncated: 3 rows removed from end
| Format | Accuracy | Tokens | Correct/Total |
| ------ | -------- | ------ | ------------- |
| `csv` | 100.0% | 425 | 4/4 |
| `xml` | 100.0% | 1,251 | 4/4 |
| `toon` | 0.0% | 474 | 0/4 |
| `json-compact` | 0.0% | 681 | 0/4 |
| `json-pretty` | 0.0% | 1,096 | 0/4 |
| `yaml` | 0.0% | 859 | 0/4 |
| `csv` | 100.0% | 413 | 4/4 |
| `xml` | 100.0% | 1,243 | 4/4 |
| `toon` | 0.0% | 462 | 0/4 |
| `json-pretty` | 0.0% | 1,085 | 0/4 |
| `yaml` | 0.0% | 843 | 0/4 |
| `json-compact` | 0.0% | 670 | 0/4 |
##### Extra rows added beyond declared length
| Format | Accuracy | Tokens | Correct/Total |
| ------ | -------- | ------ | ------------- |
| `csv` | 100.0% | 566 | 4/4 |
| `toon` | 75.0% | 621 | 3/4 |
| `xml` | 100.0% | 1,692 | 4/4 |
| `yaml` | 75.0% | 1,157 | 3/4 |
| `json-compact` | 50.0% | 917 | 2/4 |
| `json-pretty` | 50.0% | 1,476 | 2/4 |
| `csv` | 100.0% | 550 | 4/4 |
| `toon` | 75.0% | 605 | 3/4 |
| `json-compact` | 75.0% | 901 | 3/4 |
| `xml` | 100.0% | 1,678 | 4/4 |
| `yaml` | 75.0% | 1,138 | 3/4 |
| `json-pretty` | 50.0% | 1,460 | 2/4 |
##### Inconsistent field count (missing salary in row 10)
| Format | Accuracy | Tokens | Correct/Total |
| ------ | -------- | ------ | ------------- |
| `csv` | 75.0% | 489 | 3/4 |
| `yaml` | 100.0% | 996 | 4/4 |
| `toon` | 100.0% | 1,019 | 4/4 |
| `json-compact` | 75.0% | 790 | 3/4 |
| `xml` | 100.0% | 1,458 | 4/4 |
| `json-pretty` | 75.0% | 1,274 | 3/4 |
| `csv` | 100.0% | 480 | 4/4 |
| `json-compact` | 100.0% | 782 | 4/4 |
| `yaml` | 100.0% | 985 | 4/4 |
| `toon` | 100.0% | 1,008 | 4/4 |
| `json-pretty` | 100.0% | 1,266 | 4/4 |
| `xml` | 100.0% | 1,453 | 4/4 |
##### Missing required fields (no email in multiple rows)
| Format | Accuracy | Tokens | Correct/Total |
| ------ | -------- | ------ | ------------- |
| `csv` | 100.0% | 329 | 4/4 |
| `xml` | 100.0% | 1,411 | 4/4 |
| `toon` | 75.0% | 983 | 3/4 |
| `yaml` | 25.0% | 960 | 1/4 |
| `json-pretty` | 25.0% | 1,230 | 1/4 |
| `json-compact` | 0.0% | 755 | 0/4 |
| `csv` | 100.0% | 340 | 4/4 |
| `xml` | 100.0% | 1,409 | 4/4 |
| `toon` | 75.0% | 974 | 3/4 |
| `json-pretty` | 50.0% | 1,225 | 2/4 |
| `yaml` | 25.0% | 951 | 1/4 |
| `json-compact` | 0.0% | 750 | 0/4 |
#### Performance by Model
@@ -233,16 +237,16 @@ grok-4-fast-non-reasoning
| `json-compact` | 55.0% | 115/209 |
| `csv` | 50.5% | 55/109 |
##### gemini-2.5-flash
##### gemini-3-flash-preview
| Format | Accuracy | Correct/Total |
| ------ | -------- | ------------- |
| `toon` | 87.6% | 183/209 |
| `csv` | 86.2% | 94/109 |
| `json-compact` | 82.3% | 172/209 |
| `yaml` | 79.4% | 166/209 |
| `xml` | 79.4% | 166/209 |
| `json-pretty` | 77.0% | 161/209 |
| `xml` | 98.1% | 205/209 |
| `json-pretty` | 97.1% | 203/209 |
| `yaml` | 97.1% | 203/209 |
| `toon` | 96.7% | 202/209 |
| `json-compact` | 96.7% | 202/209 |
| `csv` | 96.3% | 105/109 |
##### gpt-5-nano
@@ -255,31 +259,29 @@ grok-4-fast-non-reasoning
| `yaml` | 87.1% | 182/209 |
| `xml` | 80.9% | 169/209 |
##### grok-4-fast-non-reasoning
##### grok-4-1-fast-non-reasoning
| Format | Accuracy | Correct/Total |
| ------ | -------- | ------------- |
| `toon` | 57.4% | 120/209 |
| `json-pretty` | 55.5% | 116/209 |
| `json-compact` | 54.5% | 114/209 |
| `yaml` | 53.6% | 112/209 |
| `xml` | 52.6% | 110/209 |
| `csv` | 52.3% | 57/109 |
| `toon` | 58.4% | 122/209 |
| `yaml` | 57.9% | 121/209 |
| `json-pretty` | 56.5% | 118/209 |
| `xml` | 54.1% | 113/209 |
| `json-compact` | 52.2% | 109/209 |
| `csv` | 51.4% | 56/109 |
</details>
<details>
<summary><strong>How the benchmark works</strong></summary>
#### What's Being Measured
This benchmark tests **LLM comprehension and data retrieval accuracy** across different input formats. Each LLM receives formatted data and must answer questions about it (this does **not** test model's ability to generate TOON output).
This benchmark tests **LLM comprehension and data retrieval accuracy** across different input formats. Each LLM receives formatted data and must answer questions about it. This does **not** test the model's ability to generate TOON output only to read and understand it.
#### Datasets Tested
Eleven datasets designed to test different structural patterns and validation capabilities:
**Primary datasets:**
1. **Tabular** (100 employee records): Uniform objects with identical fields optimal for TOON's tabular format.
2. **Nested** (50 e-commerce orders): Complex structures with nested customer objects and item arrays.
3. **Analytics** (60 days of metrics): Time-series data with dates and numeric values.
@@ -288,8 +290,9 @@ Eleven datasets designed to test different structural patterns and validation ca
6. **Nested Config** (1 configuration): Deeply nested configuration with minimal tabular eligibility.
**Structural validation datasets:**
7. **Control**: Valid complete dataset (baseline for validation)
8. **Truncated**: Array with 3 rows removed from end (tests [N] length detection)
8. **Truncated**: Array with 3 rows removed from end (tests `[N]` length detection)
9. **Extra rows**: Array with 3 additional rows beyond declared length
10. **Width mismatch**: Inconsistent field count (missing salary in row 10)
11. **Missing fields**: Systematic field omissions (no email in multiple rows)
@@ -312,27 +315,25 @@ Eleven datasets designed to test different structural patterns and validation ca
- Example: "How many employees in Sales have salary > 80000?" → `5`
- Example: "How many active employees have more than 10 years of experience?" → `8`
- **Structure awareness (12%)**: Tests format-native structural affordances (TOON's [N] count and {fields}, CSV's header row)
- **Structure awareness (12%)**: Tests format-native structural affordances (TOON's `[N]` count and `{fields}`, CSV's header row)
- Example: "How many employees are in the dataset?" → `100`
- Example: "List the field names for employees" → `id, name, email, department, salary, yearsExperience, active`
- Example: "What is the department of the last employee?" → `Sales`
- **Structural validation (2%)**: Tests ability to detect incomplete, truncated, or corrupted data using structural metadata
- Example: "Is this data complete and valid?" → `YES` (control dataset) or `NO` (corrupted datasets)
- Tests TOON's [N] length validation and {fields} consistency checking
- Tests TOON's `[N]` length validation and `{fields}` consistency checking
- Demonstrates CSV's lack of structural validation capabilities
#### Evaluation Process
1. **Format conversion**: Each dataset is converted to all 6 formats (TOON, JSON compact, JSON, CSV, YAML, XML).
1. **Format conversion**: Each dataset is converted to all 6 formats (TOON, JSON, YAML, JSON compact, XML, CSV).
2. **Query LLM**: Each model receives formatted data + question in a prompt and extracts the answer.
3. **Validate deterministically**: Answers are validated using type-aware comparison (e.g., `50000` = `$50,000`, `Engineering` = `engineering`, `2025-01-01` = `January 1, 2025`) without requiring an LLM judge.
#### Models & Configuration
- **Models tested**: `claude-haiku-4-5-20251001`, `gemini-2.5-flash`, `gpt-5-nano`, `grok-4-fast-non-reasoning`
- **Models tested**: `claude-haiku-4-5-20251001`, `gemini-3-flash-preview`, `gpt-5-nano`, `grok-4-1-fast-non-reasoning`
- **Token counting**: Using `gpt-tokenizer` with `o200k_base` encoding (GPT-5 tokenizer)
- **Temperature**: Not set (models use their defaults)
- **Total evaluations**: 209 questions × 6 formats × 4 models = 5,016 LLM calls
</details>
+78 -78
View File
@@ -5,34 +5,34 @@ Datasets with nested or semi-uniform structures. CSV excluded as it cannot prope
```
🛒 E-commerce orders with nested structures ┊ Tabular: 33%
TOON █████████████░░░░░░░ 72,771 tokens
├─ vs JSON (33.1%) 108,806 tokens
├─ vs JSON compact (+5.5%) 68,975 tokens
├─ vs YAML (14.2%) 84,780 tokens
└─ vs XML (40.5%) 122,406 tokens
TOON █████████████░░░░░░░ 73,126 tokens
├─ vs JSON (33.3%) 109,599 tokens
├─ vs JSON compact (+5.3%) 69,459 tokens
├─ vs YAML (14.4%) 85,415 tokens
└─ vs XML (40.7%) 123,344 tokens
🧾 Semi-uniform event logs ┊ Tabular: 50%
TOON █████████████████░░░ 153,211 tokens
├─ vs JSON (15.0%) 180,176 tokens
├─ vs JSON compact (+19.9%) 127,731 tokens
├─ vs YAML (0.8%) 154,505 tokens
└─ vs XML (25.2%) 204,777 tokens
TOON █████████████████░░░ 154,084 tokens
├─ vs JSON (15.0%) 181,201 tokens
├─ vs JSON compact (+19.9%) 128,529 tokens
├─ vs YAML (0.8%) 155,397 tokens
└─ vs XML (25.2%) 205,859 tokens
🧩 Deeply nested configuration ┊ Tabular: 0%
TOON ██████████████░░░░░░ 631 tokens
├─ vs JSON (31.3%) 919 tokens
├─ vs JSON compact (+11.9%) 564 tokens
├─ vs YAML (6.2%) 673 tokens
└─ vs XML (37.4%) 1,008 tokens
TOON ██████████████░░░░░░ 620 tokens
├─ vs JSON (31.9%) 911 tokens
├─ vs JSON compact (+11.1%) 558 tokens
├─ vs YAML (6.3%) 662 tokens
└─ vs XML (38.2%) 1,003 tokens
──────────────────────────────────── Total ────────────────────────────────────
TOON ████████████████░░░░ 226,613 tokens
├─ vs JSON (21.8%) 289,901 tokens
├─ vs JSON compact (+14.9%) 197,270 tokens
├─ vs YAML (5.6%) 239,958 tokens
└─ vs XML (31.0%) 328,191 tokens
TOON ████████████████░░░░ 227,830 tokens
├─ vs JSON (21.9%) 291,711 tokens
├─ vs JSON compact (+14.7%) 198,546 tokens
├─ vs YAML (5.7%) 241,474 tokens
└─ vs XML (31.0%) 330,206 tokens
```
#### Flat-Only Track
@@ -42,38 +42,38 @@ Datasets with flat tabular structures where CSV is applicable.
```
👥 Uniform employee records ┊ Tabular: 100%
CSV ███████████████████░ 46,954 tokens
TOON ████████████████████ 49,831 tokens (+6.1% vs CSV)
├─ vs JSON (60.7%) 126,860 tokens
├─ vs JSON compact (36.8%) 78,856 tokens
├─ vs YAML (50.0%) 99,706 tokens
└─ vs XML (66.0%) 146,444 tokens
CSV ███████████████████░ 47,102 tokens
TOON ████████████████████ 49,919 tokens (+6.0% vs CSV)
├─ vs JSON (60.7%) 127,063 tokens
├─ vs JSON compact (36.9%) 79,059 tokens
├─ vs YAML (50.1%) 100,011 tokens
└─ vs XML (65.9%) 146,579 tokens
📈 Time-series analytics data ┊ Tabular: 100%
CSV ██████████████████░░ 8,388 tokens
TOON ████████████████████ 9,120 tokens (+8.7% vs CSV)
├─ vs JSON (59.0%) 22,250 tokens
├─ vs JSON compact (35.8%) 14,216 tokens
├─ vs YAML (48.9%) 17,863 tokens
└─ vs XML (65.7%) 26,621 tokens
CSV ██████████████████░░ 8,383 tokens
TOON ████████████████████ 9,115 tokens (+8.7% vs CSV)
├─ vs JSON (59.0%) 22,245 tokens
├─ vs JSON compact (35.9%) 14,211 tokens
├─ vs YAML (49.0%) 17,858 tokens
└─ vs XML (65.8%) 26,616 tokens
⭐ Top 100 GitHub repositories ┊ Tabular: 100%
CSV ███████████████████░ 8,513 tokens
TOON ████████████████████ 8,745 tokens (+2.7% vs CSV)
├─ vs JSON (42.3%) 15,145 tokens
├─ vs JSON compact (23.7%) 11,455 tokens
├─ vs YAML (33.4%) 13,129 tokens
└─ vs XML (48.8%) 17,095 tokens
CSV ███████████████████░ 8,512 tokens
TOON ████████████████████ 8,744 tokens (+2.7% vs CSV)
├─ vs JSON (42.3%) 15,144 tokens
├─ vs JSON compact (23.7%) 11,454 tokens
├─ vs YAML (33.4%) 13,128 tokens
└─ vs XML (48.9%) 17,095 tokens
──────────────────────────────────── Total ────────────────────────────────────
CSV ███████████████████░ 63,855 tokens
TOON ████████████████████ 67,696 tokens (+6.0% vs CSV)
├─ vs JSON (58.8%) 164,255 tokens
├─ vs JSON compact (35.2%) 104,527 tokens
├─ vs YAML (48.2%) 130,698 tokens
└─ vs XML (64.4%) 190,160 tokens
CSV ███████████████████░ 63,997 tokens
TOON ████████████████████ 67,778 tokens (+5.9% vs CSV)
├─ vs JSON (58.8%) 164,452 tokens
├─ vs JSON compact (35.3%) 104,724 tokens
├─ vs YAML (48.3%) 130,997 tokens
└─ vs XML (64.4%) 190,290 tokens
```
<details>
@@ -83,64 +83,64 @@ Datasets with flat tabular structures where CSV is applicable.
**Savings:** 13,130 tokens (59.0% reduction vs JSON)
**JSON** (22,250 tokens):
**JSON** (22,245 tokens):
```json
{
"metrics": [
{
"date": "2025-01-01",
"views": 5715,
"clicks": 211,
"conversions": 28,
"revenue": 7976.46,
"bounceRate": 0.47
"views": 6138,
"clicks": 174,
"conversions": 12,
"revenue": 2712.49,
"bounceRate": 0.35
},
{
"date": "2025-01-02",
"views": 7103,
"clicks": 393,
"conversions": 28,
"revenue": 8360.53,
"bounceRate": 0.32
"views": 4616,
"clicks": 274,
"conversions": 34,
"revenue": 9156.29,
"bounceRate": 0.56
},
{
"date": "2025-01-03",
"views": 7248,
"clicks": 378,
"conversions": 24,
"revenue": 3212.57,
"bounceRate": 0.5
"views": 4460,
"clicks": 143,
"conversions": 8,
"revenue": 1317.98,
"bounceRate": 0.59
},
{
"date": "2025-01-04",
"views": 2927,
"clicks": 77,
"conversions": 11,
"revenue": 1211.69,
"bounceRate": 0.62
"views": 4740,
"clicks": 125,
"conversions": 13,
"revenue": 2934.77,
"bounceRate": 0.37
},
{
"date": "2025-01-05",
"views": 3530,
"clicks": 82,
"conversions": 8,
"revenue": 462.77,
"bounceRate": 0.56
"views": 6428,
"clicks": 369,
"conversions": 19,
"revenue": 1317.24,
"bounceRate": 0.3
}
]
}
```
**TOON** (9,120 tokens):
**TOON** (9,115 tokens):
```
metrics[5]{date,views,clicks,conversions,revenue,bounceRate}:
2025-01-01,5715,211,28,7976.46,0.47
2025-01-02,7103,393,28,8360.53,0.32
2025-01-03,7248,378,24,3212.57,0.5
2025-01-04,2927,77,11,1211.69,0.62
2025-01-05,3530,82,8,462.77,0.56
2025-01-01,6138,174,12,2712.49,0.35
2025-01-02,4616,274,34,9156.29,0.56
2025-01-03,4460,143,8,1317.98,0.59
2025-01-04,4740,125,13,2934.77,0.37
2025-01-05,6428,369,19,1317.24,0.3
```
---
@@ -149,7 +149,7 @@ metrics[5]{date,views,clicks,conversions,revenue,bounceRate}:
**Savings:** 6,400 tokens (42.3% reduction vs JSON)
**JSON** (15,145 tokens):
**JSON** (15,144 tokens):
```json
{
@@ -197,7 +197,7 @@ metrics[5]{date,views,clicks,conversions,revenue,bounceRate}:
}
```
**TOON** (8,745 tokens):
**TOON** (8,744 tokens):
```
repositories[3]{id,name,repo,description,createdAt,updatedAt,pushedAt,stars,watchers,forks,defaultBranch}:
+9 -9
View File
@@ -1,17 +1,17 @@
import type { Question } from '../src/types'
import type { Question } from '../src/types.ts'
import * as fsp from 'node:fs/promises'
import * as path from 'node:path'
import process from 'node:process'
import * as prompts from '@clack/prompts'
import PQueue from 'p-queue'
import { BENCHMARKS_DIR, DEFAULT_CONCURRENCY, DRY_RUN, DRY_RUN_LIMITS, MODEL_RPM_LIMITS, ROOT_DIR } from '../src/constants'
import { ACCURACY_DATASETS } from '../src/datasets'
import { evaluateQuestion, models } from '../src/evaluate'
import { formatters, supportsCSV } from '../src/formatters'
import { generateQuestions } from '../src/questions'
import { calculateFormatResults, calculateTokenCounts, generateAccuracyReport } from '../src/report'
import { getAllModelResults, hasModelResults, saveModelResults } from '../src/storage'
import { ensureDir } from '../src/utils'
import { BENCHMARKS_DIR, DEFAULT_CONCURRENCY, DRY_RUN, DRY_RUN_LIMITS, MODEL_RPM_LIMITS, ROOT_DIR } from '../src/constants.ts'
import { ACCURACY_DATASETS } from '../src/datasets.ts'
import { evaluateQuestion, models } from '../src/evaluate.ts'
import { formatters, supportsCSV } from '../src/formatters.ts'
import { generateQuestions } from '../src/questions/index.ts'
import { calculateFormatResults, calculateTokenCounts, generateAccuracyReport } from '../src/report.ts'
import { getAllModelResults, hasModelResults, saveModelResults } from '../src/storage.ts'
import { ensureDir } from '../src/utils.ts'
// Constants
const PROGRESS_UPDATE_INTERVAL = 10
+2 -2
View File
@@ -4,8 +4,8 @@ import process from 'node:process'
import * as prompts from '@clack/prompts'
import { ofetch } from 'ofetch'
import pMap from 'p-map'
import { BENCHMARKS_DIR } from '../src/constants'
import { ensureDir } from '../src/utils'
import { BENCHMARKS_DIR } from '../src/constants.ts'
import { ensureDir } from '../src/utils.ts'
prompts.intro('GitHub Repositories Fetcher')
@@ -1,12 +1,12 @@
import type { Dataset } from '../src/types'
import type { Dataset } from '../src/types.ts'
import * as fsp from 'node:fs/promises'
import * as path from 'node:path'
import * as prompts from '@clack/prompts'
import { encode } from '../../packages/toon/src'
import { BENCHMARKS_DIR, FORMATTER_DISPLAY_NAMES, ROOT_DIR } from '../src/constants'
import { TOKEN_EFFICIENCY_DATASETS } from '../src/datasets'
import { formatters, supportsCSV } from '../src/formatters'
import { createProgressBar, ensureDir, tokenize } from '../src/utils'
import { encode } from '../../packages/toon/src/index.ts'
import { BENCHMARKS_DIR, FORMATTER_DISPLAY_NAMES, ROOT_DIR } from '../src/constants.ts'
import { TOKEN_EFFICIENCY_DATASETS } from '../src/datasets.ts'
import { formatters, supportsCSV } from '../src/formatters.ts'
import { createProgressBar, ensureDir, tokenize } from '../src/utils.ts'
interface FormatMetrics {
name: string
+2 -2
View File
@@ -34,9 +34,9 @@ export const DRY_RUN_LIMITS = {
/// keep-sorted
export const MODEL_RPM_LIMITS: Record<string, number | undefined> = {
'claude-haiku-4-5-20251001': 50,
'gemini-2.5-flash': 25,
'gemini-3-flash-preview': 25,
'gpt-5-nano': 50,
'grok-4-fast-non-reasoning': 50,
'grok-4-1-fast-non-reasoning': 25,
}
/**
+1 -1
View File
@@ -1,4 +1,4 @@
import type { Dataset } from './types'
import type { Dataset } from './types.ts'
import { faker } from '@faker-js/faker'
import githubRepos from '../data/github-repos.json' with { type: 'json' }
+7 -7
View File
@@ -1,20 +1,20 @@
import type { LanguageModelV2 } from '@ai-sdk/provider'
import type { EvaluationResult, Question } from './types'
import type { LanguageModelV3 } from '@ai-sdk/provider'
import type { EvaluationResult, Question } from './types.ts'
import { anthropic } from '@ai-sdk/anthropic'
import { google } from '@ai-sdk/google'
import { openai } from '@ai-sdk/openai'
import { xai } from '@ai-sdk/xai'
import { generateText } from 'ai'
import { compareAnswers } from './normalize'
import { compareAnswers } from './normalize.ts'
/**
* Models used for evaluation
*/
export const models: LanguageModelV2[] = [
export const models: LanguageModelV3[] = [
anthropic('claude-haiku-4-5-20251001'),
google('gemini-2.5-flash'),
google('gemini-3-flash-preview'),
openai('gpt-5-nano'),
xai('grok-4-fast-non-reasoning'),
xai('grok-4-1-fast-non-reasoning'),
]
/**
@@ -58,7 +58,7 @@ export async function evaluateQuestion(
question: Question
formatName: string
formattedData: string
model: LanguageModelV2
model: LanguageModelV3
},
): Promise<EvaluationResult> {
const primer = PRIMERS[formatName] ?? ''
+2 -2
View File
@@ -1,8 +1,8 @@
import type { Dataset } from './types'
import type { Dataset } from './types.ts'
import { stringify as stringifyCSV } from 'csv-stringify/sync'
import { XMLBuilder } from 'fast-xml-parser'
import { stringify as stringifyYAML } from 'yaml'
import { encode as encodeToon } from '../../packages/toon/src'
import { encode as encodeToon } from '../../packages/toon/src/index.ts'
/**
* Format converters registry
+4 -4
View File
@@ -1,7 +1,7 @@
import type { AnalyticsMetric } from '../datasets'
import type { Question } from '../types'
import { QUESTION_LIMITS, QUESTION_THRESHOLDS } from '../constants'
import { QuestionBuilder, rotateQuestions, SAMPLE_STRIDES } from './utils'
import type { AnalyticsMetric } from '../datasets.ts'
import type { Question } from '../types.ts'
import { QUESTION_LIMITS, QUESTION_THRESHOLDS } from '../constants.ts'
import { QuestionBuilder, rotateQuestions, SAMPLE_STRIDES } from './utils.ts'
/**
* Generate analytics (website metrics) questions
+4 -4
View File
@@ -1,7 +1,7 @@
import type { EventLog } from '../datasets'
import type { Question } from '../types'
import { QUESTION_LIMITS } from '../constants'
import { QuestionBuilder, rotateQuestions, SAMPLE_STRIDES } from './utils'
import type { EventLog } from '../datasets.ts'
import type { Question } from '../types.ts'
import { QUESTION_LIMITS } from '../constants.ts'
import { QuestionBuilder, rotateQuestions, SAMPLE_STRIDES } from './utils.ts'
/**
* Generate event log questions
+4 -4
View File
@@ -1,7 +1,7 @@
import type { Repository } from '../datasets'
import type { Question } from '../types'
import { QUESTION_LIMITS, QUESTION_THRESHOLDS } from '../constants'
import { QuestionBuilder, rotateQuestions, SAMPLE_STRIDES } from './utils'
import type { Repository } from '../datasets.ts'
import type { Question } from '../types.ts'
import { QUESTION_LIMITS, QUESTION_THRESHOLDS } from '../constants.ts'
import { QuestionBuilder, rotateQuestions, SAMPLE_STRIDES } from './utils.ts'
/**
* Generate GitHub repository questions
+12 -12
View File
@@ -1,15 +1,15 @@
import type { AnalyticsMetric, Employee, EventLog, NestedConfig, Order, Repository } from '../datasets'
import type { Question } from '../types'
import { ACCURACY_DATASETS } from '../datasets'
import { generateAnalyticsQuestions } from './analytics'
import { generateEventLogsQuestions } from './event-logs'
import { generateGithubQuestions } from './github'
import { generateNestedQuestions } from './nested'
import { generateNestedConfigQuestions } from './nested-config'
import { generateStructuralValidationQuestions } from './structural-validation'
import { generateStructureQuestions } from './structure'
import { generateTabularQuestions } from './tabular'
import { createIdGenerator } from './utils'
import type { AnalyticsMetric, Employee, EventLog, NestedConfig, Order, Repository } from '../datasets.ts'
import type { Question } from '../types.ts'
import { ACCURACY_DATASETS } from '../datasets.ts'
import { generateAnalyticsQuestions } from './analytics.ts'
import { generateEventLogsQuestions } from './event-logs.ts'
import { generateGithubQuestions } from './github.ts'
import { generateNestedConfigQuestions } from './nested-config.ts'
import { generateNestedQuestions } from './nested.ts'
import { generateStructuralValidationQuestions } from './structural-validation.ts'
import { generateStructureQuestions } from './structure.ts'
import { generateTabularQuestions } from './tabular.ts'
import { createIdGenerator } from './utils.ts'
/**
* Generate questions from all datasets
+4 -4
View File
@@ -1,7 +1,7 @@
import type { NestedConfig } from '../datasets'
import type { Question } from '../types'
import { QUESTION_LIMITS } from '../constants'
import { QuestionBuilder } from './utils'
import type { NestedConfig } from '../datasets.ts'
import type { Question } from '../types.ts'
import { QUESTION_LIMITS } from '../constants.ts'
import { QuestionBuilder } from './utils.ts'
/**
* Generate nested configuration questions
+4 -4
View File
@@ -1,7 +1,7 @@
import type { Order } from '../datasets'
import type { Question } from '../types'
import { QUESTION_LIMITS, QUESTION_THRESHOLDS } from '../constants'
import { QuestionBuilder, rotateQuestions, SAMPLE_STRIDES } from './utils'
import type { Order } from '../datasets.ts'
import type { Question } from '../types.ts'
import { QUESTION_LIMITS, QUESTION_THRESHOLDS } from '../constants.ts'
import { QuestionBuilder, rotateQuestions, SAMPLE_STRIDES } from './utils.ts'
/**
* Generate nested (orders) questions
@@ -1,5 +1,5 @@
import type { Question } from '../types'
import { QuestionBuilder } from './utils'
import type { Question } from '../types.ts'
import { QuestionBuilder } from './utils.ts'
/**
* Generate structural validation questions for all incompleteness fixtures
+3 -3
View File
@@ -1,6 +1,6 @@
import type { AnalyticsMetric, Employee, EventLog, Order, Repository } from '../datasets'
import type { Question } from '../types'
import { QuestionBuilder } from './utils'
import type { AnalyticsMetric, Employee, EventLog, Order, Repository } from '../datasets.ts'
import type { Question } from '../types.ts'
import { QuestionBuilder } from './utils.ts'
/**
* Generate structure-awareness questions across all datasets
+4 -4
View File
@@ -1,7 +1,7 @@
import type { Employee } from '../datasets'
import type { Question } from '../types'
import { QUESTION_LIMITS, QUESTION_THRESHOLDS } from '../constants'
import { QuestionBuilder, rotateQuestions, SAMPLE_STRIDES } from './utils'
import type { Employee } from '../datasets.ts'
import type { Question } from '../types.ts'
import { QUESTION_LIMITS, QUESTION_THRESHOLDS } from '../constants.ts'
import { QuestionBuilder, rotateQuestions, SAMPLE_STRIDES } from './utils.ts'
/**
* Generate tabular (employee) questions
+2 -2
View File
@@ -1,5 +1,5 @@
import type { AnswerType, NormalizationOptions } from '../normalize'
import type { Question } from '../types'
import type { AnswerType, NormalizationOptions } from '../normalize.ts'
import type { Question } from '../types.ts'
// Constants for sampling strides
export const SAMPLE_STRIDES = {
+26 -21
View File
@@ -1,10 +1,10 @@
import type { Dataset, EfficiencyRanking, EvaluationResult, FormatResult, Question } from './types'
import { FORMATTER_DISPLAY_NAMES, QUESTION_TYPE_LABELS, QUESTION_TYPES } from './constants'
import { ACCURACY_DATASETS } from './datasets'
import { models, PRIMERS } from './evaluate'
import { supportsCSV } from './formatters'
import { generateQuestions } from './questions'
import { createProgressBar, tokenize } from './utils'
import type { Dataset, EfficiencyRanking, EvaluationResult, FormatResult, Question } from './types.ts'
import { FORMATTER_DISPLAY_NAMES, QUESTION_TYPE_LABELS, QUESTION_TYPES } from './constants.ts'
import { ACCURACY_DATASETS } from './datasets.ts'
import { models, PRIMERS } from './evaluate.ts'
import { supportsCSV } from './formatters.ts'
import { generateQuestions } from './questions/index.ts'
import { createProgressBar, tokenize } from './utils.ts'
const EFFICIENCY_CHART_STYLE: 'vertical' | 'horizontal' = 'horizontal'
@@ -179,17 +179,22 @@ function generateEfficiencyRankingReport(
if (csv) {
// CSV totalCount is evaluations (questions × models), so divide by number of models to get question count
const csvQuestionCount = csv.totalCount / modelCount
csvNote = `\n\n**Note on CSV:** Excluded from ranking as it only supports ${csvQuestionCount} of ${totalQuestions} questions (flat tabular data only). While CSV is highly token-efficient for simple tabular data, it cannot represent nested structures that other formats handle.`
csvNote = `**Note on CSV:** Excluded from ranking as it only supports ${csvQuestionCount} of ${totalQuestions} questions (flat tabular data only). While CSV is highly token-efficient for simple tabular data, it cannot represent nested structures that other formats handle.`
}
return `
Each format's overall performance, balancing accuracy against token cost:
Each format ranked by efficiency (accuracy percentage per 1,000 tokens):
\`\`\`
${efficiencyChart}
\`\`\`
${summary}${csvNote}
*Efficiency score = (Accuracy % ÷ Tokens) × 1,000. Higher is better.*
> [!TIP]
> ${summary}
${csvNote}
`.trim()
}
@@ -275,18 +280,16 @@ ${modelPerformance}
</details>
<details>
<summary><strong>How the benchmark works</strong></summary>
#### What's Being Measured
This benchmark tests **LLM comprehension and data retrieval accuracy** across different input formats. Each LLM receives formatted data and must answer questions about it (this does **not** test model's ability to generate TOON output).
This benchmark tests **LLM comprehension and data retrieval accuracy** across different input formats. Each LLM receives formatted data and must answer questions about it. This does **not** test the model's ability to generate TOON output only to read and understand it.
#### Datasets Tested
Eleven datasets designed to test different structural patterns and validation capabilities:
**Primary datasets:**
1. **Tabular** (${tabularSize} employee records): Uniform objects with identical fields optimal for TOON's tabular format.
2. **Nested** (${nestedSize} e-commerce orders): Complex structures with nested customer objects and item arrays.
3. **Analytics** (${analyticsSize} days of metrics): Time-series data with dates and numeric values.
@@ -295,8 +298,9 @@ Eleven datasets designed to test different structural patterns and validation ca
6. **Nested Config** (${nestedConfigSize} configuration): Deeply nested configuration with minimal tabular eligibility.
**Structural validation datasets:**
7. **Control**: Valid complete dataset (baseline for validation)
8. **Truncated**: Array with 3 rows removed from end (tests [N] length detection)
8. **Truncated**: Array with 3 rows removed from end (tests \`[N]\` length detection)
9. **Extra rows**: Array with 3 additional rows beyond declared length
10. **Width mismatch**: Inconsistent field count (missing salary in row 10)
11. **Missing fields**: Systematic field omissions (no email in multiple rows)
@@ -319,14 +323,14 @@ ${totalQuestions} questions are generated dynamically across five categories:
- Example: "How many employees in Sales have salary > 80000?" → \`5\`
- Example: "How many active employees have more than 10 years of experience?" → \`8\`
- **Structure awareness (${structureAwarenessPercent}%)**: Tests format-native structural affordances (TOON's [N] count and {fields}, CSV's header row)
- **Structure awareness (${structureAwarenessPercent}%)**: Tests format-native structural affordances (TOON's \`[N]\` count and \`{fields}\`, CSV's header row)
- Example: "How many employees are in the dataset?" → \`100\`
- Example: "List the field names for employees" → \`id, name, email, department, salary, yearsExperience, active\`
- Example: "What is the department of the last employee?" → \`Sales\`
- **Structural validation (${structuralValidationPercent}%)**: Tests ability to detect incomplete, truncated, or corrupted data using structural metadata
- Example: "Is this data complete and valid?" → \`YES\` (control dataset) or \`NO\` (corrupted datasets)
- Tests TOON's [N] length validation and {fields} consistency checking
- Tests TOON's \`[N]\` length validation and \`{fields}\` consistency checking
- Demonstrates CSV's lack of structural validation capabilities
#### Evaluation Process
@@ -341,8 +345,6 @@ ${totalQuestions} questions are generated dynamically across five categories:
- **Token counting**: Using \`gpt-tokenizer\` with \`o200k_base\` encoding (GPT-5 tokenizer)
- **Temperature**: Not set (models use their defaults)
- **Total evaluations**: ${totalQuestions} questions × ${formatCount} formats × ${modelNames.length} models = ${totalEvaluations.toLocaleString('en-US')} LLM calls
</details>
`.trim()
}
@@ -398,7 +400,10 @@ function generateSummaryComparison(
if (!toon || !json)
return ''
return `**Key tradeoff:** TOON achieves **${(toon.accuracy * 100).toFixed(1)}% accuracy** (vs JSON's ${(json.accuracy * 100).toFixed(1)}%) while using **${((1 - toon.totalTokens / json.totalTokens) * 100).toFixed(1)}% fewer tokens** on these datasets.`
return `
> [!TIP]
> TOON achieves **${(toon.accuracy * 100).toFixed(1)}% accuracy** (vs JSON's ${(json.accuracy * 100).toFixed(1)}%) while using **${((1 - toon.totalTokens / json.totalTokens) * 100).toFixed(1)}% fewer tokens** on these datasets.
`.trim()
}
/**
@@ -566,7 +571,7 @@ function generateHorizontalEfficiencyChart(
const accuracy = `${(r.accuracy * 100).toFixed(1)}%`.padStart(5)
const tokens = r.tokens.toLocaleString('en-US').padStart(5)
return `${formatName} ${bar} ${efficiency}${accuracy} acc │ ${tokens} tokens`
return `${formatName} ${bar} ${efficiency} acc%/1K tok${accuracy} acc │ ${tokens} tokens`
})
.join('\n')
}
+2 -3
View File
@@ -1,10 +1,9 @@
import type { Storage, StorageValue } from 'unstorage'
import type { EvaluationResult } from './types'
import type { EvaluationResult } from './types.ts'
import * as path from 'node:path'
import { createStorage } from 'unstorage'
// @ts-expect-error: No types available
import fsDriver from 'unstorage/drivers/fs'
import { BENCHMARKS_DIR } from './constants'
import { BENCHMARKS_DIR } from './constants.ts'
/**
* Storage instance for model results
+2 -2
View File
@@ -1,5 +1,5 @@
import type { DATASET_NAMES, QUESTION_TYPES, STRUCTURE_CLASSES } from './constants'
import type { AnswerType, NormalizationOptions } from './normalize'
import type { DATASET_NAMES, QUESTION_TYPES, STRUCTURE_CLASSES } from './constants.ts'
import type { AnswerType, NormalizationOptions } from './normalize.ts'
export type QuestionType = typeof QUESTION_TYPES[number]
export type DatasetName = typeof DATASET_NAMES[number]
+38
View File
@@ -0,0 +1,38 @@
import type { Rule, UserConfig } from '@commitlint/types'
import { RuleConfigSeverity } from '@commitlint/types'
// #region Rules
/**
* Rule to ensure the first letter of the commit subject is lowercase.
*
* @param parsed - Parsed commit object containing commit message parts.
* @returns A tuple where the first element is a boolean indicating
* if the rule passed, and the second is an optional error message.
*/
const subjectLowercaseFirst: Rule = async (parsed) => {
const firstChar = parsed.subject!.match(/[a-z]/i)?.[0]
if (firstChar && firstChar === firstChar.toUpperCase()) {
return [false, 'Subject must start with a lowercase letter']
}
return [true]
}
// #endregion
const Configuration: UserConfig = {
extends: ['@commitlint/config-conventional'],
rules: {
'subject-case': [RuleConfigSeverity.Disabled],
'subject-lowercase-first': [RuleConfigSeverity.Error, 'always'],
},
plugins: [
{
rules: {
'subject-lowercase-first': subjectLowercaseFirst,
},
},
],
}
export default Configuration
+145
View File
@@ -0,0 +1,145 @@
import type { DefaultTheme } from 'vitepress'
import UnoCSS from 'unocss/vite'
import { defineConfig } from 'vitepress'
import llmstxt, { copyOrDownloadAsMarkdownButtons } from 'vitepress-plugin-llms'
import { description, github, name, ogImage, ogUrl, releases, twitterImage, version } from './meta'
export default defineConfig({
title: name,
description,
head: [
['link', { rel: 'icon', href: '/favicon.svg', type: 'image/svg+xml' }],
['meta', { name: 'author', content: 'Johann Schopplich' }],
['meta', { property: 'og:type', content: 'website' }],
['meta', { property: 'og:url', content: ogUrl }],
['meta', { property: 'og:title', content: name }],
['meta', { property: 'og:description', content: description }],
['meta', { property: 'og:image', content: ogImage }],
['meta', { name: 'twitter:title', content: name }],
['meta', { name: 'twitter:description', content: description }],
['meta', { name: 'twitter:image', content: twitterImage }],
['meta', { name: 'twitter:site', content: '@jschopplich' }],
['meta', { name: 'twitter:creator', content: '@jschopplich' }],
['meta', { name: 'twitter:card', content: 'summary_large_image' }],
],
vite: {
// @ts-expect-error UnoCSS types are not compatible with Vite yet
plugins: [UnoCSS(), llmstxt()],
},
themeConfig: {
logo: '/favicon.svg',
nav: [
{
text: 'Playground',
link: '/playground',
},
{
text: 'Guide',
activeMatch: '^/guide/',
items: [
{ text: 'Getting Started', link: '/guide/getting-started' },
{ text: 'Format Overview', link: '/guide/format-overview' },
{ text: 'Using TOON with LLMs', link: '/guide/llm-prompts' },
{ text: 'Benchmarks', link: '/guide/benchmarks' },
],
},
{
text: 'CLI',
link: '/cli/',
},
{
text: 'Reference',
activeMatch: '^/reference/',
items: [
{ text: 'API', link: '/reference/api' },
{ text: 'Syntax Cheatsheet', link: '/reference/syntax-cheatsheet' },
{ text: 'Specification', link: '/reference/spec' },
{ text: 'Efficiency Formalization', link: '/reference/efficiency-formalization' },
],
},
{
text: 'Ecosystem',
activeMatch: '^/ecosystem/',
items: [
{ text: 'Tools & Playgrounds', link: '/ecosystem/tools-and-playgrounds' },
{ text: 'Implementations', link: '/ecosystem/implementations' },
],
},
{
text: `v${version}`,
items: [
{
text: 'Release Notes',
link: releases,
},
],
},
],
sidebar: {
'/guide/': sidebarPrimary(),
'/cli/': sidebarPrimary(),
'/reference/': sidebarPrimary(),
'/ecosystem/': sidebarPrimary(),
},
socialLinks: [
{ icon: 'github', link: github },
],
footer: {
message: 'Released under the <a href="https://opensource.org/licenses/MIT" target="_blank">MIT License</a>.',
copyright: 'Copyright © 2025-PRESENT <a href="https://johannschopplich.com" target="_blank">Johann Schopplich</a>',
},
search: {
provider: 'local',
},
},
markdown: {
config(md) {
md.use(copyOrDownloadAsMarkdownButtons)
},
math: true,
},
})
function sidebarPrimary(): DefaultTheme.SidebarItem[] {
return [
{
text: 'Guide',
items: [
{ text: 'Getting Started', link: '/guide/getting-started' },
{ text: 'Format Overview', link: '/guide/format-overview' },
{ text: 'Using TOON with LLMs', link: '/guide/llm-prompts' },
{ text: 'Benchmarks', link: '/guide/benchmarks' },
],
},
{
text: 'Tooling',
items: [
{ text: 'Playground', link: '/playground' },
{ text: 'CLI Reference', link: '/cli/' },
],
},
{
text: 'Ecosystem',
items: [
{ text: 'Tools & Playgrounds', link: '/ecosystem/tools-and-playgrounds' },
{ text: 'Implementations', link: '/ecosystem/implementations' },
],
},
{
text: 'Reference',
items: [
{ text: 'API (TypeScript)', link: '/reference/api' },
{ text: 'Syntax Cheatsheet', link: '/reference/syntax-cheatsheet' },
{ text: 'Specification', link: '/reference/spec' },
{ text: 'Efficiency Formalization', link: '/reference/efficiency-formalization' },
],
},
]
}
+12
View File
@@ -0,0 +1,12 @@
export { description, version } from '../../packages/toon/package.json'
/* VitePress head */
export const name = 'TOON'
export const ogUrl = 'https://toonformat.dev/'
export const ogImage = `${ogUrl}og.png`
export const twitterImage = `${ogUrl}twitter.png`
/* GitHub and social links */
export const github = 'https://github.com/toon-format/toon'
export const releases = 'https://github.com/toon-format/toon/releases'
export const twitter = 'https://twitter.com/jschopplich'
@@ -0,0 +1,764 @@
<script setup lang="ts">
import type { Delimiter, EncodeOptions } from '../../../../packages/toon/src'
import { useClipboard, useDebounceFn } from '@vueuse/core'
import { unzlibSync, zlibSync } from 'fflate'
import { base64ToUint8Array, stringToUint8Array, uint8ArrayToBase64, uint8ArrayToString } from 'uint8array-extras'
import { computed, onMounted, ref, shallowRef, watch } from 'vue'
import { parse as parseYaml, stringify as stringifyYaml } from 'yaml'
import { DEFAULT_DELIMITER, encode } from '../../../../packages/toon/src'
import VPInput from './VPInput.vue'
type InputFormat = 'json' | 'yaml'
type JsonFormat = 'pretty-2' | 'pretty-4' | 'pretty-tab' | 'compact'
interface PlaygroundState extends Required<Pick<EncodeOptions, 'delimiter' | 'indent' | 'keyFolding' | 'flattenDepth'>> {
input: string
inputFormat: InputFormat
jsonFormat: JsonFormat
/** Pre-YAML share URLs stored input under `json`. Read-only fallback. */
json?: string
}
function parseInput(text: string, format: InputFormat): unknown {
return format === 'yaml' ? parseYaml(text) : JSON.parse(text)
}
function stringifyInputYaml(value: unknown): string {
return stringifyYaml(value, { lineWidth: 0 })
}
const PRESETS = {
hikes: {
context: {
task: 'Our favorite hikes together',
location: 'Boulder',
season: 'spring_2025',
},
friends: ['ana', 'luis', 'sam'],
hikes: [
{ id: 1, name: 'Blue Lake Trail', distanceKm: 7.5, elevationGain: 320, companion: 'ana', wasSunny: true },
{ id: 2, name: 'Ridge Overlook', distanceKm: 9.2, elevationGain: 540, companion: 'luis', wasSunny: false },
{ id: 3, name: 'Wildflower Loop', distanceKm: 5.1, elevationGain: 180, companion: 'sam', wasSunny: true },
],
},
orders: {
orders: [
{
orderId: 'ORD-001',
customer: { name: 'Alice Chen', email: 'alice@example.com' },
items: [
{ sku: 'WIDGET-A', quantity: 2, price: 29.99 },
{ sku: 'GADGET-B', quantity: 1, price: 49.99 },
],
total: 109.97,
status: 'shipped',
},
{
orderId: 'ORD-002',
customer: { name: 'Bob Smith', email: 'bob@example.com' },
items: [
{ sku: 'THING-C', quantity: 3, price: 15.00 },
],
total: 45.00,
status: 'delivered',
},
],
},
metrics: {
metrics: [
{ date: '2025-01-01', views: 5200, clicks: 180, conversions: 24, revenue: 2890.50 },
{ date: '2025-01-02', views: 6100, clicks: 220, conversions: 31, revenue: 3450.00 },
{ date: '2025-01-03', views: 4800, clicks: 165, conversions: 19, revenue: 2100.25 },
{ date: '2025-01-04', views: 5900, clicks: 205, conversions: 28, revenue: 3200.00 },
],
},
events: {
logs: [
{ timestamp: '2025-01-15T10:23:45Z', level: 'info', endpoint: '/api/users', statusCode: 200, responseTime: 45 },
{ timestamp: '2025-01-15T10:24:12Z', level: 'error', endpoint: '/api/orders', statusCode: 500, responseTime: 120, error: { message: 'Database timeout', retryable: true } },
{ timestamp: '2025-01-15T10:25:03Z', level: 'info', endpoint: '/api/products', statusCode: 200, responseTime: 32 },
{ timestamp: '2025-01-15T10:26:47Z', level: 'warn', endpoint: '/api/payment', statusCode: 429, responseTime: 5, error: { message: 'Rate limit exceeded', retryable: true } },
],
},
} as const
const DELIMITER_OPTIONS: { value: Delimiter, label: string }[] = [
{ value: ',', label: 'Comma (,)' },
{ value: '\t', label: 'Tab (\\t)' },
{ value: '|', label: 'Pipe (|)' },
]
const JSON_FORMAT_OPTIONS: { value: JsonFormat, label: string, indent: string | number | undefined }[] = [
{ value: 'pretty-2', label: 'Pretty (2 spaces)', indent: 2 },
{ value: 'pretty-4', label: 'Pretty (4 spaces)', indent: 4 },
{ value: 'pretty-tab', label: 'Pretty (tabs)', indent: '\t' },
{ value: 'compact', label: 'Compact', indent: undefined },
]
const DEFAULT_JSON = JSON.stringify(PRESETS.hikes, undefined, 2)
const SHARE_URL_LIMIT = 8 * 1024
// Input state
const inputText = ref(DEFAULT_JSON)
const inputFormat = ref<InputFormat>('json')
const jsonFormat = ref<JsonFormat>('pretty-2')
const currentFormatIndent = computed(() =>
JSON_FORMAT_OPTIONS.find(opt => opt.value === jsonFormat.value)?.indent,
)
const formattedInput = computed(() => {
try {
const data = parseInput(inputText.value, inputFormat.value)
return inputFormat.value === 'yaml' ? stringifyInputYaml(data) : formatJson(data)
}
catch {
return inputText.value
}
})
// Encoder options
const delimiter = ref<Delimiter>(DEFAULT_DELIMITER)
const indent = ref(2)
const keyFolding = ref<'off' | 'safe'>('safe')
const flattenDepth = ref(2)
// Encoding output
const encodingResult = computed(() => {
try {
const parsedInput = parseInput(inputText.value, inputFormat.value)
return {
output: encode(parsedInput, {
indent: indent.value,
delimiter: delimiter.value,
keyFolding: keyFolding.value,
flattenDepth: flattenDepth.value,
}),
error: undefined,
}
}
catch (error) {
const fallback = inputFormat.value === 'yaml' ? 'Invalid YAML' : 'Invalid JSON'
return {
output: '',
error: error instanceof Error ? error.message : fallback,
}
}
})
const toonOutput = computed(() => encodingResult.value.output)
const error = computed(() => encodingResult.value.error)
// Token analysis
const tokenizer = shallowRef<typeof import('gpt-tokenizer') | undefined>()
const inputTokens = computed(() =>
tokenizer.value?.encode(formattedInput.value).length,
)
const toonTokens = computed(() =>
tokenizer.value && toonOutput.value ? tokenizer.value.encode(toonOutput.value).length : undefined,
)
const tokenSavings = computed(() => {
if (!inputTokens.value || !toonTokens.value)
return
const diff = inputTokens.value - toonTokens.value
const percent = Math.abs((diff / inputTokens.value) * 100).toFixed(1)
const sign = diff > 0 ? '' : '+'
return { diff, percent, sign, isSavings: diff > 0 }
})
// UI state
const canShareState = ref(true)
const hasCopiedUrl = ref(false)
const { copy, copied } = useClipboard({ source: toonOutput })
const updateUrl = useDebounceFn(() => {
const hash = encodeState()
const baseUrl = `${window.location.origin}${window.location.pathname}${window.location.search}`
const targetUrl = `${baseUrl}#${hash}`
if (targetUrl.length > SHARE_URL_LIMIT) {
canShareState.value = false
return
}
canShareState.value = true
window.history.replaceState(null, '', `#${hash}`)
}, 300)
watch([inputText, delimiter, indent, keyFolding, flattenDepth, jsonFormat, inputFormat], () => {
updateUrl()
})
watch(jsonFormat, () => {
if (inputFormat.value !== 'json')
return
try {
inputText.value = formatJson(JSON.parse(inputText.value))
}
catch {}
})
watch(inputFormat, (next, prev) => {
if (prev === next)
return
try {
const data = parseInput(inputText.value, prev)
inputText.value = next === 'yaml' ? stringifyInputYaml(data) : formatJson(data)
}
catch {}
})
onMounted(() => {
loadTokenizer()
const hash = window.location.hash.slice(1)
if (!hash)
return
const state = decodeState(hash)
if (state) {
inputText.value = state.input ?? state.json
delimiter.value = state.delimiter
indent.value = state.indent
keyFolding.value = state.keyFolding ?? 'safe'
flattenDepth.value = state.flattenDepth ?? 2
jsonFormat.value = state.jsonFormat ?? 'pretty-2'
inputFormat.value = state.inputFormat ?? 'json'
}
})
function formatJson(value: unknown) {
return JSON.stringify(value, undefined, currentFormatIndent.value)
}
function encodeState() {
const state: PlaygroundState = {
input: inputText.value,
inputFormat: inputFormat.value,
delimiter: delimiter.value,
indent: indent.value,
keyFolding: keyFolding.value,
flattenDepth: flattenDepth.value,
jsonFormat: jsonFormat.value,
}
const compressedData = zlibSync(stringToUint8Array(JSON.stringify(state)))
return uint8ArrayToBase64(compressedData, { urlSafe: true })
}
function decodeState(hash: string) {
try {
const bytes = base64ToUint8Array(hash)
const decompressedData = unzlibSync(bytes)
const decodedData = uint8ArrayToString(decompressedData)
if (decodedData)
return JSON.parse(decodedData) as PlaygroundState
}
catch {}
}
function loadPreset(name: keyof typeof PRESETS) {
const data = PRESETS[name]
inputText.value = inputFormat.value === 'yaml' ? stringifyInputYaml(data) : formatJson(data)
}
async function copyShareUrl() {
if (!canShareState.value)
return
await navigator.clipboard.writeText(window.location.href)
hasCopiedUrl.value = true
setTimeout(() => (hasCopiedUrl.value = false), 2000)
}
async function loadTokenizer() {
tokenizer.value ??= await import('gpt-tokenizer')
}
</script>
<template>
<div class="playground">
<div class="playground-container">
<!-- Header -->
<header class="playground-header">
<h1>Playground</h1>
<p>Convert JSON or YAML to TOON in real time.</p>
</header>
<!-- Options Bar -->
<div class="options-bar">
<VPInput id="inputFormat" label="Input format">
<select id="inputFormat" v-model="inputFormat">
<option value="json">
JSON
</option>
<option value="yaml">
YAML
</option>
</select>
</VPInput>
<VPInput id="delimiter" label="Delimiter">
<select id="delimiter" v-model="delimiter">
<option v-for="opt in DELIMITER_OPTIONS" :key="opt.value" :value="opt.value">
{{ opt.label }}
</option>
</select>
</VPInput>
<VPInput id="indent" label="Indent">
<input
id="indent"
v-model.number="indent"
type="number"
min="0"
max="8"
>
</VPInput>
<VPInput id="keyFolding" label="Key Folding">
<select id="keyFolding" v-model="keyFolding">
<option value="off">
Off
</option>
<option value="safe">
Safe
</option>
</select>
</VPInput>
<VPInput id="flattenDepth" label="Flatten Depth">
<input
id="flattenDepth"
v-model.number="flattenDepth"
type="number"
min="1"
max="10"
:disabled="keyFolding === 'off'"
>
</VPInput>
<VPInput id="preset" label="Preset">
<select id="preset" @change="(e) => loadPreset((e.target as HTMLSelectElement).value as keyof typeof PRESETS)">
<option value="" disabled selected>
Load example
</option>
<option value="hikes">
Hikes (mixed structure)
</option>
<option value="orders">
Orders (nested objects)
</option>
<option value="metrics">
Metrics (tabular data)
</option>
<option value="events">
Events (semi-uniform)
</option>
</select>
</VPInput>
<VPInput v-if="inputFormat === 'json'" id="jsonFormat" label="JSON Baseline">
<select id="jsonFormat" v-model="jsonFormat">
<option v-for="opt in JSON_FORMAT_OPTIONS" :key="opt.value" :value="opt.value">
{{ opt.label }}
</option>
</select>
</VPInput>
<button
class="share-button"
:class="[hasCopiedUrl && 'copied']"
:aria-label="
!canShareState
? 'State too large to share via URL'
: hasCopiedUrl
? 'Link copied!'
: 'Copy shareable URL'
"
:title="!canShareState ? 'State too large to share via URL' : undefined"
:disabled="!canShareState"
:aria-disabled="!canShareState"
@click="copyShareUrl"
>
<span class="vpi-link" :class="[hasCopiedUrl && 'check']" aria-hidden="true" />
<template v-if="!canShareState">
Too large to share
</template>
<template v-else>
{{ hasCopiedUrl ? 'Copied!' : 'Share' }}
</template>
</button>
</div>
<!-- Editor Container -->
<div class="editor-container">
<!-- Input -->
<div class="editor-pane">
<div class="pane-header">
<span class="pane-title">{{ inputFormat === 'yaml' ? 'YAML Input' : 'JSON Input' }}</span>
<span class="pane-stats">
<span class="stat-primary" title="Token count of the formatted input">{{ inputTokens ?? '…' }} tokens</span>
<span class="stat-secondary">{{ formattedInput.length }} chars</span>
</span>
</div>
<textarea
id="input"
v-model="inputText"
class="editor-textarea"
spellcheck="false"
:aria-label="inputFormat === 'yaml' ? 'YAML input' : 'JSON input'"
:aria-describedby="error ? 'parse-error' : undefined"
:aria-invalid="!!error"
:placeholder="inputFormat === 'yaml' ? 'Enter YAML here…' : 'Enter JSON here…'"
/>
</div>
<!-- TOON Output -->
<div class="editor-pane">
<div class="pane-header">
<span class="pane-title">
TOON Output
<span v-if="tokenSavings" class="savings-badge" :class="[!tokenSavings.isSavings && 'increase']">
{{ tokenSavings.sign }}{{ tokenSavings.percent }}%
</span>
</span>
<span class="pane-stats">
<span class="stat-primary">{{ toonTokens ?? '…' }} tokens</span>
<span class="stat-secondary">{{ toonOutput.length }} chars</span>
</span>
</div>
<div class="editor-output">
<button
v-if="!error"
class="copy-button"
:class="[copied && 'copied']"
:aria-label="copied ? 'Copied to clipboard' : 'Copy to clipboard'"
:aria-pressed="copied"
@click="copy()"
/>
<pre v-if="!error"><code>{{ toonOutput }}</code></pre>
<div v-else id="parse-error" role="alert" class="error-message">
{{ error }}
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<style scoped>
.playground {
padding: 32px 24px 32px;
}
@media (min-width: 768px) {
.playground {
padding: 48px 32px 48px;
}
}
@media (min-width: 960px) {
.playground {
padding: 48px 32px 48px;
}
}
.playground-container {
max-width: 1400px;
margin: 0 auto;
}
.playground-header {
margin-bottom: 24px;
}
.playground-header h1 {
font-size: 28px;
font-weight: 600;
letter-spacing: -0.02em;
line-height: 40px;
color: var(--vp-c-text-1);
margin: 0 0 8px;
}
@media (min-width: 768px) {
.playground-header h1 {
font-size: 32px;
}
}
.playground-header p {
font-size: 16px;
line-height: 28px;
color: var(--vp-c-text-2);
}
.options-bar {
display: flex;
flex-wrap: wrap;
gap: 12px;
align-items: flex-end;
margin-bottom: 16px;
padding: 12px 16px;
background: var(--vp-c-bg-soft);
border-radius: 8px;
border: 1px solid var(--vp-c-divider);
}
@media (max-width: 768px) {
.options-bar {
gap: 8px;
}
}
.vpi-link {
--icon: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' stroke='currentColor' stroke-linecap='round' stroke-linejoin='round' stroke-width='2' viewBox='0 0 24 24'%3E%3Cpath d='M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71'/%3E%3Cpath d='M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71'/%3E%3C/svg%3E");
display: inline-block;
width: 1em;
height: 1em;
-webkit-mask: var(--icon) no-repeat;
mask: var(--icon) no-repeat;
-webkit-mask-size: 100% 100%;
mask-size: 100% 100%;
background-color: currentColor;
}
.vpi-link.check {
--icon: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' stroke='currentColor' stroke-linecap='round' stroke-linejoin='round' stroke-width='2' viewBox='0 0 24 24'%3E%3Cpath d='M20 6 9 17l-5-5'/%3E%3C/svg%3E");
}
.share-button {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 0 12px;
height: 32px;
font-size: 13px;
font-weight: 500;
color: var(--vp-c-text-1);
background: var(--vp-c-bg);
border: 1px solid var(--vp-c-border);
border-radius: 6px;
transition: border-color 0.25s, color 0.25s;
margin-left: auto;
}
.share-button:hover {
border-color: var(--vp-c-brand-1);
color: var(--vp-c-brand-1);
}
.share-button:focus-visible {
outline: 2px solid var(--vp-c-brand-1);
outline-offset: 2px;
}
.share-button.copied {
border-color: var(--vp-c-green-1);
color: var(--vp-c-green-1);
}
.share-button:disabled {
color: var(--vp-c-text-3);
border-color: var(--vp-c-divider);
background: var(--vp-c-bg-soft);
cursor: not-allowed;
}
.editor-container {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 16px;
}
@media (max-width: 768px) {
.editor-container {
grid-template-columns: 1fr;
}
}
.editor-pane {
display: flex;
flex-direction: column;
min-height: 500px;
border: 1px solid var(--vp-c-divider);
border-radius: 8px;
overflow: hidden;
background: var(--vp-c-bg-soft);
transition: border-color 0.25s;
}
@media (max-width: 768px) {
.editor-pane {
min-height: 400px;
}
}
.editor-pane:focus-within {
border-color: var(--vp-c-brand-1);
}
.pane-header {
display: flex;
align-items: center;
gap: 12px;
padding: 12px 16px;
background: var(--vp-c-bg-alt);
border-bottom: 1px solid var(--vp-c-divider);
}
.pane-title {
display: flex;
align-items: center;
gap: 8px;
font-size: 0.75rem;
font-weight: 600;
color: var(--vp-c-text-2);
text-transform: uppercase;
letter-spacing: 0.05em;
line-height: 1.5;
}
.pane-stats {
display: flex;
gap: 12px;
margin-left: auto;
font-size: 0.75rem;
font-weight: 400;
color: var(--vp-c-text-2);
text-transform: none;
letter-spacing: normal;
}
.stat-primary {
font-weight: 600;
color: var(--vp-c-text-1);
}
.stat-secondary {
color: var(--vp-c-text-3);
}
.savings-badge {
display: inline-flex;
padding: 2px 6px;
font-size: 0.625rem;
font-weight: 600;
color: var(--vp-c-green-1);
background: var(--vp-c-green-soft);
border-radius: 4px;
text-transform: none;
letter-spacing: normal;
}
.savings-badge.increase {
color: var(--vp-c-yellow-1);
background: var(--vp-c-yellow-soft);
}
.copy-button {
position: absolute;
top: 12px;
right: 12px;
z-index: 3;
border: 1px solid var(--vp-code-copy-code-border-color);
border-radius: 4px;
width: 40px;
height: 40px;
background-color: var(--vp-code-copy-code-bg);
opacity: 0;
cursor: pointer;
background-image: var(--vp-icon-copy);
background-position: 50%;
background-size: 20px;
background-repeat: no-repeat;
transition: border-color 0.25s, background-color 0.25s, opacity 0.25s;
}
.editor-output:hover .copy-button,
.copy-button:focus {
opacity: 1;
}
.copy-button:hover:not(:disabled),
.copy-button.copied {
border-color: var(--vp-code-copy-code-hover-border-color);
background-color: var(--vp-code-copy-code-hover-bg);
}
.copy-button:focus-visible {
outline: 2px solid var(--vp-c-brand-1);
outline-offset: 2px;
}
.copy-button:disabled {
opacity: 0.3;
cursor: not-allowed;
}
.copy-button.copied,
.copy-button:hover.copied {
border-radius: 0 4px 4px 0;
background-image: var(--vp-icon-copied);
}
.copy-button.copied::before,
.copy-button:hover.copied::before {
position: relative;
top: -1px;
transform: translateX(calc(-100% - 1px));
display: flex;
justify-content: center;
align-items: center;
border: 1px solid var(--vp-code-copy-code-hover-border-color);
border-right: 0;
border-radius: 4px 0 0 4px;
padding: 0 10px;
width: fit-content;
height: 40px;
text-align: center;
font-size: 12px;
font-weight: 500;
color: var(--vp-code-copy-code-active-text);
background-color: var(--vp-code-copy-code-hover-bg);
white-space: nowrap;
content: var(--vp-code-copy-copied-text-content);
}
.copy-button[aria-pressed="true"] {
opacity: 1;
}
.editor-textarea,
.editor-output {
flex: 1;
padding: 16px;
font-family: var(--vp-font-family-mono);
font-size: 0.875rem;
line-height: 1.7;
}
.editor-textarea {
resize: none;
color: var(--vp-c-text-1);
background: var(--vp-c-bg);
}
.editor-output {
position: relative;
overflow: auto;
background: var(--vp-code-block-bg);
}
.editor-output pre {
margin: 0;
white-space: pre;
}
.error-message {
color: var(--vp-c-danger-1);
padding: 8px 12px;
background: var(--vp-c-danger-soft);
border-radius: 4px;
font-size: 0.875rem;
font-family: var(--vp-font-family-base);
}
</style>
@@ -0,0 +1,68 @@
<script setup lang="ts">
defineProps<{
label: string
id: string
}>()
</script>
<template>
<div class="VPInput">
<label :for="id" class="label">{{ label }}</label>
<div class="input-wrapper">
<slot />
</div>
</div>
</template>
<style scoped>
.VPInput {
display: flex;
flex-direction: column;
gap: 4px;
}
.label {
font-size: 11px;
font-weight: 500;
color: var(--vp-c-text-2);
}
.input-wrapper :deep(select),
.input-wrapper :deep(input) {
padding: 0 10px;
height: 32px;
font-size: 13px;
font-weight: 500;
color: var(--vp-c-text-1);
background-color: var(--vp-c-bg);
border: 1px solid var(--vp-c-border);
border-radius: 6px;
transition: border-color 0.25s;
}
.input-wrapper :deep(select):hover,
.input-wrapper :deep(input):hover,
.input-wrapper :deep(select):focus,
.input-wrapper :deep(input):focus {
border-color: var(--vp-c-brand-1);
}
.input-wrapper :deep(select:disabled),
.input-wrapper :deep(input:disabled) {
color: var(--vp-c-text-3);
background-color: var(--vp-c-bg-soft);
border-color: var(--vp-c-divider);
cursor: not-allowed;
}
.input-wrapper :deep(select:disabled):hover,
.input-wrapper :deep(input:disabled):hover,
.input-wrapper :deep(select:disabled):focus,
.input-wrapper :deep(input:disabled):focus {
border-color: var(--vp-c-divider);
}
.input-wrapper :deep(input[type="number"]) {
width: 70px;
}
</style>
+23
View File
@@ -0,0 +1,23 @@
import type { Theme } from 'vitepress'
import CopyOrDownloadAsMarkdownButtons from 'vitepress-plugin-llms/vitepress-components/CopyOrDownloadAsMarkdownButtons.vue'
import DefaultTheme from 'vitepress/theme'
import PlaygroundLayout from './components/PlaygroundLayout.vue'
import VPInput from './components/VPInput.vue'
import './vars.css'
import './overrides.css'
import 'uno.css'
const config: Theme = {
extends: DefaultTheme,
enhanceApp({ app }) {
app.config.globalProperties.$spec = {
version: '3.3',
}
app.component('CopyOrDownloadAsMarkdownButtons', CopyOrDownloadAsMarkdownButtons)
app.component('PlaygroundLayout', PlaygroundLayout)
app.component('VPInput', VPInput)
},
}
export default config
+34
View File
@@ -0,0 +1,34 @@
.dark [img-light] {
display: none;
}
html:not(.dark) [img-dark] {
display: none;
}
details summary {
cursor: pointer;
}
.vp-doc [class*="language-"] code {
color: var(--vp-c-text-1)
}
.VPHomeHero .image-src {
max-width: 112px;
max-height: 112px;
}
@media (min-width: 640px) {
.VPHomeHero .image-src {
max-width: 144px;
max-height: 144px;
}
}
@media (min-width:960px) {
.VPHomeHero .image-src {
max-width: 176px;
max-height: 176px;
}
}
+41
View File
@@ -0,0 +1,41 @@
/**
* Colors Theme
* -------------------------------------------------------------------------- */
:root {
--vp-c-brand-1: #d97c06;
--vp-c-brand-2: #C57105;
--vp-c-brand-3: #B16505;
--vp-nav-logo-height: 20px;
}
/**
* Component: Home
* -------------------------------------------------------------------------- */
:root {
--vp-home-hero-name-color: transparent;
--vp-home-hero-name-background: -webkit-linear-gradient(
120deg,
#fde98a 15%,
#d97c06
);
--vp-home-hero-image-background-image: linear-gradient(
-45deg,
#d97c0660 30%,
#fde98a60
);
--vp-home-hero-image-filter: blur(30px);
}
@media (min-width: 640px) {
:root {
--vp-home-hero-image-filter: blur(56px);
}
}
@media (min-width: 960px) {
:root {
--vp-home-hero-image-filter: blur(72px);
}
}
+355
View File
@@ -0,0 +1,355 @@
---
description: Convert JSON to TOON and back from the command line, with token statistics, streaming, and delimiter options.
---
# Command Line Interface
The `@toon-format/cli` package converts JSON to TOON and TOON to JSON. Use it to measure token savings before integrating TOON into your application, or to pipe JSON through TOON in shell workflows alongside tools like `curl` and `jq`. The CLI supports stdin/stdout, token statistics, streaming for large datasets, and every encoding option in the library.
The CLI is built on the `@toon-format/toon` TypeScript implementation and follows the [latest specification](/reference/spec).
## Usage
### Without Installation
Use `npx` to run the CLI without installing:
::: code-group
```bash [Encode]
npx @toon-format/cli input.json -o output.toon
```
```bash [Decode]
npx @toon-format/cli data.toon -o output.json
```
```bash [Stdin]
echo '{"name": "Ada"}' | npx @toon-format/cli
```
:::
### Global Installation
Or install globally for repeated use:
::: code-group
```bash [npm]
npm install -g @toon-format/cli
```
```bash [pnpm]
pnpm add -g @toon-format/cli
```
```bash [yarn]
yarn global add @toon-format/cli
```
:::
After global installation, use the `toon` command:
```bash
toon input.json -o output.toon
```
## Basic Usage
### Auto-Detection
The CLI automatically detects the operation based on file extension:
- `.json` files → encode (JSON to TOON)
- `.toon` files → decode (TOON to JSON)
When reading from stdin, use `--encode` or `--decode` flags to specify the operation (defaults to encode).
::: code-group
```bash [Encode JSON to TOON]
toon input.json -o output.toon
```
```bash [Decode TOON to JSON]
toon data.toon -o output.json
```
```bash [Output to stdout]
toon input.json
```
```bash [Pipe from stdin]
cat data.json | toon
echo '{"name": "Ada"}' | toon
```
```bash [Decode from stdin]
cat data.toon | toon --decode
```
:::
By convention, TOON files use the `.toon` extension and the provisional media type `text/toon` (see [spec §17](https://github.com/toon-format/spec/blob/main/SPEC.md#17-iana-considerations)).
### Standard Input
Omit the input argument or use `-` to read from stdin. This enables piping data directly from other commands:
```bash
# No argument needed
cat data.json | toon
# Explicit stdin with hyphen (equivalent)
cat data.json | toon -
# Decode from stdin
cat data.toon | toon --decode
```
## Performance
### Streaming Output
Both encoding and decoding operations use streaming output, writing incrementally without building the full output string in memory. This makes the CLI efficient for large datasets without requiring additional configuration.
**JSON → TOON (Encode)**:
- Streams TOON lines to output.
- No full TOON string in memory.
**TOON → JSON (Decode)**:
- Uses the same event-based streaming decoder as the `decodeStream` API in `@toon-format/toon`.
- Streams JSON tokens to output.
- No full JSON string in memory.
- When `--expandPaths safe` is enabled, falls back to non-streaming decode internally to apply deep-merge expansion before writing JSON.
Process large files with minimal memory usage:
```bash
# Encode large JSON file
toon huge-dataset.json -o output.toon
# Decode large TOON file
toon huge-dataset.toon -o output.json
# Process millions of records efficiently via stdin
cat million-records.json | toon > output.toon
cat million-records.toon | toon --decode > output.json
```
Peak memory usage scales with data depth, not total size. This allows processing arbitrarily large files as long as individual nested structures fit in memory.
::: tip Token Statistics
When using the `--stats` flag with encode, the CLI builds the full TOON string once to compute accurate token counts. For maximum memory efficiency on very large files, omit `--stats`.
:::
## Options
| Option | Description |
| ------ | ----------- |
| `-o, --output <file>` | Output file path (prints to stdout if omitted) |
| `-e, --encode` | Force encode mode (overrides auto-detection) |
| `-d, --decode` | Force decode mode (overrides auto-detection) |
| `--delimiter <char>` | Array delimiter: `,` (comma), tab character, `\|` (pipe). Pass tab as `$'\t'` in bash/zsh |
| `--indent <number>` | Indentation size (default: `2`) |
| `--stats` | Show token count estimates and savings (encode only) |
| `--no-strict` | Skip decode validation (array counts, indentation, header delimiter); last-write-wins on duplicate keys |
| `--keyFolding <mode>` | Key folding mode: `off`, `safe` (default: `off`) |
| `--flattenDepth <number>` | Maximum segments to fold (default: `Infinity`) requires `--keyFolding safe` |
| `--expandPaths <mode>` | Path expansion mode: `off`, `safe` (default: `off`) |
| `--verbose` | Show full stack traces and cause chains for errors (default: `false`) |
## Advanced Examples
### Token Statistics
Show token savings when encoding:
```bash
toon data.json --stats -o output.toon
```
This helps you estimate token cost savings before sending data to LLMs.
Example output:
```
✔ Encoded data.json → output.toon
Token estimates: ~15,145 (JSON) → ~8,745 (TOON)
✔ Saved ~6,400 tokens (-42.3%)
```
### Alternative Delimiters
TOON supports three delimiters: comma (default), tab, and pipe. Alternative delimiters can save additional tokens depending on the data.
::: code-group
```bash [Tab-separated (bash/zsh)]
toon data.json --delimiter $'\t' -o output.toon
```
```bash [Pipe-separated]
toon data.json --delimiter "|" -o output.toon
```
:::
The `--delimiter` value must be the actual delimiter character. In bash/zsh, use `$'\t'` to pass a real tab; literal `"\t"` is rejected as an invalid delimiter.
**Tab delimiter example:**
::: code-group
```yaml [Tab]
items[2 ]{id name qty price}:
A1 Widget 2 9.99
B2 Gadget 1 14.5
```
```yaml [Comma (default)]
items[2]{id,name,qty,price}:
A1,Widget,2,9.99
B2,Gadget,1,14.5
```
:::
::: tip
Tab delimiters often tokenize more efficiently than commas and reduce the need for quote-escaping. Use `--delimiter $'\t'` (bash/zsh) for maximum token savings on large tabular data. See [Delimiter Strategies](/reference/api#delimiter-strategies) for full guidance.
:::
### Lenient Decoding
Skip validation for faster, more forgiving decoding:
```bash
toon data.toon --no-strict -o output.json
```
With `--no-strict`, the decoder stops enforcing array count matches, indentation multiples, and header delimiter mismatches. Duplicate sibling keys no longer throw the last value wins. Malformed array headers fall back to plain `key: value` lines instead of erroring.
### Decode Error Output
When a TOON document fails to parse, the CLI renders the offending line with a caret pointing at the first non-whitespace character. Tabs are shown as `` so the caret column reflects what the decoder actually saw.
For an input file that uses a tab to indent the second line (rendered here with ``):
```
a:
→b: 1
```
The CLI prints:
```
ERROR Failed to decode TOON at line 2: Tabs are not allowed in indentation in strict mode
2 | →b: 1
^
```
The exit code is `1` on any error. Stack traces are suppressed by default. Pass `--verbose` to include the full stack and the underlying cause chain useful when filing a bug report or diagnosing an unexpected error path:
```bash
cat broken.toon | toon --decode --verbose
```
::: tip Programmatic Access
Decode errors are thrown as `ToonDecodeError` instances by the library. The CLI's caret rendering is built on the structured `line` and `source` fields exposed on that class. See the [Error Handling](/reference/api#error-handling) section of the API reference if you want the same diagnostic detail in your own code.
:::
### Stdin Workflows
The CLI integrates seamlessly with Unix pipes and other command-line tools:
```bash
# Convert API response to TOON
curl https://api.example.com/data | toon --stats
# Process large dataset
cat large-dataset.json | toon --delimiter $'\t' > output.toon
# Chain with jq
jq '.results' data.json | toon > filtered.toon
```
### Key Folding
Collapse nested wrapper chains to reduce tokens (since spec v1.5):
::: code-group
```bash [Basic key folding]
toon input.json --keyFolding safe -o output.toon
```
```bash [Limit folding depth]
toon input.json --keyFolding safe --flattenDepth 2 -o output.toon
```
:::
**Example:**
For data like:
```json
{
"data": {
"metadata": {
"items": ["a", "b"]
}
}
}
```
With `--keyFolding safe`, output becomes:
```yaml
data.metadata.items[2]: a,b
```
Instead of:
```yaml
data:
metadata:
items[2]: a,b
```
### Path Expansion
Reconstruct nested structure from folded keys when decoding:
```bash
toon data.toon --expandPaths safe -o output.json
```
This pairs with `--keyFolding safe` for lossless round-trips.
### Round-Trip Workflow
```bash
# Encode with folding
toon input.json --keyFolding safe -o compressed.toon
# Decode with expansion (restores original structure)
toon compressed.toon --expandPaths safe -o output.json
# Verify round-trip
diff input.json output.json
```
### Combined Options
Combine multiple options for maximum efficiency:
```bash
# Key folding + tab delimiter + stats
toon data.json --keyFolding safe --delimiter $'\t' --stats -o output.toon
```
+68
View File
@@ -0,0 +1,68 @@
---
description: Official and community TOON implementations across languages, plus contribution pointers.
---
# Implementations
TOON has official and community implementations across multiple programming languages. All implementations are intended to conform to the same [Specification](https://github.com/toon-format/spec) to ensure compatibility and interoperability.
The code examples throughout this documentation site use the TypeScript implementation by default, but the format and concepts apply equally to all languages.
> [!NOTE]
> When implementing TOON in other languages, please follow the [spec](https://github.com/toon-format/spec/blob/main/SPEC.md) to ensure compatibility across implementations. The [conformance tests](https://github.com/toon-format/spec/tree/main/tests) provide language-agnostic test fixtures that validate your implementation.
## Official Implementations
These implementations are actively being developed by dedicated teams. Contributions are welcome! Join the effort by opening issues, submitting PRs, or discussing implementation details in the respective repositories.
| Language | Repository | Status | Maintainer |
|----------|------------|--------|------------|
| **.NET** | [toon-dotnet](https://github.com/toon-format/toon-dotnet) | In Development | Official Team |
| **Dart** | [toon-dart](https://github.com/toon-format/toon-dart) | In Development | Official Team |
| **Go** | [toon-go](https://github.com/toon-format/toon-go) | In Development | Official Team |
| **Java** | [toon-java](https://github.com/toon-format/toon-java) | ✅ Stable | Official Team |
| **Julia** | [ToonFormat.jl](https://github.com/toon-format/ToonFormat.jl) | ✅ Stable | Official Team |
| **Python** | [toon-python](https://github.com/toon-format/toon-python) | ✅ Stable | Official Team |
| **Rust** | [toon-rust](https://github.com/toon-format/toon-rust) | ✅ Stable | Official Team |
| **Swift** | [toon-swift](https://github.com/toon-format/toon-swift) | ✅ Stable | Official Team |
| **TypeScript/JavaScript** | [toon](https://github.com/toon-format/toon/tree/main/packages/toon) | ✅ Stable | Official Team |
## Community Implementations
Community members have created implementations in additional languages:
| Language | Repository | Maintainer |
|----------|------------|------------|
| **Apex** | [ApexToon](https://github.com/Eacaw/ApexToon) | [@Eacaw](https://github.com/Eacaw) |
| **C** | [TOONc](https://github.com/UsboKirishima/TOONc) | [@UsboKirishima](https://github.com/UsboKirishima) |
| **C++** | [ctoon](https://github.com/mohammadraziei/ctoon) | [@mohammadraziei](https://github.com/mohammadraziei) |
| **C#** | [ToonEncoder](https://github.com/Cysharp/ToonEncoder) | [@Cysharp](https://github.com/Cysharp/ToonEncoder) |
| **Clojure** | [toon](https://github.com/vadelabs/toon) | [@vadelabs](https://github.com/vadelabs) |
| **Crystal** | [toon-crystal](https://github.com/mamantoha/toon-crystal) | [@mamantoha](https://github.com/mamantoha) |
| **Delphi** | [delphi-toon](https://github.com/ernestoalconada/delphi-toon) | [@ernestoalconada](https://github.com/ernestoalconada) |
| **Elixir** | [toon_ex](https://github.com/kentaro/toon_ex) | [@kentaro](https://github.com/kentaro) |
| **Gleam** | [toon_codec](https://github.com/axelbellec/toon_codec) | [@axelbellec](https://github.com/axelbellec) |
| **Go** | [gotoon](https://github.com/alpkeskin/gotoon) | [@alpkeskin](https://github.com/alpkeskin) |
| **Java** | [json-io](https://github.com/jdereg/json-io) | [@jdereg](https://github.com/jdereg) |
| **Kotlin** | [ktoon](https://github.com/lukelast/ktoon)| [@lukelast](https://github.com/lukelast) |
| **Laravel Framework** | [laravel-toon](https://github.com/mischasigtermans/laravel-toon) | [@mischasigtermans](https://github.com/mischasigtermans) |
| **Lua/Neovim** | [toon.nvim](https://github.com/thalesgelinger/toon.nvim) | [@thalesgelinger](https://github.com/thalesgelinger) |
| **Matlab** | [ctoon](https://github.com/mohammadraziei/ctoon) | [@mohammadraziei](https://github.com/mohammadraziei) |
| **OCaml** | [ocaml-toon](https://github.com/davesnx/ocaml-toon) | [@davesnx](https://github.com/davesnx) |
| **Perl** | [Data::TOON](https://github.com/ytnobody/p5-Data-TOON) | [@ytnobody](https://github.com/ytnobody) |
| **PHP** | [toon-php](https://github.com/HelgeSverre/toon-php) | [@HelgeSverre](https://github.com/HelgeSverre) |
| **Python** (C++ backend) | [ctoon](https://github.com/mohammadraziei/ctoon) | [@mohammadraziei](https://github.com/mohammadraziei) |
| **Python** (Rust backend) | [toons](https://github.com/alesanfra/toons) | [@alesanfra](https://github.com/alesanfra) |
| **R** | [toon](https://github.com/laresbernardo/toon) | [@laresbernardo](https://github.com/laresbernardo) |
| **Ruby** | [toon-ruby](https://github.com/andrepcg/toon-ruby) | [@andrepcg](https://github.com/andrepcg) |
| **Scala** | [toon4s](https://github.com/vim89/toon4s) | [@vim89](https://github.com/vim89) |
| **Zig** | [toon-zig](https://github.com/LatentEvals/toon-zig) | [@montanaflynn](https://github.com/montanaflynn) |
## Contributing an Implementation
Building a TOON implementation for a new language? Great! Here are some steps to get started:
1. **Follow the spec**: Implement the [latest specification](https://github.com/toon-format/spec/blob/main/SPEC.md).
2. **Add tests**: Run the [reference test suite](https://github.com/toon-format/spec/tree/main/tests).
3. **Document usage**: Provide a clear README with installation and usage examples.
4. **Share it**: Open a PR to add your implementation to the README at [github.com/toon-format/toon](https://github.com/toon-format/toon).
+82
View File
@@ -0,0 +1,82 @@
---
description: TOON playgrounds, CLI, editor support, and ecosystem tools.
---
# Tools and Playgrounds
Experiment with TOON format interactively using these tools for token comparison, format conversion, and validation.
## Playgrounds
### Official Playground
The [TOON Playground](/playground) lets you convert JSON or YAML to TOON in real time, compare token counts, and share your experiments via URL.
### Community Playgrounds
- [Format Tokenization Playground](https://www.curiouslychase.com/playground/format-tokenization-exploration)
- [TOON Tools](https://toontools.vercel.app/)
## CLI Tool
The official TOON CLI provides command-line conversion, token statistics, and all encoding/decoding features. See the [CLI reference](/cli/) for full documentation.
```bash
npx @toon-format/cli input.json --stats -o output.toon
```
## Editor Support
### VS Code
[TOON Language Support](https://marketplace.visualstudio.com/items?itemName=vishalraut.vscode-toon) Syntax highlighting, validation, conversion, and token analysis.
Install from the [VS Code Marketplace](https://marketplace.visualstudio.com/items?itemName=vishalraut.vscode-toon) or via command line:
```bash
code --install-extension vishalraut.vscode-toon
```
### Tree-sitter Grammar
[tree-sitter-toon](https://github.com/3swordman/tree-sitter-toon) Grammar for Tree-sitter-compatible editors (Neovim, Helix, Emacs, Zed).
### Neovim
[toon.nvim](https://github.com/thalesgelinger/toon.nvim) Lua-based plugin for Neovim.
### Other Editors
Use YAML syntax highlighting as a close approximation. Most editors allow associating `.toon` files with YAML language mode.
## Databases
### ToonStore
[ToonStore](https://github.com/Kalama-Tech/toonstoredb) Redis-compatible embedded database (Rust) that stores data in TOON format.
## ORMs
### TORM
[TORM](https://github.com/Kalama-Tech/torm) ORM that works with the ToonStore database, with SDKs for Node.js, Python, Go, and PHP.
## Web APIs
If you're building web applications that work with TOON, you can use the TypeScript library in the browser:
```ts
import { decode, encode } from '@toon-format/toon'
// Works in browsers, Node.js, Deno, and Bun
const toon = encode(data)
const data = decode(toon)
```
See the [API Reference](/reference/api) for details.
## MCP
### Tooner
[Tooner](https://github.com/chaindead/tooner) MCP proxy that converts JSON tool responses to TOON.
+586
View File
@@ -0,0 +1,586 @@
---
description: Retrieval accuracy and token efficiency results for TOON across mixed-structure and flat-only tracks.
---
# Benchmarks
The benchmarks on this page measure TOON's performance across two key dimensions:
- **Retrieval Accuracy**: How well LLMs understand and extract information from different input formats.
- **Token Efficiency**: How many tokens each format requires to represent the same data.
Benchmarks are organized into two tracks to ensure fair comparisons:
- **Mixed-Structure Track**: Datasets with nested or semi-uniform structures (TOON vs JSON, YAML, XML). CSV excluded as it cannot properly represent these structures.
- **Flat-Only Track**: Datasets with flat tabular structures where CSV is applicable (CSV vs TOON vs JSON, YAML, XML).
## Retrieval Accuracy
<!-- automd:file src="../../benchmarks/results/retrieval-accuracy.md" -->
Benchmarks test LLM comprehension across different input formats using 209 data retrieval questions on 4 models.
<details>
<summary><strong>Show Dataset Catalog</strong></summary>
#### Dataset Catalog
| Dataset | Rows | Structure | CSV Support | Eligibility |
| ------- | ---- | --------- | ----------- | ----------- |
| Uniform employee records | 100 | uniform | ✓ | 100% |
| E-commerce orders with nested structures | 50 | nested | ✗ | 33% |
| Time-series analytics data | 60 | uniform | ✓ | 100% |
| Top 100 GitHub repositories | 100 | uniform | ✓ | 100% |
| Semi-uniform event logs | 75 | semi-uniform | ✗ | 50% |
| Deeply nested configuration | 11 | deep | ✗ | 0% |
| Valid complete dataset (control) | 20 | uniform | ✓ | 100% |
| Array truncated: 3 rows removed from end | 17 | uniform | ✓ | 100% |
| Extra rows added beyond declared length | 23 | uniform | ✓ | 100% |
| Inconsistent field count (missing salary in row 10) | 20 | uniform | ✓ | 100% |
| Missing required fields (no email in multiple rows) | 20 | uniform | ✓ | 100% |
**Structure classes:**
- **uniform**: All objects have identical fields with primitive values
- **semi-uniform**: Mix of uniform and non-uniform structures
- **nested**: Objects with nested structures (nested objects or arrays)
- **deep**: Highly nested with minimal tabular eligibility
**CSV Support:** ✓ (supported), ✗ (not supported would require lossy flattening)
**Eligibility:** Percentage of arrays that qualify for TOON's tabular format (uniform objects with primitive values)
</details>
#### Efficiency Ranking (Accuracy per 1K Tokens)
Each format ranked by efficiency (accuracy percentage per 1,000 tokens):
```
TOON ████████████████████ 27.7 acc%/1K tok │ 76.4% acc │ 2,759 tokens
JSON compact █████████████████░░░ 23.7 acc%/1K tok │ 73.7% acc │ 3,104 tokens
YAML ██████████████░░░░░░ 19.9 acc%/1K tok │ 74.5% acc │ 3,749 tokens
JSON ████████████░░░░░░░░ 16.4 acc%/1K tok │ 75.0% acc │ 4,587 tokens
XML ██████████░░░░░░░░░░ 13.8 acc%/1K tok │ 72.1% acc │ 5,221 tokens
```
*Efficiency score = (Accuracy % ÷ Tokens) × 1,000. Higher is better.*
> [!TIP]
> TOON achieves **76.4%** accuracy (vs JSON's 75.0%) while using **39.9% fewer tokens**.
**Note on CSV:** Excluded from ranking as it only supports 109 of 209 questions (flat tabular data only). While CSV is highly token-efficient for simple tabular data, it cannot represent nested structures that other formats handle.
#### Per-Model Accuracy
Accuracy across 4 LLMs on 209 data retrieval questions:
```
claude-haiku-4-5-20251001
→ TOON ████████████░░░░░░░░ 59.8% (125/209)
JSON ███████████░░░░░░░░░ 57.4% (120/209)
YAML ███████████░░░░░░░░░ 56.0% (117/209)
XML ███████████░░░░░░░░░ 55.5% (116/209)
JSON compact ███████████░░░░░░░░░ 55.0% (115/209)
CSV ██████████░░░░░░░░░░ 50.5% (55/109)
gemini-3-flash-preview
XML ████████████████████ 98.1% (205/209)
JSON ███████████████████░ 97.1% (203/209)
YAML ███████████████████░ 97.1% (203/209)
→ TOON ███████████████████░ 96.7% (202/209)
JSON compact ███████████████████░ 96.7% (202/209)
CSV ███████████████████░ 96.3% (105/109)
gpt-5-nano
→ TOON ██████████████████░░ 90.9% (190/209)
JSON compact ██████████████████░░ 90.9% (190/209)
JSON ██████████████████░░ 89.0% (186/209)
CSV ██████████████████░░ 89.0% (97/109)
YAML █████████████████░░░ 87.1% (182/209)
XML ████████████████░░░░ 80.9% (169/209)
grok-4-1-fast-non-reasoning
→ TOON ████████████░░░░░░░░ 58.4% (122/209)
YAML ████████████░░░░░░░░ 57.9% (121/209)
JSON ███████████░░░░░░░░░ 56.5% (118/209)
XML ███████████░░░░░░░░░ 54.1% (113/209)
JSON compact ██████████░░░░░░░░░░ 52.2% (109/209)
CSV ██████████░░░░░░░░░░ 51.4% (56/109)
```
> [!TIP]
> TOON achieves **76.4% accuracy** (vs JSON's 75.0%) while using **39.9% fewer tokens** on these datasets.
<details>
<summary><strong>Performance by dataset, model, and question type</strong></summary>
#### Performance by Question Type
| Question Type | TOON | JSON | YAML | JSON compact | XML | CSV |
| ------------- | ---- | ---- | ---- | ---- | ---- | ---- |
| Field Retrieval | 99.6% | 99.3% | 98.5% | 98.5% | 98.9% | 100.0% |
| Aggregation | 61.9% | 61.9% | 59.9% | 58.3% | 54.4% | 50.9% |
| Filtering | 56.8% | 53.1% | 56.3% | 55.2% | 51.6% | 50.9% |
| Structure Awareness | 89.0% | 87.0% | 84.0% | 84.0% | 81.0% | 85.9% |
| Structural Validation | 70.0% | 60.0% | 60.0% | 55.0% | 85.0% | 80.0% |
#### Performance by Dataset
##### Uniform employee records
| Format | Accuracy | Tokens | Correct/Total |
| ------ | -------- | ------ | ------------- |
| `csv` | 73.2% | 2,334 | 120/164 |
| `toon` | 73.2% | 2,498 | 120/164 |
| `json-compact` | 73.8% | 3,924 | 121/164 |
| `yaml` | 73.8% | 4,959 | 121/164 |
| `json-pretty` | 73.8% | 6,331 | 121/164 |
| `xml` | 74.4% | 7,296 | 122/164 |
##### E-commerce orders with nested structures
| Format | Accuracy | Tokens | Correct/Total |
| ------ | -------- | ------ | ------------- |
| `toon` | 82.3% | 7,458 | 135/164 |
| `json-compact` | 78.7% | 7,110 | 129/164 |
| `yaml` | 79.9% | 8,755 | 131/164 |
| `json-pretty` | 79.3% | 11,234 | 130/164 |
| `xml` | 77.4% | 12,649 | 127/164 |
##### Time-series analytics data
| Format | Accuracy | Tokens | Correct/Total |
| ------ | -------- | ------ | ------------- |
| `csv` | 75.0% | 1,411 | 90/120 |
| `toon` | 78.3% | 1,553 | 94/120 |
| `json-compact` | 74.2% | 2,354 | 89/120 |
| `yaml` | 75.8% | 2,954 | 91/120 |
| `json-pretty` | 75.0% | 3,681 | 90/120 |
| `xml` | 72.5% | 4,389 | 87/120 |
##### Top 100 GitHub repositories
| Format | Accuracy | Tokens | Correct/Total |
| ------ | -------- | ------ | ------------- |
| `csv` | 65.9% | 8,527 | 87/132 |
| `toon` | 66.7% | 8,779 | 88/132 |
| `yaml` | 65.2% | 13,141 | 86/132 |
| `json-compact` | 59.8% | 11,464 | 79/132 |
| `json-pretty` | 63.6% | 15,157 | 84/132 |
| `xml` | 56.1% | 17,105 | 74/132 |
##### Semi-uniform event logs
| Format | Accuracy | Tokens | Correct/Total |
| ------ | -------- | ------ | ------------- |
| `json-compact` | 68.3% | 4,839 | 82/120 |
| `toon` | 65.0% | 5,819 | 78/120 |
| `json-pretty` | 69.2% | 6,817 | 83/120 |
| `yaml` | 61.7% | 5,847 | 74/120 |
| `xml` | 58.3% | 7,729 | 70/120 |
##### Deeply nested configuration
| Format | Accuracy | Tokens | Correct/Total |
| ------ | -------- | ------ | ------------- |
| `json-compact` | 90.5% | 568 | 105/116 |
| `toon` | 94.8% | 655 | 110/116 |
| `yaml` | 93.1% | 675 | 108/116 |
| `json-pretty` | 92.2% | 924 | 107/116 |
| `xml` | 91.4% | 1,013 | 106/116 |
##### Valid complete dataset (control)
| Format | Accuracy | Tokens | Correct/Total |
| ------ | -------- | ------ | ------------- |
| `toon` | 100.0% | 535 | 4/4 |
| `json-compact` | 100.0% | 787 | 4/4 |
| `yaml` | 100.0% | 992 | 4/4 |
| `json-pretty` | 100.0% | 1,274 | 4/4 |
| `xml` | 25.0% | 1,462 | 1/4 |
| `csv` | 0.0% | 483 | 0/4 |
##### Array truncated: 3 rows removed from end
| Format | Accuracy | Tokens | Correct/Total |
| ------ | -------- | ------ | ------------- |
| `csv` | 100.0% | 413 | 4/4 |
| `xml` | 100.0% | 1,243 | 4/4 |
| `toon` | 0.0% | 462 | 0/4 |
| `json-pretty` | 0.0% | 1,085 | 0/4 |
| `yaml` | 0.0% | 843 | 0/4 |
| `json-compact` | 0.0% | 670 | 0/4 |
##### Extra rows added beyond declared length
| Format | Accuracy | Tokens | Correct/Total |
| ------ | -------- | ------ | ------------- |
| `csv` | 100.0% | 550 | 4/4 |
| `toon` | 75.0% | 605 | 3/4 |
| `json-compact` | 75.0% | 901 | 3/4 |
| `xml` | 100.0% | 1,678 | 4/4 |
| `yaml` | 75.0% | 1,138 | 3/4 |
| `json-pretty` | 50.0% | 1,460 | 2/4 |
##### Inconsistent field count (missing salary in row 10)
| Format | Accuracy | Tokens | Correct/Total |
| ------ | -------- | ------ | ------------- |
| `csv` | 100.0% | 480 | 4/4 |
| `json-compact` | 100.0% | 782 | 4/4 |
| `yaml` | 100.0% | 985 | 4/4 |
| `toon` | 100.0% | 1,008 | 4/4 |
| `json-pretty` | 100.0% | 1,266 | 4/4 |
| `xml` | 100.0% | 1,453 | 4/4 |
##### Missing required fields (no email in multiple rows)
| Format | Accuracy | Tokens | Correct/Total |
| ------ | -------- | ------ | ------------- |
| `csv` | 100.0% | 340 | 4/4 |
| `xml` | 100.0% | 1,409 | 4/4 |
| `toon` | 75.0% | 974 | 3/4 |
| `json-pretty` | 50.0% | 1,225 | 2/4 |
| `yaml` | 25.0% | 951 | 1/4 |
| `json-compact` | 0.0% | 750 | 0/4 |
#### Performance by Model
##### claude-haiku-4-5-20251001
| Format | Accuracy | Correct/Total |
| ------ | -------- | ------------- |
| `toon` | 59.8% | 125/209 |
| `json-pretty` | 57.4% | 120/209 |
| `yaml` | 56.0% | 117/209 |
| `xml` | 55.5% | 116/209 |
| `json-compact` | 55.0% | 115/209 |
| `csv` | 50.5% | 55/109 |
##### gemini-3-flash-preview
| Format | Accuracy | Correct/Total |
| ------ | -------- | ------------- |
| `xml` | 98.1% | 205/209 |
| `json-pretty` | 97.1% | 203/209 |
| `yaml` | 97.1% | 203/209 |
| `toon` | 96.7% | 202/209 |
| `json-compact` | 96.7% | 202/209 |
| `csv` | 96.3% | 105/109 |
##### gpt-5-nano
| Format | Accuracy | Correct/Total |
| ------ | -------- | ------------- |
| `toon` | 90.9% | 190/209 |
| `json-compact` | 90.9% | 190/209 |
| `json-pretty` | 89.0% | 186/209 |
| `csv` | 89.0% | 97/109 |
| `yaml` | 87.1% | 182/209 |
| `xml` | 80.9% | 169/209 |
##### grok-4-1-fast-non-reasoning
| Format | Accuracy | Correct/Total |
| ------ | -------- | ------------- |
| `toon` | 58.4% | 122/209 |
| `yaml` | 57.9% | 121/209 |
| `json-pretty` | 56.5% | 118/209 |
| `xml` | 54.1% | 113/209 |
| `json-compact` | 52.2% | 109/209 |
| `csv` | 51.4% | 56/109 |
</details>
#### What's Being Measured
This benchmark tests **LLM comprehension and data retrieval accuracy** across different input formats. Each LLM receives formatted data and must answer questions about it. This does **not** test the model's ability to generate TOON output only to read and understand it.
#### Datasets Tested
Eleven datasets designed to test different structural patterns and validation capabilities:
**Primary datasets:**
1. **Tabular** (100 employee records): Uniform objects with identical fields optimal for TOON's tabular format.
2. **Nested** (50 e-commerce orders): Complex structures with nested customer objects and item arrays.
3. **Analytics** (60 days of metrics): Time-series data with dates and numeric values.
4. **GitHub** (100 repositories): Real-world data from top GitHub repos by stars.
5. **Event Logs** (75 logs): Semi-uniform data with ~50% flat logs and ~50% with nested error objects.
6. **Nested Config** (1 configuration): Deeply nested configuration with minimal tabular eligibility.
**Structural validation datasets:**
7. **Control**: Valid complete dataset (baseline for validation)
8. **Truncated**: Array with 3 rows removed from end (tests `[N]` length detection)
9. **Extra rows**: Array with 3 additional rows beyond declared length
10. **Width mismatch**: Inconsistent field count (missing salary in row 10)
11. **Missing fields**: Systematic field omissions (no email in multiple rows)
#### Question Types
209 questions are generated dynamically across five categories:
- **Field retrieval (33%)**: Direct value lookups or values that can be read straight off a record (including booleans and simple counts such as array lengths)
- Example: "What is Alice's salary?" → `75000`
- Example: "How many items are in order ORD-0042?" → `3`
- Example: "What is the customer name for order ORD-0042?" → `John Doe`
- **Aggregation (30%)**: Dataset-level totals and averages plus single-condition filters (counts, sums, min/max comparisons)
- Example: "How many employees work in Engineering?" → `17`
- Example: "What is the total revenue across all orders?" → `45123.50`
- Example: "How many employees have salary > 80000?" → `23`
- **Filtering (23%)**: Multi-condition queries requiring compound logic (AND constraints across fields)
- Example: "How many employees in Sales have salary > 80000?" → `5`
- Example: "How many active employees have more than 10 years of experience?" → `8`
- **Structure awareness (12%)**: Tests format-native structural affordances (TOON's `[N]` count and `{fields}`, CSV's header row)
- Example: "How many employees are in the dataset?" → `100`
- Example: "List the field names for employees" → `id, name, email, department, salary, yearsExperience, active`
- Example: "What is the department of the last employee?" → `Sales`
- **Structural validation (2%)**: Tests ability to detect incomplete, truncated, or corrupted data using structural metadata
- Example: "Is this data complete and valid?" → `YES` (control dataset) or `NO` (corrupted datasets)
- Tests TOON's `[N]` length validation and `{fields}` consistency checking
- Demonstrates CSV's lack of structural validation capabilities
#### Evaluation Process
1. **Format conversion**: Each dataset is converted to all 6 formats (TOON, JSON, YAML, JSON compact, XML, CSV).
2. **Query LLM**: Each model receives formatted data + question in a prompt and extracts the answer.
3. **Validate deterministically**: Answers are validated using type-aware comparison (e.g., `50000` = `$50,000`, `Engineering` = `engineering`, `2025-01-01` = `January 1, 2025`) without requiring an LLM judge.
#### Models & Configuration
- **Models tested**: `claude-haiku-4-5-20251001`, `gemini-3-flash-preview`, `gpt-5-nano`, `grok-4-1-fast-non-reasoning`
- **Token counting**: Using `gpt-tokenizer` with `o200k_base` encoding (GPT-5 tokenizer)
- **Temperature**: Not set (models use their defaults)
- **Total evaluations**: 209 questions × 6 formats × 4 models = 5,016 LLM calls
<!-- /automd -->
## Token Efficiency
Token counts are measured using the GPT-5 `o200k_base` tokenizer via [`gpt-tokenizer`](https://github.com/niieani/gpt-tokenizer). Savings are calculated against formatted JSON (2-space indentation) as the primary baseline, with additional comparisons to compact JSON (minified), YAML, and XML. Actual savings vary by model and tokenizer.
The benchmarks test datasets across different structural patterns (uniform, semi-uniform, nested, deeply nested) to show where TOON excels and where other formats may be better.
<!-- automd:file src="../../benchmarks/results/token-efficiency.md" -->
#### Mixed-Structure Track
Datasets with nested or semi-uniform structures. CSV excluded as it cannot properly represent these structures.
```
🛒 E-commerce orders with nested structures ┊ Tabular: 33%
TOON █████████████░░░░░░░ 73,126 tokens
├─ vs JSON (33.3%) 109,599 tokens
├─ vs JSON compact (+5.3%) 69,459 tokens
├─ vs YAML (14.4%) 85,415 tokens
└─ vs XML (40.7%) 123,344 tokens
🧾 Semi-uniform event logs ┊ Tabular: 50%
TOON █████████████████░░░ 154,084 tokens
├─ vs JSON (15.0%) 181,201 tokens
├─ vs JSON compact (+19.9%) 128,529 tokens
├─ vs YAML (0.8%) 155,397 tokens
└─ vs XML (25.2%) 205,859 tokens
🧩 Deeply nested configuration ┊ Tabular: 0%
TOON ██████████████░░░░░░ 620 tokens
├─ vs JSON (31.9%) 911 tokens
├─ vs JSON compact (+11.1%) 558 tokens
├─ vs YAML (6.3%) 662 tokens
└─ vs XML (38.2%) 1,003 tokens
──────────────────────────────────── Total ────────────────────────────────────
TOON ████████████████░░░░ 227,830 tokens
├─ vs JSON (21.9%) 291,711 tokens
├─ vs JSON compact (+14.7%) 198,546 tokens
├─ vs YAML (5.7%) 241,474 tokens
└─ vs XML (31.0%) 330,206 tokens
```
#### Flat-Only Track
Datasets with flat tabular structures where CSV is applicable.
```
👥 Uniform employee records ┊ Tabular: 100%
CSV ███████████████████░ 47,102 tokens
TOON ████████████████████ 49,919 tokens (+6.0% vs CSV)
├─ vs JSON (60.7%) 127,063 tokens
├─ vs JSON compact (36.9%) 79,059 tokens
├─ vs YAML (50.1%) 100,011 tokens
└─ vs XML (65.9%) 146,579 tokens
📈 Time-series analytics data ┊ Tabular: 100%
CSV ██████████████████░░ 8,383 tokens
TOON ████████████████████ 9,115 tokens (+8.7% vs CSV)
├─ vs JSON (59.0%) 22,245 tokens
├─ vs JSON compact (35.9%) 14,211 tokens
├─ vs YAML (49.0%) 17,858 tokens
└─ vs XML (65.8%) 26,616 tokens
⭐ Top 100 GitHub repositories ┊ Tabular: 100%
CSV ███████████████████░ 8,512 tokens
TOON ████████████████████ 8,744 tokens (+2.7% vs CSV)
├─ vs JSON (42.3%) 15,144 tokens
├─ vs JSON compact (23.7%) 11,454 tokens
├─ vs YAML (33.4%) 13,128 tokens
└─ vs XML (48.9%) 17,095 tokens
──────────────────────────────────── Total ────────────────────────────────────
CSV ███████████████████░ 63,997 tokens
TOON ████████████████████ 67,778 tokens (+5.9% vs CSV)
├─ vs JSON (58.8%) 164,452 tokens
├─ vs JSON compact (35.3%) 104,724 tokens
├─ vs YAML (48.3%) 130,997 tokens
└─ vs XML (64.4%) 190,290 tokens
```
<details>
<summary><strong>Show detailed examples</strong></summary>
#### 📈 Time-series analytics data
**Savings:** 13,130 tokens (59.0% reduction vs JSON)
**JSON** (22,245 tokens):
```json
{
"metrics": [
{
"date": "2025-01-01",
"views": 6138,
"clicks": 174,
"conversions": 12,
"revenue": 2712.49,
"bounceRate": 0.35
},
{
"date": "2025-01-02",
"views": 4616,
"clicks": 274,
"conversions": 34,
"revenue": 9156.29,
"bounceRate": 0.56
},
{
"date": "2025-01-03",
"views": 4460,
"clicks": 143,
"conversions": 8,
"revenue": 1317.98,
"bounceRate": 0.59
},
{
"date": "2025-01-04",
"views": 4740,
"clicks": 125,
"conversions": 13,
"revenue": 2934.77,
"bounceRate": 0.37
},
{
"date": "2025-01-05",
"views": 6428,
"clicks": 369,
"conversions": 19,
"revenue": 1317.24,
"bounceRate": 0.3
}
]
}
```
**TOON** (9,115 tokens):
```
metrics[5]{date,views,clicks,conversions,revenue,bounceRate}:
2025-01-01,6138,174,12,2712.49,0.35
2025-01-02,4616,274,34,9156.29,0.56
2025-01-03,4460,143,8,1317.98,0.59
2025-01-04,4740,125,13,2934.77,0.37
2025-01-05,6428,369,19,1317.24,0.3
```
---
#### ⭐ Top 100 GitHub repositories
**Savings:** 6,400 tokens (42.3% reduction vs JSON)
**JSON** (15,144 tokens):
```json
{
"repositories": [
{
"id": 28457823,
"name": "freeCodeCamp",
"repo": "freeCodeCamp/freeCodeCamp",
"description": "freeCodeCamp.org's open-source codebase and curriculum. Learn math, programming,…",
"createdAt": "2014-12-24T17:49:19Z",
"updatedAt": "2025-10-28T11:58:08Z",
"pushedAt": "2025-10-28T10:17:16Z",
"stars": 430886,
"watchers": 8583,
"forks": 42146,
"defaultBranch": "main"
},
{
"id": 132750724,
"name": "build-your-own-x",
"repo": "codecrafters-io/build-your-own-x",
"description": "Master programming by recreating your favorite technologies from scratch.",
"createdAt": "2018-05-09T12:03:18Z",
"updatedAt": "2025-10-28T12:37:11Z",
"pushedAt": "2025-10-10T18:45:01Z",
"stars": 430877,
"watchers": 6332,
"forks": 40453,
"defaultBranch": "master"
},
{
"id": 21737465,
"name": "awesome",
"repo": "sindresorhus/awesome",
"description": "😎 Awesome lists about all kinds of interesting topics",
"createdAt": "2014-07-11T13:42:37Z",
"updatedAt": "2025-10-28T12:40:21Z",
"pushedAt": "2025-10-27T17:57:31Z",
"stars": 410052,
"watchers": 8017,
"forks": 32029,
"defaultBranch": "main"
}
]
}
```
**TOON** (8,744 tokens):
```
repositories[3]{id,name,repo,description,createdAt,updatedAt,pushedAt,stars,watchers,forks,defaultBranch}:
28457823,freeCodeCamp,freeCodeCamp/freeCodeCamp,"freeCodeCamp.org's open-source codebase and curriculum. Learn math, programming,…","2014-12-24T17:49:19Z","2025-10-28T11:58:08Z","2025-10-28T10:17:16Z",430886,8583,42146,main
132750724,build-your-own-x,codecrafters-io/build-your-own-x,Master programming by recreating your favorite technologies from scratch.,"2018-05-09T12:03:18Z","2025-10-28T12:37:11Z","2025-10-10T18:45:01Z",430877,6332,40453,master
21737465,awesome,sindresorhus/awesome,😎 Awesome lists about all kinds of interesting topics,"2014-07-11T13:42:37Z","2025-10-28T12:40:21Z","2025-10-27T17:57:31Z",410052,8017,32029,main
```
</details>
<!-- /automd -->
## Related Resources
- [Formal Byte-Level Model](/reference/efficiency-formalization) Mathematical analysis of byte efficiency compared to JSON
- [Specification](/reference/spec) Formal TOON specification
+365
View File
@@ -0,0 +1,365 @@
---
description: TOON syntax with concrete examples objects, arrays, headers, key folding, and quoting rules.
---
# Format Overview
TOON syntax reference with concrete examples. See [Getting Started](/guide/getting-started) for an introduction.
## Data Model
TOON models data the same way as JSON:
- **Primitives**: strings, numbers, booleans, and `null`
- **Objects**: mappings from string keys to values
- **Arrays**: ordered sequences of values
### Root Forms
A TOON document can represent different root forms:
- **Root object** (most common): Fields appear at depth 0 with no parent key
- **Root array**: Begins with `[N]:` or `[N]{fields}:` at depth 0
- **Root primitive**: A single primitive value (string, number, boolean, or null)
Most examples in these docs use root objects, but the format supports all three forms equally ([spec §5](https://github.com/toon-format/spec/blob/main/SPEC.md#5-concrete-syntax-and-root-form)).
## Objects
### Simple Objects
Objects with primitive values use `key: value` syntax, with one field per line:
```yaml
id: 123
name: Ada
active: true
```
Indentation replaces braces. One space follows the colon.
### Nested Objects
Nested objects add one indentation level (default: 2 spaces):
```yaml
user:
id: 123
name: Ada
```
When a key ends with `:` and has no value on the same line, it opens a nested object. All lines at the next indentation level belong to that object.
### Empty Objects
An empty object at the root yields an empty document (no lines). A nested empty object is `key:` alone, with no children.
## Arrays
TOON detects array structure and chooses the most efficient representation. Arrays always declare their length in brackets: `[N]`.
### Primitive Arrays (Inline)
Arrays of primitives (strings, numbers, booleans, null) are rendered inline:
```yaml
tags[3]: admin,ops,dev
```
The delimiter (comma by default) separates values. Strings containing the active delimiter must be quoted.
### Arrays of Objects (Tabular)
When all objects in an array share the same set of primitive-valued keys, TOON uses tabular format:
::: code-group
```yaml [Basic Tabular]
items[2]{sku,qty,price}:
A1,2,9.99
B2,1,14.5
```
```yaml [With Spaces in Values]
users[2]{id,name,role}:
1,Alice Admin,admin
2,"Bob Smith",user
```
:::
The header `items[2]{sku,qty,price}:` declares:
- **Array length**: `[2]` means 2 rows
- **Field names**: `{sku,qty,price}` defines the columns
- **Active delimiter**: comma (default)
Each row contains values in the same order as the field list. Values are encoded as primitives (strings, numbers, booleans, null) and separated by the delimiter.
> [!NOTE]
> Tabular format requires identical field sets across all objects (same keys, order per object may vary), primitive values only (no nested arrays/objects), and at least one key per object arrays that contain an empty `{}` element fall back to the expanded list form below.
### Mixed and Non-Uniform Arrays
Arrays that don't meet the tabular requirements use list format with hyphen markers:
```yaml
items[3]:
- 1
- a: 1
- text
```
Each element starts with `- ` at one indentation level deeper than the parent array header.
### Objects as List Items
When an array element is an object, it appears as a list item:
```yaml
items[2]:
- id: 1
name: First
- id: 2
name: Second
extra: true
```
When a tabular array is the first field of a list-item object, the tabular header appears on the hyphen line, with rows indented two levels deeper and other fields indented one level deeper:
```yaml
items[1]:
- users[2]{id,name}:
1,Ada
2,Bob
status: active
```
When the object has only a single tabular field, the same pattern applies:
```yaml
items[1]:
- users[2]{id,name}:
1,Ada
2,Bob
```
This is the canonical encoding for list-item objects whose first field is a tabular array.
### Arrays of Arrays
When you have arrays containing primitive inner arrays:
```yaml
pairs[2]:
- [2]: 1,2
- [2]: 3,4
```
Each inner array gets its own header on the list-item line.
When the inner arrays are themselves arrays of objects or non-uniform arrays, the same `- [N]:` header appears on the hyphen line and the nested items follow one indent deeper:
```yaml
items[3]:
- summary
- id: 1
name: Ada
- [2]:
- id: 2
- status: draft
```
### Empty Arrays
Empty arrays render as `key: []` for fields and `[]` at the root:
```yaml
items: []
```
The legacy `items[0]:` form is still decoded for backward compatibility.
## Array Headers
### Header Syntax
Array headers follow this pattern:
```
key[N<delimiter?>]<{fields}>:
```
Where:
- **N** is the non-negative integer length
- **delimiter** (optional) explicitly declares the active delimiter:
- Absent → comma (`,`)
- `\t` (tab character) → tab delimiter
- `|` → pipe delimiter
- **fields** (optional) for tabular arrays: `{field1,field2,field3}`
> [!NOTE]
> The array length `[N]` helps LLMs validate structure. If you ask a model to generate TOON output, explicit lengths let you detect truncation or malformed data.
### Delimiter Options
TOON supports three delimiters: comma (default), tab, and pipe. The delimiter is scoped to the array header that declares it.
::: code-group
```yaml [Comma (default)]
items[2]{sku,name,qty,price}:
A1,Widget,2,9.99
B2,Gadget,1,14.5
```
```yaml [Tab]
items[2 ]{sku name qty price}:
A1 Widget 2 9.99
B2 Gadget 1 14.5
```
```yaml [Pipe]
items[2|]{sku|name|qty|price}:
A1|Widget|2|9.99
B2|Gadget|1|14.5
```
:::
Tab and pipe delimiters are explicitly encoded in the header brackets and field braces. Inside an array scope, only the active delimiter triggers quoting the others are literal data. Object field values (`key: value`) follow the document delimiter (§11.1) regardless of any surrounding array's active delimiter.
> [!TIP]
> Tab delimiters often tokenize more efficiently than commas, especially for data with few quoted strings. Use `encode(data, { delimiter: '\t' })` for additional token savings.
## Key Folding (Optional)
Key folding is an optional encoder feature (since spec v1.5) that collapses chains of single-key objects into dotted paths, reducing tokens for deeply nested data.
### Basic Folding
Standard nesting:
```yaml
data:
metadata:
items[2]: a,b
```
With key folding (`keyFolding: 'safe'`):
```yaml
data.metadata.items[2]: a,b
```
The three nested objects collapse into a single dotted key `data.metadata.items`.
### When Folding Applies
A chain of objects is foldable when:
- Each object in the chain has exactly one key (leading to the next object or a leaf value)
- The leaf value is a primitive, array, or empty object
- All segments are valid identifier segments (letters, digits, underscores only; no dots within segments)
- The resulting folded key doesn't collide with existing keys
::: details Advanced Folding Rules
**Segment Requirements (safe mode):**
- All folded segments must match `^[A-Za-z_][A-Za-z0-9_]*$` (no dots, hyphens, or other special characters)
- No segment may require quoting per §7.3 of the spec
- The resulting folded key must not equal any existing sibling literal key at the same depth (collision avoidance)
**Depth Limit:**
- The `flattenDepth` option (default: `Infinity`) controls how many segments to fold
- `flattenDepth: 2` folds only two-segment chains: `{a: {b: val}}` → `a.b: val`
- Values less than 2 have no practical effect
**Round-Trip with Path Expansion:**
To reconstruct the original structure when decoding, use `expandPaths: 'safe'`. This splits dotted keys back into nested objects using the same safety rules ([spec §13.4](https://github.com/toon-format/spec/blob/main/SPEC.md#134-key-folding-and-path-expansion)).
:::
### Round-Trip with Path Expansion
When decoding TOON that used key folding, enable path expansion to restore the nested structure:
```ts
import { decode, encode } from '@toon-format/toon'
const original = { data: { metadata: { items: ['a', 'b'] } } }
// Encode with folding
const toon = encode(original, { keyFolding: 'safe' })
// → "data.metadata.items[2]: a,b"
// Decode with expansion
const restored = decode(toon, { expandPaths: 'safe' })
// → { data: { metadata: { items: ['a', 'b'] } } }
```
Path expansion is off by default, so dotted keys are treated as literal keys unless explicitly enabled.
## Quoting and Types
### When Strings Need Quotes
TOON quotes strings **only when necessary** to maximize token efficiency. A string must be quoted if:
- It's empty (`""`)
- It has leading or trailing whitespace
- It equals `true`, `false`, or `null` (case-sensitive)
- It looks like a number (e.g., `"42"`, `"-3.14"`, `"1e-6"`, `"05"`)
- It contains special characters: colon (`:`), quote (`"`), backslash (`\`), brackets, braces, or any control character in U+0000U+001F
- It contains the relevant delimiter (the active delimiter inside an array scope, or the document delimiter elsewhere)
- It equals `"-"` or starts with `"-"` followed by any character
Otherwise, strings can be unquoted. Unicode, emoji, and strings with internal (non-leading/trailing) spaces are safe unquoted:
```yaml
message: Hello 世界 👋
note: This has inner spaces
```
### Escape Sequences
In quoted strings and keys, six escape sequences are valid:
| Character | Escape |
|-----------|--------|
| Backslash (`\`) | `\\` |
| Double quote (`"`) | `\"` |
| Newline (U+000A) | `\n` |
| Carriage return (U+000D) | `\r` |
| Tab (U+0009) | `\t` |
| Any other U+0000U+001F control character | `\uXXXX` |
Other escapes (e.g., `\x`, `\0`, `\b`) are always rejected, as are lone-surrogate `\uXXXX` values (U+D800U+DFFF).
### Type Conversions
Numbers are emitted in canonical decimal form for values in the §2 carve-out range; exponent notation is permitted outside. Non-JSON types (`NaN`, `Infinity`, `BigInt`, `Date`, `Set`, `Map`, `undefined`, etc.) are normalized before encoding see [API Reference Type Normalization](/reference/api#type-normalization) for the full mapping.
Decoders accept both decimal and exponent forms on input (e.g., `42`, `-3.14`, `1e-6`), and treat tokens with forbidden leading zeros (e.g., `"05"`) as strings, not numbers.
### Custom Serialization with toJSON
Objects with a `toJSON()` method are serialized by calling the method and normalizing its result before encoding, similar to `JSON.stringify`:
```ts
const obj = {
data: 'example',
toJSON() {
return { info: this.data }
}
}
encode(obj)
// info: example
```
The `toJSON()` method:
- Takes precedence over built-in normalization (Date, Array, Set, Map)
- Results are recursively normalized
- Is called for objects with `toJSON` in their prototype chain
---
For complete rules on quoting, escaping, type conversions, and strict-mode decoding, see [spec §24 (data model), §7 (strings and keys), and §14 (strict mode)](https://github.com/toon-format/spec/blob/main/SPEC.md).
+248
View File
@@ -0,0 +1,248 @@
---
description: What TOON is, when to use it, and a first encode/decode example with the TypeScript library.
---
# Getting Started
## What Is TOON?
**Token-Oriented Object Notation** is a compact, human-readable encoding of the JSON data model that minimizes tokens and makes structure easy for models to follow. It is intended for *LLM input* as a drop-in, lossless representation of your existing JSON.
TOON combines YAML's indentation-based structure for nested objects with a CSV-style tabular layout for uniform arrays. TOON's sweet spot is uniform arrays of objects (multiple fields per row, same structure across items), achieving CSV-like compactness while adding explicit structure that helps LLMs parse and validate data reliably.
Think of it as a translation layer: use JSON programmatically, and encode it as TOON for LLM input.
### Why TOON?
Standard JSON is verbose and token-expensive. For uniform arrays of objects, JSON repeats every field name for every record:
```json
{
"users": [
{ "id": 1, "name": "Alice", "role": "admin" },
{ "id": 2, "name": "Bob", "role": "user" }
]
}
```
YAML already reduces some redundancy with indentation instead of braces:
```yaml
users:
- id: 1
name: Alice
role: admin
- id: 2
name: Bob
role: user
```
TOON goes further by declaring fields once and streaming data as rows:
```yaml
users[2]{id,name,role}:
1,Alice,admin
2,Bob,user
```
The `[2]` declares the array length, letting LLMs answer dataset-size questions and detect truncation. The `{id,name,role}` declares the field names. Each row is a compact, comma-separated list of values. The pattern is the same throughout TOON: declare structure once, stream data compactly. The result lands close to CSV density with explicit structure preserved.
For a more realistic example, here's how TOON handles a dataset with both nested objects and tabular arrays:
::: code-group
```json [JSON (235 tokens)]
{
"context": {
"task": "Our favorite hikes together",
"location": "Boulder",
"season": "spring_2025"
},
"friends": ["ana", "luis", "sam"],
"hikes": [
{
"id": 1,
"name": "Blue Lake Trail",
"distanceKm": 7.5,
"elevationGain": 320,
"companion": "ana",
"wasSunny": true
},
{
"id": 2,
"name": "Ridge Overlook",
"distanceKm": 9.2,
"elevationGain": 540,
"companion": "luis",
"wasSunny": false
},
{
"id": 3,
"name": "Wildflower Loop",
"distanceKm": 5.1,
"elevationGain": 180,
"companion": "sam",
"wasSunny": true
}
]
}
```
```yaml [TOON (106 tokens)]
context:
task: Our favorite hikes together
location: Boulder
season: spring_2025
friends[3]: ana,luis,sam
hikes[3]{id,name,distanceKm,elevationGain,companion,wasSunny}:
1,Blue Lake Trail,7.5,320,ana,true
2,Ridge Overlook,9.2,540,luis,false
3,Wildflower Loop,5.1,180,sam,true
```
:::
Notice how TOON combines YAML's indentation for the `context` object with inline format for the primitive `friends` array and tabular format for the structured `hikes` array. Each format is chosen automatically based on the data structure.
### Design Goals
TOON is optimized for specific use cases. It aims to:
- Make uniform arrays of objects as compact as possible by declaring structure once and streaming data.
- Stay fully lossless and deterministic round-trips preserve all data and structure.
- Keep parsing simple and robust for both LLMs and humans through explicit structure markers.
- Provide validation guardrails (array lengths, field counts) that help detect truncation and malformed output.
## When to Use TOON
TOON excels with uniform arrays of objects data with the same structure across items. For LLM prompts, the format produces deterministic, minimally quoted text with built-in validation. Explicit array lengths (`[N]`) and field headers (`{fields}`) help detect truncation and malformed data, while the tabular structure declares fields once rather than repeating them in every row.
::: tip
The TOON format is stable, but also an idea in progress. Nothing's set in stone help shape where it goes by contributing to the [spec](https://github.com/toon-format/spec) or sharing feedback.
:::
## When Not to Use TOON
TOON is not always the best choice. Consider alternatives when:
- **Deeply nested or non-uniform structures** (tabular eligibility ≈ 0%): JSON-compact often uses fewer tokens. Example: complex configuration objects with many nested levels.
- **Semi-uniform arrays** (~4060% tabular eligibility): Token savings diminish. Prefer JSON if your pipelines already rely on it.
- **Pure tabular data**: CSV is smaller than TOON for flat tables. TOON adds minimal overhead (~510%) to provide structure (array length declarations, field headers, delimiter scoping) that improves LLM reliability.
- **Latency-critical applications**: Benchmark on your exact setup. Some deployments (especially local/quantized models) may process compact JSON faster despite TOON's lower token count.
::: info
For data-driven comparisons across different structures, see [Benchmarks](/guide/benchmarks). When optimizing for latency, measure TTFT, tokens/sec, and total time for both TOON and JSON-compact, and use whichever is faster in your specific environment.
:::
## Installation
### TypeScript Library
Install the library via your preferred package manager:
::: code-group
```bash [npm]
npm install @toon-format/toon
```
```bash [pnpm]
pnpm add @toon-format/toon
```
```bash [yarn]
yarn add @toon-format/toon
```
:::
### CLI
The CLI can be used without installation via `npx`, or installed globally:
::: code-group
```bash [npx (no install)]
npx @toon-format/cli input.json -o output.toon
```
```bash [npm]
npm install -g @toon-format/cli
```
```bash [pnpm]
pnpm add -g @toon-format/cli
```
```bash [yarn]
yarn global add @toon-format/cli
```
:::
For full CLI documentation, see the [CLI reference](/cli/).
## Media Type & File Extension
TOON files conventionally use the `.toon` extension. For HTTP transmission, the provisional media type is `text/toon`, always with UTF-8 encoding. While you may specify `charset=utf-8` explicitly, it's optional UTF-8 is the default assumption. This follows the registration process outlined in [spec §17](https://github.com/toon-format/spec/blob/main/SPEC.md#17-iana-considerations).
## Your First Example
The examples below use the TypeScript library for demonstration, but the same operations work in any language with a TOON implementation.
Let's encode a simple dataset with the TypeScript library:
```ts
import { encode } from '@toon-format/toon'
const data = {
users: [
{ id: 1, name: 'Alice', role: 'admin' },
{ id: 2, name: 'Bob', role: 'user' }
]
}
console.log(encode(data))
```
**Output:**
```yaml
users[2]{id,name,role}:
1,Alice,admin
2,Bob,user
```
### Decoding Back to JSON
Decoding is just as simple:
```ts
import { decode } from '@toon-format/toon'
const toon = `
users[2]{id,name,role}:
1,Alice,admin
2,Bob,user
`
const data = decode(toon)
console.log(JSON.stringify(data, null, 2))
```
**Output:**
```json
{
"users": [
{ "id": 1, "name": "Alice", "role": "admin" },
{ "id": 2, "name": "Bob", "role": "user" }
]
}
```
Round-tripping is lossless: `decode(encode(x))` always equals `x` (after normalization of non-JSON types like `Date`, `NaN`, etc.).
## Where to Go Next
Now that you've seen your first TOON document, read the [Format Overview](/guide/format-overview) for complete syntax details (objects, arrays, quoting rules, key folding), then explore [Using TOON with LLMs](/guide/llm-prompts) to see how to use it effectively in prompts. For implementation details, check the [API Reference](/reference/api) (TypeScript) or the [Specification](/reference/spec) (language-agnostic normative rules).
+191
View File
@@ -0,0 +1,191 @@
---
description: Prompting strategies for sending TOON to LLMs and validating TOON they generate, with examples.
---
# Using TOON with LLMs
TOON is designed for passing structured data to Large Language Models with reduced token costs and improved reliability. This guide shows how to use TOON effectively in prompts, both for input (sending data to models) and output (getting models to generate TOON).
This guide is about the TOON format itself. Code examples use the TypeScript library for demonstration, but the same patterns and techniques apply regardless of which programming language you're using.
## Why TOON for LLMs
LLM tokens cost money, and JSON is verbose repeating every field name for every record in an array. TOON minimizes tokens especially for uniform arrays by declaring fields once and streaming data as rows, typically saving 3060% compared to formatted JSON.
TOON adds structure guardrails: explicit `[N]` lengths and `{fields}` headers make it easier for models to track rows and for you to validate output. Strict mode helps detect truncation and malformed TOON when decoding model responses.
## Sending TOON as Input
TOON works best when you show the format instead of describing it. The structure is self-documenting models parse it naturally once they see the pattern.
Wrap your encoded data in a fenced code block (label it ` ```toon` for clarity):
````md
Data is in TOON format (2-space indent, arrays show length and fields).
```toon
users[3]{id,name,role,lastLogin}:
1,Alice,admin,"2025-01-15T10:30:00Z"
2,Bob,user,"2025-01-14T15:22:00Z"
3,Charlie,user,"2025-01-13T09:45:00Z"
```
Task: Summarize the user roles and their last activity.
````
The indentation and headers are usually enough models treat TOON like familiar YAML or CSV. The explicit array lengths (`[N]`) and field headers (`{fields}`) help the model track structure, especially for large tables.
> [!NOTE]
> Most models don't have built-in TOON syntax highlighting, so ` ```toon` or ` ```yaml` both work fine. The structure is what matters.
## Generating TOON from LLMs
For output, be more explicit. When you want the model to **generate** TOON:
- **Show the expected header** (e.g., `users[N]{id,name,role}:`). The model fills rows instead of repeating keys, reducing generation errors.
- **State the rules**: 2-space indent, no trailing spaces, `[N]` matches row count.
Here's a prompt that works for both reading and generating:
````md
Data is in TOON format (2-space indent, arrays show length and fields).
```toon
users[3]{id,name,role,lastLogin}:
1,Alice,admin,"2025-01-15T10:30:00Z"
2,Bob,user,"2025-01-14T15:22:00Z"
3,Charlie,user,"2025-01-13T09:45:00Z"
```
Task: Return only users with role "user" as TOON. Use the same header format. Set [N] to match the row count. Output only the code block.
````
**Expected output:**
```toon
users[2]{id,name,role,lastLogin}:
2,Bob,user,"2025-01-14T15:22:00Z"
3,Charlie,user,"2025-01-13T09:45:00Z"
```
The model adjusts `[N]` to `2` and generates two rows.
### Validation with Strict Mode
When decoding model-generated TOON, use strict mode (default) to catch errors:
```ts
import { decode } from '@toon-format/toon'
try {
const data = decode(modelOutput, { strict: true })
// Success data is valid
}
catch (error) {
// Model output was malformed (count mismatch, invalid escapes, etc.)
console.error('Validation failed:', error.message)
}
```
Strict mode checks counts, indentation, and escaping so you can detect truncation or malformed TOON. For complete details, see the [API Reference](/reference/api#decode-input-options).
## Delimiter Choices for Token Efficiency
Use `delimiter: '\t'` for tab-separated tables if you want even fewer tokens. Tabs are single characters, often tokenize more efficiently than commas, and rarely appear in natural text (reducing quote-escaping).
```ts
const toon = encode(data, { delimiter: '\t' })
```
Tell the model "fields are tab-separated" when using tabs. For more on delimiters, see the [Format Overview](/guide/format-overview#delimiter-options).
## Streaming Large Outputs
When working with large datasets (thousands of records or deeply nested structures), use `encodeLines()` to stream TOON output line-by-line instead of building the full string in memory.
```ts
import { encodeLines } from '@toon-format/toon'
const largeData = await fetchThousandsOfRecords()
// Stream large dataset without loading full string in memory
for (const line of encodeLines(largeData, { delimiter: '\t' })) {
process.stdout.write(`${line}\n`)
}
```
The CLI also supports streaming for memory-efficient JSON-to-TOON conversion:
```bash
toon large-dataset.json -o output.toon
```
This streaming approach prevents out-of-memory errors when preparing large context windows for LLMs. For complete details on `encodeLines()`, see the [API Reference](/reference/api#encodelines-input-options).
**Consuming streaming LLM outputs:** If your LLM client exposes streaming text and you buffer by lines, you can decode TOON incrementally:
```ts
import { decodeFromLines } from '@toon-format/toon'
// Buffer streaming response into lines
const lines: string[] = []
let buffer = ''
for await (const chunk of modelStream) {
buffer += chunk
let index: number
while ((index = buffer.indexOf('\n')) !== -1) {
lines.push(buffer.slice(0, index))
buffer = buffer.slice(index + 1)
}
}
// Decode buffered lines
const data = decodeFromLines(lines)
```
For streaming decode APIs, see [`decodeFromLines()`](/reference/api#decodefromlines-lines-options) and [`decodeStream()`](/reference/api#decodestream-source-options).
## Tips and Pitfalls
**Show, don't describe.** Don't explain TOON syntax in detail just show an example. Models learn the pattern from context. A simple code block with 25 rows is more effective than paragraphs of explanation.
**Keep examples small.** Use 25 rows in your examples, not hundreds. The model generalizes from the pattern. Large examples waste tokens without improving accuracy.
**Always validate output.** Decode generated TOON with `strict: true` (default) to catch errors early. Don't assume model output is valid TOON without checking.
## Real-World Example
Here's a complete workflow: send data to a model and validate its TOON response.
**Prompt with TOON input:**
````md
System logs in TOON format (tab-separated):
```toon
events[4 ]{id level message timestamp}:
1 error Connection timeout "2025-01-15T10:00:00Z"
2 warn Slow query "2025-01-15T10:05:00Z"
3 info User login "2025-01-15T10:10:00Z"
4 error Database error "2025-01-15T10:15:00Z"
```
Task: Return only error-level events as TOON. Use the same format.
````
**Validate the response:**
```ts
import { decode } from '@toon-format/toon'
const modelResponse = `
events[2 ]{id level message timestamp}:
1 error Connection timeout "2025-01-15T10:00:00Z"
4 error Database error "2025-01-15T10:15:00Z"
`
const filtered = decode(modelResponse, { strict: true })
// ✓ Validated model correctly filtered and adjusted [N] to 2
```
+51
View File
@@ -0,0 +1,51 @@
---
layout: home
titleTemplate: Token-Oriented Object Notation
hero:
name: TOON
text: Token-Oriented Object Notation
tagline: A compact, human-readable encoding of the JSON data model for LLM prompts.
image:
dark: /logo-index-dark.svg
light: /logo-index-light.svg
alt: TOON Logo
actions:
- theme: brand
text: What is TOON?
link: /guide/getting-started
- theme: alt
text: Benchmarks
link: /guide/benchmarks
- theme: alt
text: Playground
link: /playground
- theme: alt
text: CLI
link: /cli/
features:
- title: Token-Efficient & Accurate
icon: 📊
details: TOON reaches 76.4% accuracy (vs JSON's 75.0%) while using ~40% fewer tokens in mixed-structure benchmarks across 4 models.
link: /guide/benchmarks
- title: JSON Data Model
icon: 🔁
details: Encodes the same objects, arrays, and primitives as JSON with deterministic, lossless round-trips.
link: /guide/format-overview
- title: LLM-Friendly Guardrails
icon: 🛤️
details: Explicit [N] lengths and {fields} headers give models a clear schema to follow, improving parsing reliability.
link: /guide/format-overview#arrays
- title: Minimal Syntax
icon: 📐
details: Uses indentation instead of braces and minimizes quoting, giving YAML-like readability with CSV-style compactness.
link: /guide/format-overview#arrays
- title: Tabular Arrays
icon: 🧺
details: Uniform arrays of objects collapse into tables that declare fields once and stream row values line by line.
link: /guide/format-overview#arrays
- title: Multi-Language Ecosystem
icon: 🌐
details: Spec-driven implementations in TypeScript, Python, Go, Rust, .NET, and other languages.
link: /ecosystem/implementations
---
+21
View File
@@ -0,0 +1,21 @@
{
"name": "@toon-format/docs",
"type": "module",
"private": true,
"scripts": {
"dev": "vitepress dev",
"build": "vitepress build",
"preview": "vitepress preview"
},
"devDependencies": {
"@vueuse/core": "^14.3.0",
"fflate": "^0.8.3",
"gpt-tokenizer": "^3.4.0",
"markdown-it-mathjax3": "^4.3.2",
"uint8array-extras": "^1.5.0",
"unocss": "^66.7.5",
"vitepress": "^1.6.4",
"vitepress-plugin-llms": "^1.13.3",
"yaml": "^2.9.0"
}
}
+4
View File
@@ -0,0 +1,4 @@
---
layout: PlaygroundLayout
title: Playground
---
Binary file not shown.

After

Width:  |  Height:  |  Size: 5.3 KiB

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 180 180"><g clip-path="url(#a)"><path fill="#fef3c0" d="M0 59.76C0 10.548 10.548 0 59.76 0h60.48C169.452 0 180 10.548 180 59.76v60.48c0 49.212-10.548 59.76-59.76 59.76H59.76C10.548 180 0 169.452 0 120.24z"/><path fill="#fff" d="M120 40h20v20h-20z"/><path fill="#1b1b1f" d="M160 80h-60V20h60zm-40-20h20V40h-20zM140 100v20h-20v40h-20v-60zm20 60h-20v-40h20z"/><path fill="#fff" d="M40 120h20v20H40z"/><path fill="#1b1b1f" d="M80 160H20v-60h60zm-40-20h20v-20H40zM60 80H40V40H20V20h60v20H60z"/></g><defs><clipPath id="a"><path fill="#fff" d="M0 0h180v180H0z"/></clipPath></defs></svg>

After

Width:  |  Height:  |  Size: 645 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 180 180"><g fill="#fff" clip-path="url(#a)"><path d="M180 77.143h-77.143V0H180zm-51.429-25.714h25.715V25.714h-25.715zM154.286 102.857v25.714h-25.715V180h-25.714v-77.143zM180 180h-25.714v-51.429H180zM77.143 180H0v-77.143h77.143zm-51.429-25.714h25.715v-25.715H25.714zM51.429 77.143H25.714V25.714H0V0h77.143v25.714H51.429z"/></g><defs><clipPath id="a"><path fill="#fff" d="M0 0h180v180H0z"/></clipPath></defs></svg>

After

Width:  |  Height:  |  Size: 478 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 180 180"><g fill="#1b1b1f" clip-path="url(#a)"><path d="M180 77.143h-77.143V0H180zm-51.429-25.714h25.715V25.714h-25.715zM154.286 102.857v25.714h-25.715V180h-25.714v-77.143zM180 180h-25.714v-51.429H180zM77.143 180H0v-77.143h77.143zm-51.429-25.714h25.715v-25.715H25.714zM51.429 77.143H25.714V25.714H0V0h77.143v25.714H51.429z"/></g><defs><clipPath id="a"><path fill="#fff" d="M0 0h180v180H0z"/></clipPath></defs></svg>

After

Width:  |  Height:  |  Size: 481 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 180 180"><path fill="#fef3c0" d="M0 0h180v180H0z"/><path fill="#fff" d="M120 40h20v20h-20z"/><path fill="#1b1b1f" d="M160 80h-60V20h60zm-40-20h20V40h-20zM140 100v20h-20v40h-20v-60zm20 60h-20v-40h20z"/><path fill="#fff" d="M40 120h20v20H40z"/><path fill="#1b1b1f" d="M80 160H20v-60h60zm-40-20h20v-20H40zM60 80H40V40H20V20h60v20H60z"/></svg>

After

Width:  |  Height:  |  Size: 405 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

+766
View File
@@ -0,0 +1,766 @@
---
description: TypeScript and JavaScript encode and decode functions, options, error types, and streaming decoders for @toon-format/toon.
---
# API Reference
TypeScript/JavaScript API documentation for the `@toon-format/toon` package. For format rules, see the [Format Overview](/guide/format-overview) or the [Specification](/reference/spec). For other languages, see [Implementations](/ecosystem/implementations).
## Installation
::: code-group
```bash [npm]
npm install @toon-format/toon
```
```bash [pnpm]
pnpm add @toon-format/toon
```
```bash [yarn]
yarn add @toon-format/toon
```
:::
## Encoding Functions
### `encode(input, options?)`
Converts any JSON-serializable value to TOON format.
```ts
import { encode } from '@toon-format/toon'
const toon = encode(data, {
indent: 2,
delimiter: ',',
keyFolding: 'off',
flattenDepth: Infinity
})
```
#### Parameters
| Parameter | Type | Description |
|-----------|------|-------------|
| `input` | `unknown` | Any JSON-serializable value (object, array, primitive, or nested structure) |
| `options` | `EncodeOptions?` | Optional encoding options (see [Configuration Reference](#configuration-reference)) |
#### Return Value
Returns a TOON-formatted string with no trailing newline or spaces.
#### Type Normalization
Non-JSON-serializable values are normalized before encoding:
| Input | Output |
|-------|--------|
| `Object` with `toJSON()` method | Result of calling `toJSON()`, recursively normalized |
| Finite number in `[1e-6, 1e21)` (or zero) | Canonical decimal (e.g., `1e6` → `1000000`, `-0` → `0`) |
| Finite number outside that range | Exponent form permitted (e.g., `1e-7`, `1e+21`) |
| `NaN`, `Infinity`, `-Infinity` | `null` |
| `BigInt` (within safe range) | Number |
| `BigInt` (out of range) | Quoted decimal string (e.g., `"9007199254740993"`) |
| `Date` | ISO string in quotes (e.g., `"2025-01-01T00:00:00.000Z"`) |
| `Set` | Array of normalized values |
| `Map` | Object with `String(key)` keys |
| `undefined`, `function`, `symbol` | `null` |
::: info
TOON itself doesn't specify how `Date` should be encoded the spec leaves this to implementations. This library emits an ISO 8601 string in quotes; other implementations may choose differently.
:::
#### Example
```ts
import { encode } from '@toon-format/toon'
const items = [
{ sku: 'A1', qty: 2, price: 9.99 },
{ sku: 'B2', qty: 1, price: 14.5 }
]
console.log(encode({ items }))
```
**Output:**
```yaml
items[2]{sku,qty,price}:
A1,2,9.99
B2,1,14.5
```
### `encodeLines(input, options?)`
**Preferred method for streaming TOON output.** Converts any JSON-serializable value to TOON format as a sequence of lines, without building the full string in memory. Suitable for streaming large outputs to files, HTTP responses, or process stdout.
```ts
import { encodeLines } from '@toon-format/toon'
// Stream to stdout (Node.js)
for (const line of encodeLines(data)) {
process.stdout.write(`${line}\n`)
}
// Write to file line-by-line
const lines = encodeLines(data, { indent: 2, delimiter: '\t' })
for (const line of lines) {
await writeToStream(`${line}\n`)
}
// Collect to array
const lineArray = Array.from(encodeLines(data))
```
#### Parameters
| Parameter | Type | Description |
|-----------|------|-------------|
| `input` | `unknown` | Any JSON-serializable value (object, array, primitive, or nested structure) |
| `options` | `EncodeOptions?` | Optional encoding options (see [Configuration Reference](#configuration-reference)) |
#### Return Value
Returns an `Iterable<string>` that yields TOON lines one at a time. **Each yielded string is a single line without a trailing newline character** you must add `\n` when writing to streams or stdout.
::: info Relationship to `encode()`
`encode(value, options)` is equivalent to:
```ts
Array.from(encodeLines(value, options)).join('\n')
```
:::
#### Example
```ts
import { createWriteStream } from 'node:fs'
import { encodeLines } from '@toon-format/toon'
const data = {
items: Array.from({ length: 100000 }, (_, i) => ({
id: i,
name: `Item ${i}`,
value: Math.random()
}))
}
// Stream large dataset to file
const stream = createWriteStream('output.toon')
for (const line of encodeLines(data, { delimiter: '\t' })) {
stream.write(`${line}\n`)
}
stream.end()
```
### Replacer Function
The `replacer` option allows you to transform or filter values during encoding. It works similarly to `JSON.stringify`'s replacer parameter, but with path tracking for more precise control.
#### Type Signature
```typescript
type EncodeReplacer = (
key: string,
value: JsonValue,
path: readonly (string | number)[]
) => unknown
```
#### Parameters
| Parameter | Type | Description |
|-----------|------|-------------|
| `key` | `string` | Property name, array index (as string), or empty string for root |
| `value` | `JsonValue` | The normalized value at this location |
| `path` | `readonly (string \| number)[]` | Path from root to current value |
#### Return Value
- Return the value unchanged to keep it
- Return a different value to replace it (will be normalized)
- Return `undefined` to omit properties/array elements
- For root value, `undefined` means "no change" (root cannot be omitted)
#### Examples
**Filtering sensitive data:**
```typescript
import { encode } from '@toon-format/toon'
const data = {
user: { name: 'Alice', password: 'secret123', email: 'alice@example.com' }
}
function replacer(key, value) {
if (key === 'password')
return undefined
return value
}
console.log(encode(data, { replacer }))
```
**Output:**
```yaml
user:
name: Alice
email: alice@example.com
```
**Transforming values:**
```typescript
const data = { user: 'alice', role: 'admin' }
function replacer(key, value) {
if (typeof value === 'string')
return value.toUpperCase()
return value
}
console.log(encode(data, { replacer }))
```
**Output:**
```yaml
user: ALICE
role: ADMIN
```
**Path-based transformations:**
```typescript
const data = {
metadata: { created: '2025-01-01' },
user: { created: '2025-01-02' }
}
function replacer(key, value, path) {
// Add timezone info only to top-level metadata
if (path.length === 1 && path[0] === 'metadata' && key === 'created') {
return `${value}T00:00:00Z`
}
return value
}
console.log(encode(data, { replacer }))
```
**Output:**
```yaml
metadata:
created: "2025-01-01T00:00:00Z"
user:
created: 2025-01-02
```
::: info Replacer Execution Order
The replacer is called in a depth-first manner:
1. Root value first (key = `''`, path = `[]`)
2. Then each property/element (with proper key and path)
3. Values are re-normalized after replacement
4. Children are processed after parent transformation
:::
::: warning Array Indices as Strings
Following `JSON.stringify` behavior, array indices are passed as strings (`'0'`, `'1'`, `'2'`, etc.) to the replacer, not as numbers.
:::
## Decoding Functions
### `decode(input, options?)`
Converts a TOON-formatted string back to JavaScript values.
```ts
import { decode } from '@toon-format/toon'
const data = decode(toon, {
indent: 2,
strict: true,
expandPaths: 'off'
})
```
#### Parameters
| Parameter | Type | Description |
|-----------|------|-------------|
| `input` | `string` | A TOON-formatted string to parse |
| `options` | `DecodeOptions?` | Optional decoding options (see [Configuration Reference](#configuration-reference)) |
#### Return Value
Returns a JavaScript value (object, array, or primitive) representing the parsed TOON data.
#### Example
```ts
import { decode } from '@toon-format/toon'
const toon = `
items[2]{sku,qty,price}:
A1,2,9.99
B2,1,14.5
`
const data = decode(toon)
console.log(data)
```
**Output:**
```json
{
"items": [
{ "sku": "A1", "qty": 2, "price": 9.99 },
{ "sku": "B2", "qty": 1, "price": 14.5 }
]
}
```
### `decodeFromLines(lines, options?)`
Decodes TOON format from pre-split lines into a JavaScript value. This is a streaming-friendly wrapper around the event-based decoder that builds the full value in memory.
Useful when you already have lines as an array or iterable (e.g., from file streams, readline interfaces, or network responses) and want the standard decode behavior with path expansion support.
#### Parameters
| Parameter | Type | Description |
|-----------|------|-------------|
| `lines` | `Iterable<string>` | Iterable of TOON lines (without trailing newlines) |
| `options` | `DecodeOptions?` | Optional decoding configuration (see [Configuration Reference](#configuration-reference)) |
#### Return Value
Returns a `JsonValue` (the parsed JavaScript value: object, array, or primitive).
#### Example
**Basic usage with arrays:**
```ts
import { decodeFromLines } from '@toon-format/toon'
const lines = ['name: Alice', 'age: 30']
const value = decodeFromLines(lines)
// { name: 'Alice', age: 30 }
```
**Streaming from Node.js readline:**
```ts
import { createReadStream } from 'node:fs'
import { createInterface } from 'node:readline'
import { decodeFromLines } from '@toon-format/toon'
const rl = createInterface({
input: createReadStream('data.toon'),
crlfDelay: Infinity,
})
const value = decodeFromLines(rl)
console.log(value)
```
**With path expansion:**
```ts
const lines = ['user.name: Alice', 'user.age: 30']
const value = decodeFromLines(lines, { expandPaths: 'safe' })
// { user: { name: 'Alice', age: 30 } }
```
### Choosing the Right Decoder
| Function | Input | Output | Async | Path Expansion | Use When |
|----------|-------|--------|-------|----------------|----------|
| `decode()` | String | Value | No | Yes | You have a complete TOON string |
| `decodeFromLines()` | Lines | Value | No | Yes | You have lines and want the full value |
| `decodeStreamSync()` | Lines | Events | No | No | You need event-by-event processing (sync) |
| `decodeStream()` | Lines | Events | Yes | No | You need event-by-event processing (async) |
::: info Key Differences
- **Value vs. Events**: Functions ending in `Stream` yield events without building the full value in memory.
- **Path expansion**: Only `decode()` and `decodeFromLines()` support `expandPaths: 'safe'`.
- **Async support**: Only `decodeStream()` accepts async iterables (useful for file/network streams).
:::
## Streaming Decoders
### `decodeStreamSync(lines, options?)`
Synchronously decodes TOON lines into a stream of JSON events. This function yields structured events that represent the JSON data model without building the full value tree.
Useful for streaming processing, custom transformations, or memory-efficient parsing of large datasets where you don't need the full value in memory.
::: tip Event Streaming
This is a low-level API that returns individual parse events. For most use cases, [`decodeFromLines()`](#decodefromlines-lines-options) or [`decode()`](#decode-input-options) are more convenient.
Path expansion (`expandPaths: 'safe'`) is **not supported** in streaming mode since it requires the full value tree.
:::
#### Parameters
| Parameter | Type | Description |
|-----------|------|-------------|
| `lines` | `Iterable<string>` | Iterable of TOON lines (without trailing newlines) |
| `options` | `DecodeStreamOptions?` | Optional streaming decoding configuration (see [Configuration Reference](#configuration-reference)) |
#### Return Value
Returns an `Iterable<JsonStreamEvent>` that yields structured events (see [TypeScript Types](#typescript-types) for event structure).
#### Example
**Basic event streaming:**
```ts
import { decodeStreamSync } from '@toon-format/toon'
const lines = ['name: Alice', 'age: 30']
for (const event of decodeStreamSync(lines)) {
console.log(event)
}
// Output:
// { type: 'startObject' }
// { type: 'key', key: 'name' }
// { type: 'primitive', value: 'Alice' }
// { type: 'key', key: 'age' }
// { type: 'primitive', value: 30 }
// { type: 'endObject' }
```
**Custom processing:**
```ts
import { decodeStreamSync } from '@toon-format/toon'
const lines = ['users[2]{id,name}:', ' 1,Alice', ' 2,Bob']
let userCount = 0
for (const event of decodeStreamSync(lines)) {
if (event.type === 'endObject' && userCount < 2) {
userCount++
console.log(`Processed user ${userCount}`)
}
}
```
### `decodeStream(source, options?)`
Asynchronously decodes TOON lines into a stream of JSON events. This is the async version of [`decodeStreamSync()`](#decodestreamsync-lines-options), supporting both synchronous and asynchronous iterables.
Useful for processing file streams, network responses, or other async sources where you want to handle data incrementally as it arrives.
#### Parameters
| Parameter | Type | Description |
|-----------|------|-------------|
| `source` | `AsyncIterable<string>` \| `Iterable<string>` | Async or sync iterable of TOON lines (without trailing newlines) |
| `options` | `DecodeStreamOptions?` | Optional streaming decoding configuration (see [Configuration Reference](#configuration-reference)) |
#### Return Value
Returns an `AsyncIterable<JsonStreamEvent>` that yields structured events asynchronously (see [TypeScript Types](#typescript-types) for event structure).
#### Example
**Streaming from file:**
```ts
import { createReadStream } from 'node:fs'
import { createInterface } from 'node:readline'
import { decodeStream } from '@toon-format/toon'
const fileStream = createReadStream('data.toon', 'utf-8')
const rl = createInterface({ input: fileStream, crlfDelay: Infinity })
for await (const event of decodeStream(rl)) {
console.log(event)
// Process events as they arrive
}
```
## Error Handling
Decoding throws a `ToonDecodeError` when input cannot be parsed. The class extends `SyntaxError`, so existing `error instanceof SyntaxError` checks keep working without code changes.
### `ToonDecodeError`
```ts
import { ToonDecodeError } from '@toon-format/toon'
```
#### Fields
| Field | Type | Description |
|-------|------|-------------|
| `name` | `'ToonDecodeError'` | Discriminator `error.name === 'ToonDecodeError'` |
| `message` | `string` | Human-readable message; prefixed with `Line N: ` when a line is known |
| `line` | `number?` | 1-based line number where the error was detected |
| `source` | `string?` | Raw source line (including its leading whitespace) |
| `cause` | `unknown?` | The original error when the decoder enriched a lower-level parser failure |
The `line` and `source` fields are populated for every error that has line context essentially every parse error during normal decoding. The `cause` chain points back to the underlying `SyntaxError` or `TypeError` thrown by the token-level parser, so debuggers and verbose loggers can show the original frame.
#### Example
```ts
import { decode, ToonDecodeError } from '@toon-format/toon'
try {
decode('a:\n\tb: 1')
}
catch (error) {
if (error instanceof ToonDecodeError) {
console.error(`Line ${error.line}:`, error.source)
console.error(error.message)
// Line 2: b: 1
// Line 2: Tabs are not allowed in indentation in strict mode
}
else {
throw error
}
}
```
::: info Backwards Compatibility
`ToonDecodeError` extends `SyntaxError`. Code written against earlier versions that catches `SyntaxError` continues to match these errors. The class adds structured fields without removing anything.
:::
## Configuration Reference
### `EncodeOptions`
Configuration for [`encode()`](#encode-input-options) and [`encodeLines()`](#encodelines-input-options):
| Option | Type | Default | Description |
|--------|------|---------|-------------|
| `indent` | `number` | `2` | Number of spaces per indentation level |
| `delimiter` | `','` \| `'\t'` \| `'\|'` | `','` | Delimiter for array values and tabular rows |
| `keyFolding` | `'off'` \| `'safe'` | `'off'` | Enable key folding to collapse single-key wrapper chains into dotted paths |
| `flattenDepth` | `number` | `Infinity` | Maximum number of segments to fold when `keyFolding` is enabled (values 0-1 have no practical effect) |
| `replacer` | `EncodeReplacer` | `undefined` | Optional hook to transform or omit values before encoding (see [Replacer Function](#replacer-function)) |
**Delimiter options:**
::: code-group
```ts [Comma (default)]
encode(data, { delimiter: ',' })
```
```ts [Tab]
encode(data, { delimiter: '\t' })
```
```ts [Pipe]
encode(data, { delimiter: '|' })
```
:::
See [Delimiter Strategies](#delimiter-strategies) for guidance on choosing delimiters.
### `DecodeOptions`
Configuration for [`decode()`](#decode-input-options) and [`decodeFromLines()`](#decodefromlines-lines-options):
| Option | Type | Default | Description |
|--------|------|---------|-------------|
| `indent` | `number` | `2` | Expected number of spaces per indentation level |
| `strict` | `boolean` | `true` | Enable strict validation (array counts, indentation, delimiter consistency) |
| `expandPaths` | `'off'` \| `'safe'` | `'off'` | Enable path expansion to reconstruct dotted keys into nested objects (pairs with `keyFolding: 'safe'`) |
By default (`strict: true`), the decoder validates input strictly:
- **Invalid escape sequences**: Throws on `\x`, unterminated strings, lone-surrogate `\uXXXX`
- **Syntax errors**: Throws on missing colons, malformed headers
- **Array length mismatches**: Throws when declared length doesn't match actual count
- **Header delimiter mismatch**: Throws when the bracket-declared delimiter differs from the field-list delimiter (§14.2)
- **Indentation errors**: Throws when leading spaces aren't exact multiples of `indent`
- **Header structure**: Throws on leading-zero or non-integer array lengths and on intervening content between bracket/fields/colon
- **Duplicate sibling keys**: Throws when an object has two children with the same key (§14.4)
- **Path-expansion conflicts**: When `expandPaths: 'safe'` is set, throws on overlapping dotted paths that would collide
All decode errors are thrown as [`ToonDecodeError`](#error-handling) instances with structured `line` and `source` fields.
Set `strict: false` to skip these checks. Duplicate sibling keys and path-expansion conflicts then resolve with last-write-wins in document order.
See [Key Folding & Path Expansion](#key-folding-path-expansion) for more details on path expansion behavior and conflict resolution.
### `DecodeStreamOptions`
Configuration for [`decodeStreamSync()`](#decodestreamsync-lines-options) and [`decodeStream()`](#decodestream-source-options):
| Option | Type | Default | Description |
|--------|------|---------|-------------|
| `indent` | `number` | `2` | Expected number of spaces per indentation level |
| `strict` | `boolean` | `true` | Enable strict validation (array counts, indentation, delimiter consistency) |
::: warning Path Expansion Not Supported
Path expansion requires building the full value tree, which is incompatible with event streaming. Use [`decodeFromLines()`](#decodefromlines-lines-options) if you need path expansion.
:::
## TypeScript Types
### `JsonStreamEvent`
Events emitted by [`decodeStreamSync()`](#decodestreamsync-lines-options) and [`decodeStream()`](#decodestream-source-options):
```ts
type JsonStreamEvent
= | { type: 'startObject' }
| { type: 'endObject' }
| { type: 'startArray', length: number }
| { type: 'endArray' }
| { type: 'key', key: string, wasQuoted?: boolean }
| { type: 'primitive', value: JsonPrimitive }
```
### Delimiters
```ts
import { DEFAULT_DELIMITER, DELIMITERS } from '@toon-format/toon'
DEFAULT_DELIMITER // ','
DELIMITERS // { comma: ',', tab: '\t', pipe: '|' }
```
| Export | Description |
|--------|-------------|
| `DEFAULT_DELIMITER` | The default delimiter character (`,`) used when none is specified |
| `DELIMITERS` | Frozen record mapping delimiter names to their characters |
| `Delimiter` | Type union of valid delimiter characters: `',' \| '\t' \| '\|'` |
| `DelimiterKey` | Type union of delimiter names: `'comma' \| 'tab' \| 'pipe'` |
### Option Types
| Export | Description |
|--------|-------------|
| `EncodeOptions` | Options accepted by [`encode()`](#encode-input-options) and [`encodeLines()`](#encodelines-input-options) |
| `DecodeOptions` | Options accepted by [`decode()`](#decode-input-options) and [`decodeFromLines()`](#decodefromlines-lines-options) |
| `DecodeStreamOptions` | Options accepted by [`decodeStreamSync()`](#decodestreamsync-lines-options) and [`decodeStream()`](#decodestream-source-options) |
| `EncodeReplacer` | Signature of the [replacer function](#replacer-function) |
| `ResolvedEncodeOptions` | `EncodeOptions` after defaults are applied (advanced) |
| `ResolvedDecodeOptions` | `DecodeOptions` after defaults are applied (advanced) |
## Guides & Examples
### Round-Trip Compatibility
TOON provides lossless round-trips after normalization:
```ts
import { decode, encode } from '@toon-format/toon'
const original = {
users: [
{ id: 1, name: 'Alice', role: 'admin' },
{ id: 2, name: 'Bob', role: 'user' }
]
}
const toon = encode(original)
const restored = decode(toon)
console.log(JSON.stringify(original) === JSON.stringify(restored))
// true
```
**With Key Folding:**
```ts
import { decode, encode } from '@toon-format/toon'
const original = { data: { metadata: { items: ['a', 'b'] } } }
// Encode with folding
const toon = encode(original, { keyFolding: 'safe' })
// → "data.metadata.items[2]: a,b"
// Decode with expansion
const restored = decode(toon, { expandPaths: 'safe' })
// → { data: { metadata: { items: ['a', 'b'] } } }
console.log(JSON.stringify(original) === JSON.stringify(restored))
// true
```
### Key Folding & Path Expansion
**Key Folding** (`keyFolding: 'safe'`) collapses single-key wrapper chains during encoding:
```ts
import { encode } from '@toon-format/toon'
const data = { data: { metadata: { items: ['a', 'b'] } } }
// Without folding
encode(data)
// data:
// metadata:
// items[2]: a,b
// With folding
encode(data, { keyFolding: 'safe' })
// data.metadata.items[2]: a,b
```
**Path Expansion** (`expandPaths: 'safe'`) reverses this during decoding:
```ts
import { decode } from '@toon-format/toon'
const toon = 'data.metadata.items[2]: a,b'
const data = decode(toon, { expandPaths: 'safe' })
console.log(data)
// { data: { metadata: { items: ['a', 'b'] } } }
```
**Expansion Conflict Resolution:**
When multiple expanded keys construct overlapping paths, the decoder merges them recursively:
- **Object + Object**: Deep merge recursively
- **Object + Non-object** (array or primitive): Conflict
- With `strict: true` (default): Error
- With `strict: false`: Last-write-wins (LWW)
Duplicate sibling keys (independent of `expandPaths`) follow the same policy: strict mode throws, lenient mode keeps the last value seen.
### Delimiter Strategies
Tab delimiters (`\t`) often tokenize more efficiently than commas. Tabs are single characters that rarely appear in natural text, which reduces the need for quote-escaping and leads to smaller token counts in large datasets.
Example:
```yaml
items[2 ]{sku name qty price}:
A1 Widget 2 9.99
B2 Gadget 1 14.5
```
For maximum token savings on large tabular data, combine tab delimiters with key folding:
```ts
encode(data, { delimiter: '\t', keyFolding: 'safe' })
```
**Choosing a Delimiter:**
- **Comma (`,`)**: Default, widely understood, good for simple tabular data.
- **Tab (`\t`)**: Best for LLM token efficiency, excellent for large datasets.
- **Pipe (`|`)**: Alternative when commas appear frequently in data.
+509
View File
@@ -0,0 +1,509 @@
---
description: Mathematical model of TOON's byte-level overhead vs JSON across structure families, with formulas and worked examples.
---
# TOON vs JSON: Byte-Level Efficiency Model
A mathematical analysis of TOON's byte efficiency compared to JSON across different data structures.
::: info Scope of This Document
This page presents a theoretical, character-based comparison between TOON and JSON. For practical benchmarks and token counts, see [Benchmarks](/guide/benchmarks). It is an **advanced, non-normative** reference: it explains TOON's design from a mathematical angle but does not change the TOON specification.
:::
## Overview
Standard JSON introduces structural verbosity that inflates token usage and inference cost. This page formalises a byte-level comparison between TOON and JSON to evaluate whether TOON achieves quantifiable efficiency gains by removing structural redundancy.
Under the assumptions described below (compact JSON, canonical TOON, ASCII keys and punctuation, shallow to moderate nesting, and mostly unquoted TOON strings), TOON's **structural overhead is lower than compact JSON** for the structure families analyzed here, except arrays of arrays.
### Key Findings
- **Tabular arrays** represent TOON's optimal use case, with efficiency gains scaling linearly with both row count and field count.
- **Simple objects and primitive arrays** show consistent byte reduction, with savings proportional to the number of fields or elements.
- **Nested objects** benefit from reduced overhead, though efficiency decreases with depth due to indentation costs; at sufficient depth, compact JSON can become smaller.
- **Arrays of arrays** are the only structure where TOON is less efficient than JSON in this analysis, due to TOON's explicit list markers and inner array headers.
## Methodology
We define recursive byte-length functions $L_{\text{json}}$ and $L_{\text{toon}}$ for both formats, then derive the efficiency delta:
$$
\Delta = L_{\text{json}}(\Omega) - L_{\text{toon}}(\Omega)
$$
Where $\Omega$ represents the data structure under comparison. If $\Delta > 0$, TOON uses fewer bytes than JSON for that structure.
::: info Scope & Assumptions
- **Compact JSON**: JSON is assumed to be compact (no spaces or newlines outside strings). Byte counts are computed on this compact form.
- **Canonical TOON**: TOON is assumed to follow canonical formatting (indent = 2 spaces, exactly one space after `:`, no spaces after commas in arrays/field lists, no trailing spaces).
- **Keys and strings**: All keys are "simple" ASCII identifier-style keys that:
- must be quoted in JSON, and
- can be left unquoted in TOON (no characters that would force quoting).
Many examples assume values are numbers, booleans, null, or TOON-safe strings that can be unquoted in TOON but must be quoted in JSON.
- **Numbers**: For this analysis only, both formats are assumed to use the same canonical decimal representation. JSON could use exponent forms; we ignore that here to isolate structural differences.
- **ASCII/UTF-8**: Keys and structural tokens are assumed ASCII, so byte length equals character count ($|x|_{\text{utf8}} = |x|_{\text{char}}$). Non-ASCII content affects both formats similarly and does not change the structural conclusions.
- **Nesting depth**: Closed-form expressions are given for flat structures and a single level of nesting. Each additional nesting level in TOON adds 2 bytes of indentation per nested line. At sufficient depth, the braces of compact JSON can win over TOON's indentation (as seen in [When Not to Use TOON](/guide/getting-started#when-not-to-use-toon)).
- **Byte vs token count**: Modern LLM tokenizers operate over UTF-8 bytes, so byte length is a good upper bound and first-order proxy for token count, even though the mapping is not exactly linear.
:::
Think of this as a simplified structural model: we strip away real-world noise and ask, "if you only count structural characters, how do JSON and TOON compare?"
## Formal Notation
### Data Model
Let $\omega$ be a primitive value such that $\omega \in \{\text{string, number, boolean, null}\}$.
Let $\mathcal{O}$ be an object composed of $n$ key-value pairs:
$$
\mathcal{O} = \{(k_1, v_1), (k_2, v_2), \dots, (k_n, v_n)\}
$$
Let $\mathcal{A}$ be an array composed of $n$ elements:
$$
\mathcal{A} = \{v_1, v_2, \dots, v_n\}
$$
Where:
- $k_i$ is a key (string)
- $v_i$ can be a primitive value $\omega$, an object $\mathcal{O}$, or an array $\mathcal{A}$
Therefore: $v_i \in \{\omega, \mathcal{O}, \mathcal{A}\}$
### String Length
Let $\mathcal{S}$ be the set of valid Unicode strings. For any string $x \in \mathcal{S}$, we denote $|x|_{\text{utf8}}$ as the byte-length of $x$ under UTF-8 encoding.
### Integer Length
Let $n \in \mathbb{Z}_{\ge 0}$ be a non-negative integer. The number of bytes required to represent $n$ in decimal format is:
$$
L_{\text{num}}(n) = \begin{cases}
1 & \text{if } n = 0 \\
\lfloor \log_{10}(|n|) \rfloor + 1 & \text{if } n > 0
\end{cases}
$$
## JSON Size Functions
For a flat object of $n$ keys:
$$
L_{\text{json}}(\mathcal{O}) = \underbrace{2}_{\{\}} + \sum_{i=1}^{n} (L_{\text{str}}(k_i) + \underbrace{1}_{:} + L_{\text{json}}(v_i)) + \underbrace{(n-1)}_{\text{commas}}
$$
Where $L_{\text{str}}(k)$ is the length of the key including its mandatory quotes:
$$
L_{\text{str}}(k) = |k|_{\text{utf8}} + \underbrace{2}_{\text{quotes}}
$$
### Primitive Values in JSON
When $v_i$ is a primitive data type $\omega$:
| Type | Formula |
|------|---------|
| String | $L_{\text{str}}(v_i) = \lvert v_i\rvert_{\text{utf8}} + 2$ |
| Number | $L_{\text{num}}(v_i) = \lvert v_i\rvert_{\text{utf8}}$ |
| Boolean | $L_{\text{bool}}(v_i) = \lvert v_i\rvert_{\text{utf8}}$ |
| Null | $L_{\text{null}}(v_i) = \lvert v_i\rvert_{\text{utf8}}$ |
### Arrays in JSON
When $v_i$ is an array $\mathcal{A}$:
$$
L_{\text{json}}(\mathcal{A}) = \underbrace{2}_{\text{[]}} + \sum_{i=1}^{n} L_{\text{json}}(v_i) + \underbrace{(n-1)}_{\text{commas}}
$$
## TOON Size Functions
For a flat object of $n$ keys:
$$
L_{\text{toon}}(\mathcal{O}) = \sum_{i=1}^{n} (L_{\text{str}}(k_i) + \underbrace{1}_{:} + \underbrace{1}_{\text{space}} + L_{\text{toon}}(v_i)) + \underbrace{(n-1)}_{\text{newlines}}
$$
Where $L_{\text{str}}(k)$ is the length of the key (no quotes required for simple keys):
$$
L_{\text{str}}(k) = |k|_{\text{utf8}}
$$
### Primitive Values in TOON
When $v_i$ is a primitive data type $\omega$:
| Type | Formula |
|------|---------|
| String (normal) | $L_{\text{str}}(v_i) = \lvert v_i\rvert_{\text{utf8}}$ |
| String (looks like number/boolean) | $L_{\text{str}}(v_i) = \lvert v_i\rvert_{\text{utf8}} + 2$ |
| Number | $L_{\text{num}}(v_i) = \lvert v_i\rvert_{\text{utf8}}$ |
| Boolean | $L_{\text{bool}}(v_i) = \lvert v_i\rvert_{\text{utf8}}$ |
| Null | $L_{\text{null}}(v_i) = \lvert v_i\rvert_{\text{utf8}}$ |
### Simple Arrays in TOON
Here $L_{\text{toon}}(\mathcal{A})$ refers to the length of the whole field line `key[N]: ...`, not just the array value.
When $v_i$ is a simple array $\mathcal{A}$:
$$
L_{\text{toon}}(\mathcal{A}) = L_{\text{str}}(k_i) + \underbrace{1}_{\text{[}} + L_{\text{num}}(n) + \underbrace{1}_{\text{]}} + \underbrace{1}_{:} + \underbrace{1}_{\text{space}} + \sum_{i=1}^{n} L_{\text{toon}}(v_i) + \underbrace{(n-1)}_{\text{commas}}
$$
### Tabular Arrays in TOON
When $v_i$ is an array of objects with $m$ fields:
$$
\begin{split}
L_{\text{toon}}(\mathcal{A}') = L_{\text{str}}(k_i) + \underbrace{1}_{\text{[}} + L_{\text{num}}(n) + \underbrace{1}_{\text{]}} + \underbrace{1}_{\{} + \\
\sum_{i=1}^{m} L_{\text{str}}(k_i) + \underbrace{(m-1)}_{\text{commas}} + \underbrace{1}_{\}} + \underbrace{1}_{:} + \\
\underbrace{2n}_{\text{indents}} + \sum_{i=1}^{n}\sum_{j=1}^{m} L_{\text{toon}}(v_{ij}) + \underbrace{(m-1)n}_{\text{commas}} + \underbrace{n}_{\text{newlines}}
\end{split}
$$
*Note: The term $2n$ assumes an indentation size of 2 spaces.*
## Efficiency Analysis by Structure
Each subsection below focuses on a particular structure family, states the resulting formula, and shows a small example. Intuitively, TOON tends to win when it can:
- avoid repeating keys (tabular arrays),
- avoid quoting keys and many values,
- and replace braces with indentation,
and tends to lose when it pays a fixed overhead per element (arrays of arrays) or deep indentation (heavily nested configs).
### Simple Objects
Flat objects with primitive string values are the easiest win: JSON pays for braces and quoted keys and strings, while TOON drops braces at the root, omits quotes on simple keys, and uses one line per field.
For objects with only string primitives:
$$
\Delta_{\text{obj}} = 2 + n + \sum_{i=1}^{n}(L_{\text{json}}(v_i)) - \sum_{i=1}^{n}(L_{\text{toon}}(v_i))
$$
If all values are strings that can be unquoted in TOON, this simplifies to:
$$
f(n) = 2 + 3n
$$
**Example:** For 1,000,000 objects, TOON saves **3,000,002 bytes ≈ 2.86 MB**.
#### Empirical Validation
::: code-group
```json [JSON (21 bytes)]
{ "id": 1, "name": "Ada" }
```
```yaml [TOON (15 bytes)]
id: 1
name: Ada
```
:::
$$
\Delta_{\text{obj}} = 2 + \underbrace{2}_{n} + \underbrace{6}_{\sum L_{\text{json}}(v_i)} - \underbrace{4}_{\sum L_{\text{toon}}(v_i)} = 6
$$
### Nested Objects
Adding a wrapper object (one extra level of nesting) introduces extra braces for JSON and extra indentation and newlines for TOON. For a single level of nesting with primitive values, TOON still comes out ahead, but the net advantage is smaller.
For a single level of nesting with primitives:
$$
f(n) = 5 + n
$$
**Example:** For 1,000,000 nested objects (depth 1), TOON saves **1,000,005 bytes ≈ 0.95 MB**.
::: warning Caveat
This formula is for a single nesting level. Each additional nesting level adds 2 spaces of indentation per nested line; at sufficient depth, compact JSON can become smaller, especially when tabular opportunities disappear (see [When Not to Use TOON](/guide/getting-started#when-not-to-use-toon) and the "Deeply nested configuration" dataset in [Benchmarks](/guide/benchmarks)).
:::
#### Empirical Validation
::: code-group
```json [JSON (30 bytes)]
{ "user": { "id": 1, "name": "Ada" } }
```
```yaml [TOON (25 bytes)]
user:
id: 1
name: Ada
```
:::
$$
\Delta_{\text{nested}} = 5
$$
### Primitive Arrays
For arrays of string primitives, JSON writes `["foo","bar","baz"]`, quoting every string and using `[]` for the array. TOON writes `key[N]: foo,bar,baz`, paying once for the length marker but omitting most quotes.
For arrays of $n$ string primitives:
$$
\Delta_{\text{arr}} = 3 - L_{\text{num}}(n) + \sum_{i=1}^{n}(L_{\text{json}}(v_i)) - \sum_{i=1}^{n}(L_{\text{toon}}(v_i))
$$
With string values that can be unquoted in TOON, this simplifies to:
$$
f(n) = 2 + 2n - \lfloor \log_{10}(|n|) \rfloor
$$
**Example:** For 1,000,000 elements, TOON saves **1,999,996 bytes ≈ 1.91 MB**.
#### Empirical Validation
::: code-group
```json [JSON (28 bytes)]
{ "tags": ["foo", "bar", "baz"] }
```
```yaml [TOON (20 bytes)]
tags[3]: foo,bar,baz
```
:::
$$
\Delta_{\text{arr}} = 3 - \underbrace{1}_{L_{\text{num}}(3)} + \underbrace{15}_{\sum L_{\text{json}}} - \underbrace{9}_{\sum L_{\text{toon}}} = 8
$$
### Root Arrays
At the root, JSON writes `["x","y","z"]`; TOON writes `[3]: x,y,z`. There is no object key cost, so the advantage mainly comes from not quoting TOON-safe strings and from replacing `[]` with `[N]:`.
For root-level arrays of $n$ string primitives:
$$
f(n) = -3 + 2n - \lfloor \log_{10}(|n|) \rfloor
$$
**Example:** For 1,000,000 elements, TOON saves **1,999,991 bytes ≈ 1.91 MB**.
#### Empirical Validation
::: code-group
```json [JSON (13 bytes)]
["x", "y", "z"]
```
```yaml [TOON (10 bytes)]
[3]: x,y,z
```
:::
$$
\Delta_{\text{root}} = \underbrace{9}_{\sum L_{\text{json}}} - 2 - \underbrace{1}_{L_{\text{num}}(3)} - \underbrace{3}_{\sum L_{\text{toon}}} = 3
$$
### Tabular Arrays
Uniform arrays of objects are TOON's sweet spot. JSON repeats every key for every row, while TOON declares the length and column names once (`key[N]{id,qty,...}:`) and streams rows as bare values.
For arrays of objects with $n$ rows and $m$ fields, assuming numeric values and $|k| = 3$:
$$
f(n) = 1 + nm(3 + |k|) - m(1 + |k|) - \lfloor \log_{10}(|n|) \rfloor
$$
**Example:** For 1,000,000 rows with 2 fields and 3-character field names, TOON saves **11,999,987 bytes ≈ 11.44 MB**.
This is where TOON's design (declare fields once, stream rows) pays off most strongly: savings grow linearly with both row count and field count.
#### Empirical Validation
::: code-group
```json [JSON (45 bytes)]
{ "items": [{ "id": 1, "qty": 5 }, { "id": 2, "qty": 3 }] }
```
```yaml [TOON (29 bytes)]
items[2]{id,qty}:
1,5
2,3
```
:::
$$
\Delta_{\text{tab}} = 2 + \underbrace{4}_{nm} - \underbrace{2}_{m} + \underbrace{22}_{\Sigma L_{\text{json}}} - \underbrace{1}_{L_{\text{num}}(n)} - \underbrace{5}_{\Sigma L_{\text{toon}}(k)} - \underbrace{4}_{\Sigma L_{\text{toon}}(v)} = 16
$$
### Arrays of Arrays
Arrays of arrays of primitives are where TOON structurally loses: each inner array becomes a list item with its own header, so TOON pays a fixed overhead per inner array (`"- "` plus `"[m]: "`), while JSON just uses commas.
::: info Practical Note
For arrays of arrays of primitives, this model predicts that JSON is more byte-efficient than TOON, because TOON pays ~6 extra bytes per inner array (2 for `"- "`, 4 for `"[m]: "`), plus the length marker.
:::
For arrays of arrays with $n$ outer elements and $m$ inner elements:
$$
\begin{split}
\Delta_{\text{arrarr}} = 2 - 6n - \sum_{i=1}^{n}\sum_{j=1}^{m} L_{\text{num}}(m) + \\
\sum_{i=1}^{n}\sum_{j=1}^{m} L_{\text{json}}(v_{ij}) - \sum_{i=1}^{n}\sum_{j=1}^{m} L_{\text{toon}}(v_{ij})
\end{split}
$$
With string primitives and $m = 2$:
$$
f(n) = 2 - 6n - \sum_{i=1}^{n}\sum_{j=1}^{m} (\lfloor \log_{10}(|m|) \rfloor + 1) + 2nm
$$
**Example:** For 1,000,000 arrays with $m = 2$, TOON **wastes 2,999,998 bytes ≈ 2.86 MB** relative to JSON under this model.
#### Empirical Validation
::: code-group
```json [JSON (23 bytes)]
{ "pairs": [[1, 2], [3, 4]] }
```
```yaml [TOON (35 bytes)]
pairs[2]:
- [2]: 1,2
- [2]: 3,4
```
:::
$$
\Delta_{\text{arrarr}} = 2 - \underbrace{12}_{6n} - \underbrace{2}_{\sum L_{\text{num}}(m)} + \underbrace{4}_{\sum L_{\text{json}}} - \underbrace{4}_{\sum L_{\text{toon}}} = -12
$$
### Strings That Look Like Literals
Strings that look like numbers or booleans (e.g. `"123"`, `"true"`) must be quoted in both JSON and TOON, slightly reducing TOON's advantage because it no longer saves quotes on those values.
For objects containing such strings:
$$
\Delta_{\text{strlit}} = 2 + n
$$
**Example:** For 1,000,000 objects, TOON saves **2,000,002 bytes ≈ 1.91 MB**.
#### Empirical Validation
::: code-group
```json [JSON (34 bytes)]
{ "version": "123", "enabled": "true" }
```
```yaml [TOON (30 bytes)]
version: "123"
enabled: "true"
```
:::
$$
\Delta_{\text{str}} = 2 + \underbrace{2}_{n} = 4
$$
### Empty Structures
Empty containers reveal structural differences even at minimal sizes.
**Empty Object:**
$$
\Delta_{\text{EmptyObject}} = 2
$$
JSON requires `{}` (2 bytes), whereas a completely empty root object in TOON is represented as an empty document (0 bytes).
**Empty Array (field):**
$$
\Delta_{\text{EmptyArray}} = 3
$$
For a field named `key`, JSON uses `{"key":[]}` in compact form, while TOON uses:
```yaml
key: []
```
Under this model, that yields a constant 3-byte advantage for TOON. The legacy `key[0]:` form remains decodable for backward compatibility.
## Summary Table
The table below summarizes the formulas and which side wins under the modeling assumptions.
| Structure | Efficiency Formula | TOON Advantage? |
|-----------|-------------------|-----------------|
| Simple Objects | $f(n) = 2 + 3n$ | ✅ Yes |
| Nested Objects (1 level) | $f(n) = 5 + n$ | ✅ Yes (shrinks with depth) |
| Primitive Arrays | $f(n) = 2 + 2n - \lfloor \log_{10}(n) \rfloor$ | ✅ Yes |
| Root Arrays | $f(n) = -3 + 2n - \lfloor \log_{10}(n) \rfloor$ | ✅ Yes |
| Tabular Arrays | $f(n) = 1 + nm(3+\lvert k\rvert) - m(1+\lvert k\rvert) - \lfloor \log_{10}(n) \rfloor$ | ✅ **Best case** |
| Arrays of Arrays | $f(n) = 2 - 6n + 2nm - \text{overhead}$ | ❌ JSON wins here |
| String Literals | $f(n) = 2 + n$ | ✅ Yes (smaller gain) |
| Empty Structures | $\Delta = 2$ or $3$ | ✅ Yes |
In short:
- TOON's gains are **linear in the number of fields** for flat objects.
- For arrays, gains grow **linearly in the number of elements**, and for tabular arrays **linearly in both rows and fields**.
- Arrays of arrays are the main structural case where JSON is smaller.
- Deep nesting and heavy quoting can erode or reverse these advantages in real data.
## Conclusion
This simplified theoretical model supports TOON's design goal: structurally, it reduces overhead compared to compact JSON in many common patterns by:
- avoiding repeated keys in tabular arrays,
- omitting quotes on many keys and values,
- and replacing braces with indentation at shallow depths.
For the structure families examined here and under the stated assumptions, the structural overhead of TOON is lower than that of compact JSON except for arrays of arrays. Since UTF-8 byte length is a reasonable first-order proxy for tokens, these structural savings usually translate into lower token counts in those patterns.
At the same time, this is deliberately a simplified model. In real datasets, additional factors deeper or irregular nesting, heavily quoted strings, exponent notation in JSON, and tokenizer idiosyncrasies can reduce or even reverse these gains. Our [Benchmarks](/guide/benchmarks) and [When Not to Use TOON](/guide/getting-started#when-not-to-use-toon) show that compact JSON can be more efficient for deeply nested or low-tabularity data. Use this page as intuition for *why* TOON behaves the way it does, not as a universal guarantee.
## Related Resources
- [Benchmarks](/guide/benchmarks) Empirical token count and accuracy comparisons across formats
- [Specification](/reference/spec) Formal TOON specification
## References
This analysis is based on:
- **Original Research**: [TOON vs. JSON: A Mathematical Evaluation of Byte Efficiency in Structured Data](https://www.researchgate.net/publication/397903673_TOON_vs_JSON_A_Mathematical_Evaluation_of_Byte_Efficiency_in_Structured_Data)
- **TOON Specification**: [toon-format/spec](https://github.com/toon-format/spec)
- **JSON Specification**: [RFC 8259](https://datatracker.ietf.org/doc/html/rfc8259), [ECMA-404](https://www.ecma-international.org/publications-and-standards/standards/ecma-404/)
---
This page was contributed by Mateo Lafalce ([@mateolafalce](https://github.com/mateolafalce)).
*Have questions or found an error in the formalization? Open an issue on [GitHub](https://github.com/toon-format/spec) or contribute improvements to this analysis.*
+175
View File
@@ -0,0 +1,175 @@
---
description: Guided tour of the TOON specification sections, conformance checklists, media type, and versioning.
---
# Specification
The [TOON specification](https://github.com/toon-format/spec) is the authoritative reference for implementing encoders, decoders, and validators. It defines the concrete syntax, normative encoding/decoding behavior, and strict-mode validation rules.
You don't need this page to *use* TOON. It's mainly for implementers and contributors. If you're looking to learn how to use TOON, start with the [Getting Started](/guide/getting-started) guide instead.
> [!NOTE]
> The TOON specification is stable, but also an idea in progress. Nothing's set in stone help shape where it goes by contributing to it or sharing feedback.
## Current Version
**Spec v{{ $spec.version }}** (2026-05-20) is the current published Working Draft. It is stable for implementation but not yet finalized; see "Status of This Document" in the spec for details.
## Media Type & File Extension
The spec defines a provisional media type and file extension in [§17](https://github.com/toon-format/spec/blob/main/SPEC.md#17-iana-considerations):
- **Media type:** `text/toon` (provisional, not yet IANAregistered; UTF8 only)
- **File extension:** `.toon`
TOON documents are always UTF8 with LF (`\n`) line endings; the optional `charset` parameter, when present, is `utf-8`.
## Guided Tour of the Spec
### Core Concepts
[§1 Terminology and Conventions](https://github.com/toon-format/spec/blob/main/SPEC.md#1-terminology-and-conventions):
Defines key terms like "indentation level", "active delimiter", "strict mode", and RFC2119 keywords (MUST, SHOULD, MAY).
[§2 Data Model](https://github.com/toon-format/spec/blob/main/SPEC.md#2-data-model):
Specifies the JSON data model (objects, arrays, primitives), array/object ordering requirements, and canonical number formatting (canonical decimal for values in `[1e-6, 1e21)` or zero; exponent form permitted outside).
[§3 Encoding Normalization](https://github.com/toon-format/spec/blob/main/SPEC.md#3-encoding-normalization-reference-encoder):
Defines how non-JSON types (Date, BigInt, NaN, Infinity, undefined, etc.) are normalized before encoding. Required reading for encoder implementers.
[§4 Decoding Interpretation](https://github.com/toon-format/spec/blob/main/SPEC.md#4-decoding-interpretation-reference-decoder):
Specifies how decoders map text tokens to host values (quoted strings, unquoted primitives, numeric parsing with leading-zero handling). Decoders default to strict mode (`strict = true`) in the reference implementation; strict-mode errors are enumerated in §14.
### Syntax Rules
[§5 Concrete Syntax and Root Form](https://github.com/toon-format/spec/blob/main/SPEC.md#5-concrete-syntax-and-root-form):
Defines TOON's line-oriented, indentation-based notation and how to determine whether the root is an object, array, or primitive.
[§6 Header Syntax](https://github.com/toon-format/spec/blob/main/SPEC.md#6-header-syntax-normative):
Normative ABNF grammar for array headers: `key[N<delim?>]{fields}:`. Specifies bracket segments, delimiter symbols, and field lists.
[§7 Strings and Keys](https://github.com/toon-format/spec/blob/main/SPEC.md#7-strings-and-keys):
Complete quoting rules (when strings MUST be quoted), escape sequences (only `\\`, `\"`, `\n`, `\r`, `\t`, and `\uXXXX` for other U+0000U+001F controls are valid), and key encoding requirements.
[§8 Objects](https://github.com/toon-format/spec/blob/main/SPEC.md#8-objects):
Object field encoding (key: value), nesting rules, key order preservation, and empty object handling.
[§9 Arrays](https://github.com/toon-format/spec/blob/main/SPEC.md#9-arrays):
Covers all array forms: primitive (inline), arrays of objects (tabular), mixed/non-uniform (list), and arrays of arrays. Includes tabular detection requirements.
[§10 Objects as List Items](https://github.com/toon-format/spec/blob/main/SPEC.md#10-objects-as-list-items):
Indentation rules for objects appearing in list items (first field on the hyphen line), including the canonical pattern when the first field is a tabular array (header on the hyphen line, rows at depth +2, sibling fields at depth +1).
[§11 Delimiters](https://github.com/toon-format/spec/blob/main/SPEC.md#11-delimiters):
Delimiter scoping (document vs active), delimiter-aware quoting, and parsing rules for comma/tab/pipe delimiters.
[§12 Indentation and Whitespace](https://github.com/toon-format/spec/blob/main/SPEC.md#12-indentation-and-whitespace):
Encoding requirements (consistent spaces, no tabs in indentation, no trailing spaces/newlines) and decoding rules (strict vs non-strict indentation handling).
### Conformance and Validation
[§13 Conformance and Options](https://github.com/toon-format/spec/blob/main/SPEC.md#13-conformance-and-options):
Defines conformance classes (encoder, decoder, validator), standardized options, and conformance checklists.
[§13.4 Key Folding and Path Expansion](https://github.com/toon-format/spec/blob/main/SPEC.md#134-key-folding-and-path-expansion):
Optional encoder feature (key folding) and decoder feature (path expansion) for collapsing/expanding dotted paths, with deep-merge semantics and strict/non-strict conflict resolution.
[§14 Strict Mode Errors and Diagnostics](https://github.com/toon-format/spec/blob/main/SPEC.md#14-strict-mode-errors-and-diagnostics-authoritative-checklist):
**Authoritative checklist** of all strict-mode errors: array count and width mismatches (§14.1), syntax and structural errors (§14.2), path expansion conflicts (§14.3), and duplicate sibling keys (§14.4).
### Implementation Guidance
[§15 Security Considerations](https://github.com/toon-format/spec/blob/main/SPEC.md#15-security-considerations):
Injection risks, quoting rules, and strict-mode checks relevant to security.
[§16 Internationalization](https://github.com/toon-format/spec/blob/main/SPEC.md#16-internationalization):
Unicode handling and locale-independent number formatting.
[§17 IANA Considerations](https://github.com/toon-format/spec/blob/main/SPEC.md#17-iana-considerations):
Media type registration plans and provisional status.
[§18 Versioning and Extensibility](https://github.com/toon-format/spec/blob/main/SPEC.md#18-versioning-and-extensibility):
How the spec evolves: major vs minor bumps and the extensibility policy.
[§19 Intellectual Property Considerations](https://github.com/toon-format/spec/blob/main/SPEC.md#19-intellectual-property-considerations):
Licensing and IP terms for the specification.
[Appendix F: Host Type Normalization Examples](https://github.com/toon-format/spec/blob/main/SPEC.md#appendix-f-host-type-normalization-examples-informative):
Non-normative guidance for Go, JavaScript, Python, Rust, and Java implementations on normalizing language-specific types.
[Appendix C: Test Suite and Compliance](https://github.com/toon-format/spec/blob/main/SPEC.md#appendix-c-test-suite-and-compliance-informative):
Reference test suite at [github.com/toon-format/spec/tree/main/tests](https://github.com/toon-format/spec/tree/main/tests) for validating implementations.
## Spec Sections at a Glance
| Section | Topic | When to Read |
|---------|-------|--------------|
| §14 | Data model, normalization, decoding | Implementing encoders/decoders |
| §56 | Syntax, headers, root form | Implementing parsers |
| §7 | Strings, keys, quoting, escaping | Implementing string handling |
| §810 | Objects, arrays, list items | Implementing structure encoding |
| §1112 | Delimiters, indentation, whitespace | Implementing formatting and validation |
| §13 | Conformance, options, key folding/path expansion | Implementing options and features |
| §14 | Strict-mode errors | Implementing validators |
| §1516 | Security, internationalization | Operational considerations |
| §1719 | IANA, versioning, IP | Ecosystem and licensing |
## Conformance Checklists
The spec includes three conformance checklists:
### Encoder Checklist (§13.1) <sup>[↗ SPEC.md](https://github.com/toon-format/spec/blob/main/SPEC.md#131-encoder-conformance-checklist)</sup>
Key requirements:
- Produce UTF-8 with LF line endings
- Use consistent indentation (default 2 spaces, no tabs)
- Escape `\\`, `\"`, `\n`, `\r`, `\t` in quoted strings, and use `\uXXXX` for any other U+0000U+001F control character; lone surrogates are rejected
- Quote strings with active delimiter, colon, or structural characters
- Emit array lengths `[N]` matching actual count
- Preserve object key order
- Emit numbers per §2 (canonical decimal in `[1e-6, 1e21)` or zero; exponent form permitted outside)
- Convert `-0` to `0`, `NaN`/±Infinity to `null`
- Emit booleans and null as lowercase literals (`true`, `false`, `null`)
- No trailing spaces or trailing newline
- When `keyFolding="safe"` is enabled, folding MUST follow §13.4:
- Only fold IdentifierSegment keys (letters/digits/underscores, no dots),
- Do not introduce collisions with existing sibling keys,
- Do not fold segments that would require quoting.
- When `flattenDepth` is set, folding MUST stop at the configured number of segments (§13.4).
### Decoder Checklist (§13.2) <sup>[↗ SPEC.md](https://github.com/toon-format/spec/blob/main/SPEC.md#132-decoder-conformance-checklist)</sup>
Key requirements:
- Parse array headers per §6 (length, delimiter, fields)
- Split inline arrays and tabular rows using active delimiter only
- Unescape quoted strings with only valid escapes
- Type unquoted primitives: true/false/null → booleans/null, numeric → number, else → string
- Enforce strict-mode rules when `strict=true`
- Preserve array order and object key order
- When `expandPaths="safe"` is enabled, expand dotted keys into nested objects per §13.4:
- Split on `.`, only expand when all segments are IdentifierSegments,
- Deep-merge overlapping paths (object + object),
- Do not perform element-wise array merges.
- With `expandPaths="safe"` and `strict=true` (default), MUST error on any expansion conflict (§14.3).
- With `expandPaths="safe"` and `strict=false`, MUST apply deterministic last-write-wins (LWW) conflict resolution (§13.4).
### Validator Checklist (§13.3) <sup>[↗ SPEC.md](https://github.com/toon-format/spec/blob/main/SPEC.md#133-validator-conformance-checklist)</sup>
Validators should verify:
- Structural conformance (headers, indentation, list markers)
- Whitespace invariants (no trailing spaces/newlines)
- Delimiter consistency between headers and rows
- Array length counts match declared `[N]`
- All strict-mode requirements (including path-expansion conflicts when enabled)
## Versioning
The spec uses semantic versioning (major.minor):
- **Major version** (e.g., v2 → v3): Breaking changes, incompatible with previous versions
- **Minor version** (e.g., v3.1 → v3.2): Clarifications, additional requirements, or backward-compatible additions
See [Appendix D: Document Changelog](https://github.com/toon-format/spec/blob/main/SPEC.md#appendix-d-document-changelog-informative) for detailed version history.
## Contributing to the Spec
The spec is community-maintained at [github.com/toon-format/spec](https://github.com/toon-format/spec). We welcome contributions of all kinds: reporting ambiguities or errors, proposing clarifications and examples, adding test cases to the reference suite, or discussing edge cases and normative behavior. Your feedback helps shape the format.
+367
View File
@@ -0,0 +1,367 @@
---
description: JSON-to-TOON mappings at a glance for objects, arrays, quoting, key folding, and type conversions.
---
# Syntax Cheatsheet
Quick reference for mapping JSON to TOON format. For rigorous, normative syntax rules and edge cases, see the [Specification](/reference/spec).
## Objects
::: code-group
```json [JSON]
{
"id": 1,
"name": "Ada"
}
```
```yaml [TOON]
id: 1
name: Ada
```
:::
## Nested Objects
::: code-group
```json [JSON]
{
"user": {
"id": 1,
"name": "Ada"
}
}
```
```yaml [TOON]
user:
id: 1
name: Ada
```
:::
## Primitive Arrays
::: code-group
```json [JSON]
{
"tags": ["foo", "bar", "baz"]
}
```
```yaml [TOON]
tags[3]: foo,bar,baz
```
:::
## Tabular Arrays
::: code-group
```json [JSON]
{
"items": [
{ "id": 1, "qty": 5 },
{ "id": 2, "qty": 3 }
]
}
```
```yaml [TOON]
items[2]{id,qty}:
1,5
2,3
```
:::
## Mixed and Non-Uniform Arrays
::: code-group
```json [JSON]
{
"items": [1, { "a": 1 }, "x"]
}
```
```yaml [TOON]
items[3]:
- 1
- a: 1
- x
```
:::
> [!NOTE]
> When a list-item object has a tabular array as its first field, the tabular header appears on the hyphen line. Rows are indented two levels deeper than the hyphen, and other fields are indented one level deeper. This is the canonical encoding for this pattern.
::: code-group
```yaml [Multi-field object]
items[1]:
- users[2]{id,name}:
1,Ada
2,Bob
status: active
```
```yaml [Single-field object]
items[1]:
- users[2]{id,name}:
1,Ada
2,Bob
```
:::
## Arrays of Arrays
::: code-group
```json [JSON]
{
"pairs": [[1, 2], [3, 4]]
}
```
```yaml [TOON]
pairs[2]:
- [2]: 1,2
- [2]: 3,4
```
:::
## Root Arrays
::: code-group
```json [JSON]
["x", "y", "z"]
```
```yaml [TOON]
[3]: x,y,z
```
:::
## Empty Containers
::: code-group
```json [Empty Object]
{}
```
```yaml [Empty Object]
(empty output)
```
:::
::: code-group
```json [Empty Array]
{
"items": []
}
```
```yaml [Empty Array]
items: []
```
:::
## Quoting Special Cases
### Strings That Look Like Literals
::: code-group
```json [JSON]
{
"version": "123",
"enabled": "true"
}
```
```yaml [TOON]
version: "123"
enabled: "true"
```
:::
These strings must be quoted because they look like numbers/booleans.
### Strings Containing Delimiters
::: code-group
```json [JSON]
{
"note": "hello, world"
}
```
```yaml [TOON]
note: "hello, world"
```
:::
Strings must be quoted when they contain the active delimiter (inside an array scope) or the document delimiter (object field values, comma by default).
### Strings with Leading/Trailing Spaces
::: code-group
```json [JSON]
{
"message": " padded "
}
```
```yaml [TOON]
message: " padded "
```
:::
### Empty String
::: code-group
```json [JSON]
{
"name": ""
}
```
```yaml [TOON]
name: ""
```
:::
## Quoting Rules Summary
Strings **must** be quoted if they:
- Are empty (`""`)
- Have leading or trailing whitespace
- Equal `true`, `false`, or `null` (case-sensitive)
- Look like numbers (e.g., `"42"`, `"-3.14"`, `"1e-6"`, `"05"`)
- Contain special characters: `:`, `"`, `\`, `[`, `]`, `{`, `}`, or any control character (U+0000U+001F, including newline/tab/CR)
- Contain the relevant delimiter the active delimiter inside an array scope, or the document delimiter (comma by default) for object field values
- Equal `"-"` or start with `"-"` followed by any character
Otherwise, strings can be unquoted. Unicode and emoji are safe:
```yaml
message: Hello 世界 👋
note: This has inner spaces
```
## Escape Sequences
Six escape sequences are valid in quoted strings:
| Character | Escape |
|-----------|--------|
| Backslash (`\`) | `\\` |
| Double quote (`"`) | `\"` |
| Newline | `\n` |
| Carriage return | `\r` |
| Tab | `\t` |
| Any other U+0000U+001F control character | `\uXXXX` |
Other escapes (e.g., `\x`, `\0`, `\b`) are invalid, and lone-surrogate `\uXXXX` values (U+D800U+DFFF) are rejected.
## Array Headers
### Basic Header
```
key[N]:
```
- `N` = array length
- Default delimiter: comma
### Tabular Header
```
key[N]{field1,field2,field3}:
```
- `N` = array length
- `{fields}` = column names
- Default delimiter: comma
### Alternative Delimiters
::: code-group
```yaml [Tab Delimiter]
items[2 ]{id name}:
1 Alice
2 Bob
```
```yaml [Pipe Delimiter]
items[2|]{id|name}:
1|Alice
2|Bob
```
:::
The delimiter symbol appears inside the brackets and braces.
## Key Folding (Optional)
Standard nesting:
```yaml
data:
metadata:
items[2]: a,b
```
With key folding (`keyFolding: 'safe'`):
```yaml
data.metadata.items[2]: a,b
```
See [Format Overview Key Folding](/guide/format-overview#key-folding-optional) for details.
## Type Conversions
| Input | Output |
|-------|--------|
| Finite number in `[1e-6, 1e21)` (or zero) | Canonical decimal |
| Finite number outside that range | Exponent form permitted |
| `NaN`, `Infinity`, `-Infinity` | `null` |
| `BigInt` (safe range) | Number |
| `BigInt` (out of range) | Quoted decimal string |
| `Date` | ISO string (quoted) |
| `Set` | Array of normalized values |
| `Map` | Object with `String(key)` keys |
| `undefined`, `function`, `symbol` | `null` |
::: info
TOON itself doesn't specify how `Date` should be encoded the spec leaves this to implementations. This library emits an ISO 8601 string in quotes; other implementations may choose differently.
:::
+14
View File
@@ -0,0 +1,14 @@
import type { UserConfig } from 'unocss'
import { defineConfig, presetIcons, presetWind4, transformerDirectives } from 'unocss'
const config: UserConfig = defineConfig({
presets: [
presetWind4(),
presetIcons(),
],
transformers: [
transformerDirectives(),
],
})
export default config
+10
View File
@@ -0,0 +1,10 @@
name = "toon-docs"
compatibility_date = "2025-10-01"
[[routes]]
pattern = "toonformat.dev"
custom_domain = true
[assets]
directory = "./.vitepress/dist/"
not_found_handling = "404-page"
-9
View File
@@ -1,9 +0,0 @@
// @ts-check
import antfu from '@antfu/eslint-config'
export default antfu().append({
files: ['README.md', 'SPEC.md'],
rules: {
'style/no-tabs': 'off',
},
})
+22
View File
@@ -0,0 +1,22 @@
import type { ConfigNames, TypedFlatConfigItem } from '@antfu/eslint-config'
import type { FlatConfigComposer } from 'eslint-flat-config-utils'
import antfu from '@antfu/eslint-config'
const config: FlatConfigComposer<TypedFlatConfigItem, ConfigNames> = antfu({
rules: {
'no-cond-assign': 'off',
},
}).append({
files: ['**/README.md', 'SPEC.md', '**/benchmarks/**/*', '**/docs/**/*'],
rules: {
'markdown/no-missing-link-fragments': 'off',
'markdown/fenced-code-language': 'off',
'markdown/heading-increment': 'off',
'import/no-duplicates': 'off',
'style/no-tabs': 'off',
'yaml/quotes': 'off',
'yaml/indent': 'off',
},
})
export default config
+17 -12
View File
@@ -1,12 +1,15 @@
{
"name": "@toon-format/monorepo",
"type": "module",
"version": "1.0.0",
"version": "2.3.1",
"private": true,
"packageManager": "pnpm@10.20.0",
"packageManager": "pnpm@11.13.0",
"scripts": {
"build": "pnpm -r --filter=./packages/** run build",
"build": "pnpm -r --filter='./packages/**' run build",
"automd": "automd",
"docs:dev": "pnpm -C docs run dev",
"docs:build": "pnpm -C docs run build",
"docs:preview": "pnpm -C docs run preview",
"lint": "eslint .",
"lint:fix": "eslint . --fix",
"test": "pnpm -r test",
@@ -14,14 +17,16 @@
"release": "bumpp -r"
},
"devDependencies": {
"@antfu/eslint-config": "^6.2.0",
"@types/node": "^24.9.2",
"automd": "^0.4.2",
"bumpp": "^10.3.1",
"eslint": "^9.39.0",
"tsdown": "^0.15.12",
"tsx": "^4.20.6",
"typescript": "^5.9.3",
"vitest": "^4.0.6"
"@antfu/eslint-config": "^9.1.0",
"@commitlint/types": "^21.2.0",
"@types/node": "^26.1.1",
"automd": "^0.4.3",
"bumpp": "^11.1.0",
"eslint": "^10.7.0",
"eslint-flat-config-utils": "^3.2.0",
"tsdown": "^0.22.8",
"typescript": "^6.0.3",
"vite": "^8.1.4",
"vitest": "^4.1.10"
}
}
+67 -28
View File
@@ -1,8 +1,8 @@
# @toon-format/cli
Command-line tool for converting between JSON and TOON formats.
Command-line tool for converting JSON to TOON and back, with token analysis and streaming support.
[TOON (Token-Oriented Object Notation)](https://toonformat.dev) is a compact, human-readable serialization format designed for passing structured data to Large Language Models with significantly reduced token usage.
[TOON (Token-Oriented Object Notation)](https://toonformat.dev) is a compact, human-readable encoding of the JSON data model that minimizes tokens for LLM input. The CLI lets you test conversions, analyze token savings, and integrate TOON into shell pipelines with stdin/stdout support.
## Installation
@@ -60,13 +60,14 @@ cat data.toon | toon --decode
| `-o, --output <file>` | Output file path (prints to stdout if omitted) |
| `-e, --encode` | Force encode mode (overrides auto-detection) |
| `-d, --decode` | Force decode mode (overrides auto-detection) |
| `--delimiter <char>` | Array delimiter: `,` (comma), `\t` (tab), `\|` (pipe) |
| `--delimiter <char>` | Array delimiter: `,` (comma), tab character, `\|` (pipe). Pass tab as `$'\t'` in bash/zsh |
| `--indent <number>` | Indentation size (default: `2`) |
| `--stats` | Show token count estimates and savings (encode only) |
| `--no-strict` | Disable strict validation when decoding |
| `--key-folding <mode>` | Enable key folding: `off`, `safe` (default: `off`) |
| `--flatten-depth <number>` | Maximum folded segment count when key folding is enabled (default: `Infinity`) |
| `--expand-paths <mode>` | Enable path expansion: `off`, `safe` (default: `off`) |
| `--no-strict` | Skip decode validation (array counts, indentation, header delimiter); last-write-wins on duplicate keys |
| `--keyFolding <mode>` | Enable key folding: `off`, `safe` (default: `off`) |
| `--flattenDepth <number>` | Maximum folded segment count when key folding is enabled (default: `Infinity`) |
| `--expandPaths <mode>` | Enable path expansion: `off`, `safe` (default: `off`) |
| `--verbose` | Show full stack traces and cause chains for errors (default: `false`) |
## Advanced Examples
@@ -79,11 +80,12 @@ toon data.json --stats -o output.toon
```
Example output:
```
Encoded to TOON
Input: 15,145 tokens (JSON)
Output: 8,745 tokens (TOON)
Saved: 6,400 tokens (42.3% reduction)
Encoded data.json → output.toon
Token estimates: ~15,145 (JSON) → ~8,745 (TOON)
Saved ~6,400 tokens (-42.3%)
```
### Alternative Delimiters
@@ -91,23 +93,34 @@ Example output:
#### Tab-separated (often more token-efficient)
```bash
toon data.json --delimiter "\t" -o output.toon
toon data.json --delimiter $'\t' -o output.toon
```
#### Pipe-separated with length markers
```bash
toon data.json --delimiter "|" --length-marker -o output.toon
```
The `--delimiter` value must be the actual delimiter character. In bash/zsh, use `$'\t'` to pass a real tab; literal `"\t"` is rejected as an invalid delimiter.
### Lenient Decoding
Skip validation for faster processing:
Skip validation for faster, more forgiving decoding:
```bash
toon data.toon --no-strict -o output.json
```
With `--no-strict`, the decoder stops enforcing array count matches, indentation multiples, and header delimiter mismatches. Duplicate sibling keys no longer throw the last value wins. Malformed array headers fall back to plain `key: value` lines instead of erroring.
### Decode Error Output
When a TOON document fails to parse, the CLI renders the offending line with a caret pointing at the first non-whitespace character. Tabs are shown as `→` so the caret column reflects what the decoder actually saw:
```
ERROR Failed to decode TOON at line 2: Tabs are not allowed in indentation in strict mode
2 | →b: 1
^
```
The exit code is `1` on any error. Stack traces are suppressed by default. Pass `--verbose` to include the full stack and the underlying cause chain.
### Stdin Workflows
```bash
@@ -115,12 +128,37 @@ toon data.toon --no-strict -o output.json
curl https://api.example.com/data | toon --stats
# Process large dataset
cat large-dataset.json | toon --delimiter "\t" > output.toon
cat large-dataset.json | toon --delimiter $'\t' > output.toon
# Chain with other tools
jq '.results' data.json | toon > filtered.toon
```
### Large Dataset Processing
The CLI uses streaming output for both encoding and decoding, writing incrementally without building the full output string in memory:
```bash
# Encode large JSON file with minimal memory usage
toon huge-dataset.json -o output.toon
# Decode large TOON file with streaming JSON output
toon huge-dataset.toon -o output.json
# Process millions of records efficiently via stdin
cat million-records.json | toon > output.toon
cat million-records.toon | toon --decode > output.json
```
**Memory efficiency:**
- **Encode (JSON → TOON)**: Streams TOON lines to output without full string in memory
- **Decode (TOON → JSON)**: Uses the same event-based streaming decoder as the `decodeStream` API in `@toon-format/toon`, streaming JSON tokens to output without full string in memory
- Peak memory usage scales with data depth, not total size
- When `--expandPaths safe` is enabled, decode falls back to non-streaming mode internally to apply deep-merge expansion before writing JSON
> [!TIP]
> When using `--stats` with encode, the full output string is kept in memory for token counting. Omit `--stats` for maximum memory efficiency with very large datasets.
### Key Folding (Since v1.5)
Collapse nested wrapper chains to reduce tokens:
@@ -129,7 +167,7 @@ Collapse nested wrapper chains to reduce tokens:
```bash
# Encode with key folding
toon input.json --key-folding safe -o output.toon
toon input.json --keyFolding safe -o output.toon
```
For data like:
@@ -159,24 +197,24 @@ data:
```bash
# Fold maximum 2 levels deep
toon input.json --key-folding safe --flatten-depth 2 -o output.toon
toon input.json --keyFolding safe --flattenDepth 2 -o output.toon
```
#### Path expansion on decode
```bash
# Reconstruct nested structure from folded keys
toon data.toon --expand-paths safe -o output.json
toon data.toon --expandPaths safe -o output.json
```
#### Round-trip workflow
```bash
# Encode with folding
toon input.json --key-folding safe -o compressed.toon
toon input.json --keyFolding safe -o compressed.toon
# Decode with expansion (restores original structure)
toon compressed.toon --expand-paths safe -o output.json
toon compressed.toon --expandPaths safe -o output.json
# Verify round-trip
diff input.json output.json
@@ -186,7 +224,7 @@ diff input.json output.json
```bash
# Key folding + tab delimiter + stats
toon data.json --key-folding safe --delimiter "\t" --stats -o output.toon
toon data.json --keyFolding safe --delimiter $'\t' --stats -o output.toon
```
## Why Use the CLI?
@@ -196,12 +234,13 @@ toon data.json --key-folding safe --delimiter "\t" --stats -o output.toon
- **Pipeline integration** with existing JSON-based workflows
- **Flexible formatting** with delimiter and indentation options
- **Key folding** to collapse nested wrappers for additional token savings
- **Memory-efficient streaming** for both encode and decode operations - process large datasets without loading entire outputs into memory
## Related
- [@toon-format/toon](https://www.npmjs.com/package/@toon-format/toon) - JavaScript/TypeScript library
- [Full specification](https://github.com/toon-format/spec) - Complete format documentation
- [Website](https://toonformat.dev) - Interactive examples and guides
- [@toon-format/toon](https://www.npmjs.com/package/@toon-format/toon) JavaScript/TypeScript library
- [Full specification](https://github.com/toon-format/spec) Complete format documentation
- [Website](https://toonformat.dev) Interactive examples and guides
## License
+1 -1
View File
@@ -1,3 +1,3 @@
#!/usr/bin/env node
'use strict'
import('../dist/index.js')
import('../dist/index.mjs')
+8 -8
View File
@@ -1,8 +1,8 @@
{
"name": "@toon-format/cli",
"type": "module",
"version": "1.0.0",
"packageManager": "pnpm@10.20.0",
"version": "2.3.1",
"packageManager": "pnpm@11.13.0",
"description": "CLI for JSON ↔ TOON conversion using @toon-format/toon",
"author": "Johann Schopplich <hello@johannschopplich.com>",
"license": "MIT",
@@ -17,11 +17,11 @@
"sideEffects": false,
"exports": {
".": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
"types": "./dist/index.d.mts",
"default": "./dist/index.mjs"
}
},
"types": "./dist/index.d.ts",
"types": "./dist/index.d.mts",
"bin": {
"toon": "bin/toon.mjs"
},
@@ -30,13 +30,13 @@
"dist"
],
"scripts": {
"dev": "tsx ./src/cli-entry.ts --help",
"dev": "node ./src/cli-entry.ts --help",
"build": "tsdown",
"test": "vitest"
},
"dependencies": {
"citty": "^0.1.6",
"citty": "^0.2.2",
"consola": "^3.4.2",
"tokenx": "^1.2.0"
"tokenx": "^1.3.0"
}
}
+1 -1
View File
@@ -1,4 +1,4 @@
import { runMain } from 'citty'
import { mainCommand } from '.'
import { mainCommand } from './index.ts'
runMain(mainCommand)
+127 -27
View File
@@ -1,12 +1,15 @@
import type { DecodeOptions, EncodeOptions } from '../../toon/src'
import type { InputSource } from './types'
import type { FileHandle } from 'node:fs/promises'
import type { DecodeOptions, DecodeStreamOptions, EncodeOptions } from '../../toon/src/index.ts'
import type { InputSource } from './types.ts'
import * as fsp from 'node:fs/promises'
import * as path from 'node:path'
import process from 'node:process'
import { consola } from 'consola'
import { estimateTokenCount } from 'tokenx'
import { decode, encode } from '../../toon/src'
import { formatInputLabel, readInput } from './utils'
import { decode, decodeStream, encode, encodeLines } from '../../toon/src/index.ts'
import { jsonStreamFromEvents } from './json-from-events.ts'
import { jsonStringifyLines } from './json-stringify-stream.ts'
import { formatInputLabel, readInput, readLinesFromSource } from './utils.ts'
export async function encodeToToon(config: {
input: InputSource
@@ -34,28 +37,41 @@ export async function encodeToToon(config: {
flattenDepth: config.flattenDepth,
}
const toonOutput = encode(data, encodeOptions)
if (config.output) {
await fsp.writeFile(config.output, toonOutput, 'utf-8')
const relativeInputPath = formatInputLabel(config.input)
const relativeOutputPath = path.relative(process.cwd(), config.output)
consola.success(`Encoded \`${relativeInputPath}\`\`${relativeOutputPath}\``)
}
else {
console.log(toonOutput)
}
// When printing stats, we need the full string for token counting
if (config.printStats) {
const toonOutput = encode(data, encodeOptions)
if (config.output) {
await fsp.writeFile(config.output, toonOutput, 'utf-8')
}
else {
console.log(toonOutput)
}
const jsonTokens = estimateTokenCount(jsonContent)
const toonTokens = estimateTokenCount(toonOutput)
const diff = jsonTokens - toonTokens
const percent = ((diff / jsonTokens) * 100).toFixed(1)
if (config.output) {
const relativeInputPath = formatInputLabel(config.input)
const relativeOutputPath = path.relative(process.cwd(), config.output)
consola.success(`Encoded \`${relativeInputPath}\`\`${relativeOutputPath}\``)
}
console.log()
consola.info(`Token estimates: ~${jsonTokens} (JSON) → ~${toonTokens} (TOON)`)
consola.success(`Saved ~${diff} tokens (-${percent}%)`)
}
else {
await writeStreamingToon(encodeLines(data, encodeOptions), config.output)
if (config.output) {
const relativeInputPath = formatInputLabel(config.input)
const relativeOutputPath = path.relative(process.cwd(), config.output)
consola.success(`Encoded \`${relativeInputPath}\`\`${relativeOutputPath}\``)
}
}
}
export async function decodeToJson(config: {
@@ -65,30 +81,114 @@ export async function decodeToJson(config: {
strict: NonNullable<DecodeOptions['strict']>
expandPaths?: NonNullable<DecodeOptions['expandPaths']>
}): Promise<void> {
const toonContent = await readInput(config.input)
// Path expansion requires full value in memory, so use non-streaming path
if (config.expandPaths === 'safe') {
const toonContent = await readInput(config.input)
let data: unknown
try {
const decodeOptions: DecodeOptions = {
indent: config.indent,
strict: config.strict,
expandPaths: config.expandPaths,
}
data = decode(toonContent, decodeOptions)
}
catch (error) {
throw new Error(`Failed to decode TOON: ${error instanceof Error ? error.message : String(error)}`)
}
const data = decode(toonContent, decodeOptions)
const jsonOutput = JSON.stringify(data, undefined, config.indent)
await writeStreamingJson(jsonStringifyLines(data, config.indent), config.output)
}
else {
const lineSource = readLinesFromSource(config.input)
const decodeStreamOptions: DecodeStreamOptions = {
indent: config.indent,
strict: config.strict,
}
const events = decodeStream(lineSource, decodeStreamOptions)
const jsonChunks = jsonStreamFromEvents(events, config.indent)
await writeStreamingJson(jsonChunks, config.output)
}
if (config.output) {
await fsp.writeFile(config.output, jsonOutput, 'utf-8')
const relativeInputPath = formatInputLabel(config.input)
const relativeOutputPath = path.relative(process.cwd(), config.output)
consola.success(`Decoded \`${relativeInputPath}\`\`${relativeOutputPath}\``)
}
}
/**
* Writes JSON chunks to a file or stdout using streaming approach.
* Chunks are written one at a time without building the full string in memory.
*/
async function writeStreamingJson(
chunks: AsyncIterable<string> | Iterable<string>,
outputPath?: string,
): Promise<void> {
// Stream to file using fs/promises API
if (outputPath) {
let fileHandle: FileHandle | undefined
try {
fileHandle = await fsp.open(outputPath, 'w')
for await (const chunk of chunks) {
await fileHandle.write(chunk)
}
}
finally {
await fileHandle?.close()
}
}
// Stream to stdout
else {
console.log(jsonOutput)
for await (const chunk of chunks) {
process.stdout.write(chunk)
}
// Add final newline for stdout
process.stdout.write('\n')
}
}
/**
* Writes TOON lines to a file or stdout using streaming approach.
* Lines are written one at a time without building the full string in memory.
*/
async function writeStreamingToon(
lines: Iterable<string>,
outputPath?: string,
): Promise<void> {
let isFirst = true
// Stream to file using fs/promises API
if (outputPath) {
let fileHandle: FileHandle | undefined
try {
fileHandle = await fsp.open(outputPath, 'w')
for (const line of lines) {
if (!isFirst)
await fileHandle.write('\n')
await fileHandle.write(line)
isFirst = false
}
}
finally {
await fileHandle?.close()
}
}
// Stream to stdout
else {
for (const line of lines) {
if (!isFirst)
process.stdout.write('\n')
process.stdout.write(line)
isFirst = false
}
// Add final newline for stdout
process.stdout.write('\n')
}
}
+70
View File
@@ -0,0 +1,70 @@
import { ToonDecodeError } from '../../toon/src/index.ts'
export interface FormatErrorOptions {
isVerbose: boolean
}
// #region Public API
export function formatError(error: unknown, options: FormatErrorOptions): string {
const sections: string[] = []
if (error instanceof ToonDecodeError && error.line !== undefined) {
sections.push(formatDecodeError(error))
}
else {
sections.push(String(error))
}
if (options.isVerbose) {
const causeChain = formatCauseChain(error)
if (causeChain) {
sections.push(causeChain)
}
if (error instanceof Error && error.stack) {
sections.push(error.stack)
}
}
return sections.join('\n\n')
}
// #endregion
// #region Internal renderers
function formatDecodeError(error: ToonDecodeError): string {
const linePrefix = `Line ${error.line}: `
const messageWithoutPrefix = error.message.startsWith(linePrefix)
? error.message.slice(linePrefix.length)
: error.message
const header = `Failed to decode TOON at line ${error.line}: ${messageWithoutPrefix}`
if (error.source === undefined) {
return header
}
const visibleSource = error.source.replace(/\t/g, '→')
const firstNonWhitespaceIndex = visibleSource.search(/\S/)
const gutter = ` ${error.line} | `
const caretIndent = ' '.repeat(gutter.length + Math.max(firstNonWhitespaceIndex, 0))
return `${header}\n\n${gutter}${visibleSource}\n${caretIndent}^`
}
function formatCauseChain(error: unknown): string {
const causeLines: string[] = []
let current: unknown = error instanceof Error ? error.cause : undefined
while (current instanceof Error) {
const name = current.name || 'Error'
causeLines.push(`Caused by: ${name}: ${current.message}`)
current = current.cause
}
return causeLines.join('\n')
}
// #endregion
+65 -110
View File
@@ -1,132 +1,87 @@
import type { CommandDef } from 'citty'
import type { DecodeOptions, Delimiter, EncodeOptions } from '../../toon/src'
import type { InputSource } from './types'
import type { ArgsDef, CommandDef } from 'citty'
import type { DecodeOptions, Delimiter, EncodeOptions } from '../../toon/src/index.ts'
import type { InputSource } from './types.ts'
import * as path from 'node:path'
import process from 'node:process'
import { defineCommand } from 'citty'
import { consola } from 'consola'
import { DEFAULT_DELIMITER, DELIMITERS } from '../../toon/src'
import { name, version } from '../package.json' with { type: 'json' }
import { decodeToJson, encodeToToon } from './conversion'
import { detectMode } from './utils'
import { DEFAULT_DELIMITER, DELIMITERS } from '../../toon/src/index.ts'
import pkg from '../package.json' with { type: 'json' }
import { decodeToJson, encodeToToon } from './conversion.ts'
import { formatError } from './format-error.ts'
import { detectMode } from './utils.ts'
export const mainCommand: CommandDef<{
const { name, version } = pkg
const args: ArgsDef = {
input: {
type: 'positional'
description: string
required: false
}
type: 'positional',
description: 'Input file path (omit or use "-" to read from stdin)',
required: false,
},
output: {
type: 'string'
description: string
alias: string
}
type: 'string',
description: 'Output file path',
alias: 'o',
},
encode: {
type: 'boolean'
description: string
alias: string
}
type: 'boolean',
description: 'Encode JSON to TOON (auto-detected by default)',
alias: 'e',
},
decode: {
type: 'boolean'
description: string
alias: string
}
type: 'boolean',
description: 'Decode TOON to JSON (auto-detected by default)',
alias: 'd',
},
delimiter: {
type: 'string'
description: string
default: string
}
type: 'string',
description: 'Delimiter for arrays: comma (,), tab (\\t), or pipe (|)',
default: ',',
},
indent: {
type: 'string'
description: string
default: string
}
type: 'string',
description: 'Indentation size',
default: '2',
},
strict: {
type: 'boolean'
description: string
default: true
}
type: 'boolean',
description: 'Strict decode validation (disable with --no-strict)',
default: true,
},
keyFolding: {
type: 'string'
description: string
default: string
}
type: 'string',
description: 'Enable key folding: off, safe (default: off)',
default: 'off',
},
flattenDepth: {
type: 'string'
description: string
}
type: 'string',
description: 'Maximum folded segment count when key folding is enabled (default: Infinity)',
},
expandPaths: {
type: 'string'
description: string
default: string
}
type: 'string',
description: 'Enable path expansion: off, safe (default: off)',
default: 'off',
},
stats: {
type: 'boolean'
description: string
default: false
}
}> = defineCommand({
type: 'boolean',
description: 'Show token statistics',
default: false,
},
verbose: {
type: 'boolean',
description: 'Show full stack traces and cause chains for errors',
default: false,
},
} as const
export const mainCommand: CommandDef<ArgsDef> = defineCommand({
meta: {
name,
description: 'TOON CLI Convert between JSON and TOON formats',
description: 'TOON CLI Convert between JSON and TOON formats',
version,
},
args: {
input: {
type: 'positional',
description: 'Input file path (omit or use "-" to read from stdin)',
required: false,
},
output: {
type: 'string',
description: 'Output file path',
alias: 'o',
},
encode: {
type: 'boolean',
description: 'Encode JSON to TOON (auto-detected by default)',
alias: 'e',
},
decode: {
type: 'boolean',
description: 'Decode TOON to JSON (auto-detected by default)',
alias: 'd',
},
delimiter: {
type: 'string',
description: 'Delimiter for arrays: comma (,), tab (\\t), or pipe (|)',
default: ',',
},
indent: {
type: 'string',
description: 'Indentation size',
default: '2',
},
strict: {
type: 'boolean',
description: 'Enable strict mode for decoding',
default: true,
},
keyFolding: {
type: 'string',
description: 'Enable key folding: off, safe (default: off)',
default: 'off',
},
flattenDepth: {
type: 'string',
description: 'Maximum folded segment count when key folding is enabled (default: Infinity)',
},
expandPaths: {
type: 'string',
description: 'Enable path expansion: off, safe (default: off)',
default: 'off',
},
stats: {
type: 'boolean',
description: 'Show token statistics',
default: false,
},
},
args,
async run({ args }) {
const input = args.input
@@ -193,7 +148,7 @@ export const mainCommand: CommandDef<{
}
}
catch (error) {
consola.error(error)
consola.error(formatError(error, { isVerbose: args.verbose === true }))
process.exit(1)
}
},
+217
View File
@@ -0,0 +1,217 @@
import type { JsonStreamEvent } from '../../toon/src/types.ts'
/**
* Context for tracking JSON structure state during event streaming.
*/
type JsonContext
= | { type: 'object', needsComma: boolean, expectValue: boolean }
| { type: 'array', needsComma: boolean }
/**
* Converts a stream of `JsonStreamEvent` into formatted JSON string chunks.
*
* Similar to `jsonStringifyLines` but driven by events instead of a value tree.
* Useful for streaming TOON decode directly to JSON output without building
* the full data structure in memory.
*
* @param events - Async iterable of JSON stream events
* @param indent - Number of spaces for indentation (0 = compact, >0 = pretty)
* @returns Async iterable of JSON string chunks
*
* @example
* ```ts
* const lines = readLinesFromSource(input)
* const events = decodeStream(lines)
* for await (const chunk of jsonStreamFromEvents(events, 2)) {
* process.stdout.write(chunk)
* }
* ```
*/
export async function* jsonStreamFromEvents(
events: AsyncIterable<JsonStreamEvent>,
indent: number = 2,
): AsyncIterable<string> {
const stack: JsonContext[] = []
let depth = 0
for await (const event of events) {
const parent = stack.length > 0 ? stack[stack.length - 1] : undefined
switch (event.type) {
case 'startObject': {
// Emit comma if needed (inside array or after previous object field value)
if (parent) {
if (parent.type === 'array' && parent.needsComma) {
yield ','
}
else if (parent.type === 'object' && !parent.expectValue) {
// Object field value already emitted, this is a nested object after a key
// The comma is handled by the key event
}
}
// Emit newline and indent for pretty printing
if (indent > 0 && parent) {
if (parent.type === 'array') {
yield '\n'
yield ' '.repeat(depth * indent)
}
}
yield '{'
stack.push({ type: 'object', needsComma: false, expectValue: false })
depth++
break
}
case 'endObject': {
const context = stack.pop()
if (!context || context.type !== 'object') {
throw new Error('Mismatched endObject event')
}
depth--
// Emit newline and indent for closing brace (pretty print)
if (indent > 0 && context.needsComma) {
yield '\n'
yield ' '.repeat(depth * indent)
}
yield '}'
// Mark parent as needing comma for next item
const newParent = stack.length > 0 ? stack[stack.length - 1] : undefined
if (newParent) {
if (newParent.type === 'object') {
newParent.expectValue = false
newParent.needsComma = true
}
else if (newParent.type === 'array') {
newParent.needsComma = true
}
}
break
}
case 'startArray': {
// Emit comma if needed
if (parent) {
if (parent.type === 'array' && parent.needsComma) {
yield ','
}
}
// Emit newline and indent for pretty printing
if (indent > 0 && parent) {
if (parent.type === 'array') {
yield '\n'
yield ' '.repeat(depth * indent)
}
}
yield '['
stack.push({
type: 'array',
needsComma: false,
})
depth++
break
}
case 'endArray': {
const context = stack.pop()
if (!context || context.type !== 'array') {
throw new Error('Mismatched endArray event')
}
depth--
// Emit newline and indent for closing bracket (pretty print)
if (indent > 0 && context.needsComma) {
yield '\n'
yield ' '.repeat(depth * indent)
}
yield ']'
// Mark parent as needing comma for next item
const newParent = stack.length > 0 ? stack[stack.length - 1] : undefined
if (newParent) {
if (newParent.type === 'object') {
newParent.expectValue = false
newParent.needsComma = true
}
else if (newParent.type === 'array') {
newParent.needsComma = true
}
}
break
}
case 'key': {
if (!parent || parent.type !== 'object') {
throw new Error('Key event outside of object context')
}
// Emit comma before this field if needed
if (parent.needsComma) {
yield ','
}
// Emit newline and indent (pretty print)
if (indent > 0) {
yield '\n'
yield ' '.repeat(depth * indent)
}
// Emit key
yield JSON.stringify(event.key)
yield indent > 0 ? ': ' : ':'
parent.expectValue = true
parent.needsComma = true
break
}
case 'primitive': {
// Emit comma if needed
if (parent) {
if (parent.type === 'array' && parent.needsComma) {
yield ','
}
else if (parent.type === 'object' && !parent.expectValue) {
// This shouldn't happen in well-formed events
throw new Error('Primitive event in object without preceding key')
}
}
// Emit newline and indent for array items (pretty print)
if (indent > 0 && parent && parent.type === 'array') {
yield '\n'
yield ' '.repeat(depth * indent)
}
// Emit primitive value
yield JSON.stringify(event.value)
// Update parent context
if (parent) {
if (parent.type === 'object') {
parent.expectValue = false
// needsComma already true from key event
}
else if (parent.type === 'array') {
parent.needsComma = true
}
}
break
}
}
}
// Ensure stack is empty
if (stack.length !== 0) {
throw new Error('Incomplete event stream: unclosed objects or arrays')
}
}
+161
View File
@@ -0,0 +1,161 @@
/**
* Streaming JSON stringifier.
*
* Yields JSON tokens one at a time, allowing streaming output without holding
* the entire JSON string in memory.
*
* @param value - The value to stringify (must be JSON-serializable)
* @param indent - Number of spaces for indentation (0 = compact, >0 = pretty)
* @returns Generator that yields JSON string chunks
*
* @example
* ```ts
* const data = { name: "Alice", scores: [95, 87, 92] }
* for (const chunk of jsonStringifyLines(data, 2)) {
* process.stdout.write(chunk)
* }
* ```
*/
export function* jsonStringifyLines(
value: unknown,
indent: number = 2,
): Iterable<string> {
yield* stringifyValue(value, 0, indent)
}
/**
* Internal generator for recursive stringification.
*/
function* stringifyValue(
value: unknown,
depth: number,
indent: number,
): Iterable<string> {
// Handle null
if (value === null) {
yield 'null'
return
}
const type = typeof value
// Handle primitives
if (type === 'boolean' || type === 'number') {
yield JSON.stringify(value)
return
}
if (type === 'string') {
yield JSON.stringify(value)
return
}
// Handle arrays
if (Array.isArray(value)) {
yield* stringifyArray(value, depth, indent)
return
}
// Handle objects
if (type === 'object') {
yield* stringifyObject(value as Record<string, unknown>, depth, indent)
return
}
// Undefined, functions, symbols become null in JSON
yield 'null'
}
/**
* Stringify an array with proper formatting.
*/
function* stringifyArray(
arr: unknown[],
depth: number,
indent: number,
): Iterable<string> {
if (arr.length === 0) {
yield '[]'
return
}
yield '['
if (indent > 0) {
// Pretty-printed format
for (let i = 0; i < arr.length; i++) {
yield '\n'
yield ' '.repeat((depth + 1) * indent)
yield* stringifyValue(arr[i], depth + 1, indent)
if (i < arr.length - 1) {
yield ','
}
}
yield '\n'
yield ' '.repeat(depth * indent)
yield ']'
}
else {
// Compact format
for (let i = 0; i < arr.length; i++) {
yield* stringifyValue(arr[i], depth + 1, indent)
if (i < arr.length - 1) {
yield ','
}
}
yield ']'
}
}
/**
* Stringify an object with proper formatting.
*/
function* stringifyObject(
obj: Record<string, unknown>,
depth: number,
indent: number,
): Iterable<string> {
const keys = Object.keys(obj)
if (keys.length === 0) {
yield '{}'
return
}
yield '{'
if (indent > 0) {
// Pretty-printed format
for (let i = 0; i < keys.length; i++) {
const key = keys[i]!
const value = obj[key]
yield '\n'
yield ' '.repeat((depth + 1) * indent)
yield JSON.stringify(key)
yield ': '
yield* stringifyValue(value, depth + 1, indent)
if (i < keys.length - 1) {
yield ','
}
}
yield '\n'
yield ' '.repeat(depth * indent)
yield '}'
}
else {
// Compact format
for (let i = 0; i < keys.length; i++) {
const key = keys[i]!
const value = obj[key]
yield JSON.stringify(key)
yield ':'
yield* stringifyValue(value, depth + 1, indent)
if (i < keys.length - 1) {
yield ','
}
}
yield '}'
}
}
+31 -1
View File
@@ -1,4 +1,5 @@
import type { InputSource } from './types'
import type { InputSource } from './types.ts'
import { createReadStream } from 'node:fs'
import * as fsp from 'node:fs/promises'
import * as path from 'node:path'
import process from 'node:process'
@@ -77,3 +78,32 @@ function readFromStdin(): Promise<string> {
stdin.resume()
})
}
export async function* readLinesFromSource(source: InputSource): AsyncIterable<string> {
const stream = source.type === 'stdin'
? process.stdin
: createReadStream(source.path, { encoding: 'utf-8' })
// Explicitly set encoding for stdin
if (source.type === 'stdin') {
stream.setEncoding('utf-8')
}
let buffer = ''
for await (const chunk of stream) {
buffer += chunk
let index: number
while ((index = buffer.indexOf('\n')) !== -1) {
const line = buffer.slice(0, index)
buffer = buffer.slice(index + 1)
yield line
}
}
// Emit last line if buffer is not empty and doesn't end with newline
if (buffer.length > 0) {
yield buffer
}
}
+78
View File
@@ -0,0 +1,78 @@
import { describe, expect, it } from 'vitest'
import { ToonDecodeError } from '../../toon/src/index'
import { formatError } from '../src/format-error'
describe('formatError', () => {
it('renders a decode error with line and source as a header, source line, and caret', () => {
const error = new ToonDecodeError(
'Tabs are not allowed in indentation in strict mode',
{ line: 2, source: '\tb: 1' },
)
const output = formatError(error, { isVerbose: false })
expect(output).toBe(
'Failed to decode TOON at line 2: Tabs are not allowed in indentation in strict mode\n'
+ '\n'
+ ' 2 | →b: 1\n'
+ ' ^',
)
})
it('renders a decode error without source as a header only', () => {
const error = new ToonDecodeError('Something went wrong', { line: 5 })
const output = formatError(error, { isVerbose: false })
expect(output).toBe('Failed to decode TOON at line 5: Something went wrong')
})
it('appends the cause chain under verbose mode', () => {
const cause = new SyntaxError('Unterminated string: missing closing quote')
const error = new ToonDecodeError(
'Unterminated string: missing closing quote',
{ line: 2, source: 'greeting: "hello', cause },
)
const output = formatError(error, { isVerbose: true })
expect(output).toContain('Failed to decode TOON at line 2:')
expect(output).toContain(' 2 | greeting: "hello')
expect(output).toContain('Caused by: SyntaxError: Unterminated string: missing closing quote')
})
it('appends the stack trace under verbose mode and omits it otherwise', () => {
const error = new ToonDecodeError('Boom', { line: 1, source: 'x' })
error.stack = 'ToonDecodeError: Line 1: Boom\n at fakeFrame (file.ts:1:1)'
const verbose = formatError(error, { isVerbose: true })
const quiet = formatError(error, { isVerbose: false })
expect(verbose).toContain('at fakeFrame (file.ts:1:1)')
expect(quiet).not.toContain('at fakeFrame')
})
it('renders a generic Error as its message only when not verbose', () => {
const error = new Error('something went wrong')
const output = formatError(error, { isVerbose: false })
expect(output).toBe('Error: something went wrong')
})
it('places the caret under the first non-whitespace character of the source line', () => {
const error = new ToonDecodeError(
'Indentation must be exact multiple of 2, but found 3 spaces',
{ line: 2, source: ' b: 1' },
)
const output = formatError(error, { isVerbose: false })
expect(output).toBe(
'Failed to decode TOON at line 2: Indentation must be exact multiple of 2, but found 3 spaces\n'
+ '\n'
+ ' 2 | b: 1\n'
+ ' ^',
)
})
})
+672 -13
View File
@@ -3,11 +3,13 @@ import { consola } from 'consola'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { DEFAULT_DELIMITER, encode } from '../../toon/src'
import { version } from '../package.json' with { type: 'json' }
import { createCliTestContext, runCli } from './utils'
import { createCliTestContext, mockStdin, runCli } from './utils'
describe('toon CLI', () => {
beforeEach(() => {
vi.spyOn(process, 'exit').mockImplementation(() => 0 as never)
vi.spyOn(console, 'log').mockImplementation(() => undefined)
vi.spyOn(process.stdout, 'write').mockImplementation(() => true)
})
afterEach(() => {
@@ -16,15 +18,39 @@ describe('toon CLI', () => {
describe('version', () => {
it('prints the version when using --version', async () => {
const consolaLog = vi.spyOn(consola, 'log').mockImplementation(() => undefined)
const consoleLog = vi.mocked(console.log)
await runCli({ rawArgs: ['--version'] })
expect(consolaLog).toHaveBeenCalledWith(version)
expect(consoleLog).toHaveBeenCalledWith(version)
})
})
describe('encode (JSON → TOON)', () => {
it('encodes JSON from stdin', async () => {
const data = {
title: 'TOON test',
count: 3,
nested: { ok: true },
}
const cleanup = mockStdin(JSON.stringify(data))
const writeChunks: string[] = []
vi.spyOn(process.stdout, 'write').mockImplementation((chunk) => {
writeChunks.push(String(chunk))
return true
})
try {
await runCli()
const fullOutput = writeChunks.join('')
expect(fullOutput).toBe(`${encode(data)}\n`)
}
finally {
cleanup()
}
})
it('encodes a JSON file into a TOON file', async () => {
const data = {
title: 'TOON test',
@@ -60,19 +86,39 @@ describe('toon CLI', () => {
'input.json': JSON.stringify(data),
})
const stdout: string[] = []
const logSpy = vi.spyOn(console, 'log').mockImplementation((message?: unknown) => {
stdout.push(String(message ?? ''))
const writeChunks: string[] = []
vi.spyOn(process.stdout, 'write').mockImplementation((chunk) => {
writeChunks.push(String(chunk))
return true
})
try {
await context.run(['input.json'])
expect(stdout).toHaveLength(1)
expect(stdout[0]).toBe(encode(data))
const fullOutput = writeChunks.join('')
expect(fullOutput).toBe(`${encode(data)}\n`)
}
finally {
logSpy.mockRestore()
await context.cleanup()
}
})
it('encodes JSON from stdin to output file', async () => {
const data = { key: 'value' }
const context = await createCliTestContext({})
const cleanup = mockStdin(JSON.stringify(data))
const consolaSuccess = vi.spyOn(consola, 'success').mockImplementation(() => undefined)
try {
await context.run(['--output', 'output.toon'])
const output = await context.read('output.toon')
expect(output).toBe(encode(data))
expect(consolaSuccess).toHaveBeenCalledWith(expect.stringMatching(/Encoded.*stdin[^\n\r\u2028\u2029\u2192]*\u2192.*output\.toon/))
}
finally {
cleanup()
await context.cleanup()
}
})
@@ -102,6 +148,547 @@ describe('toon CLI', () => {
await context.cleanup()
}
})
it('decodes TOON from stdin', async () => {
const data = { items: ['a', 'b'], count: 2 }
const toonInput = encode(data)
const cleanup = mockStdin(toonInput)
const writeChunks: string[] = []
vi.spyOn(process.stdout, 'write').mockImplementation((chunk) => {
writeChunks.push(String(chunk))
return true
})
try {
await runCli({ rawArgs: ['--decode'] })
const fullOutput = writeChunks.join('')
// Remove trailing newline before parsing
const jsonOutput = fullOutput.endsWith('\n') ? fullOutput.slice(0, -1) : fullOutput
const result = JSON.parse(jsonOutput)
expect(result).toEqual(data)
}
finally {
cleanup()
}
})
it('decodes TOON from stdin to output file', async () => {
const data = { name: 'test', values: [1, 2, 3] }
const toonInput = encode(data)
const context = await createCliTestContext({})
const cleanup = mockStdin(toonInput)
const consolaSuccess = vi.spyOn(consola, 'success').mockImplementation(() => undefined)
try {
await context.run(['--decode', '--output', 'output.json'])
const output = await context.read('output.json')
expect(JSON.parse(output)).toEqual(data)
expect(consolaSuccess).toHaveBeenCalledWith(expect.stringMatching(/Decoded.*stdin[^\n\r\u2028\u2029\u2192]*\u2192.*output\.json/))
}
finally {
cleanup()
await context.cleanup()
}
})
})
describe('stdin edge cases', () => {
it('handles invalid JSON from stdin', async () => {
const cleanup = mockStdin('{ invalid json }')
const consolaError = vi.spyOn(consola, 'error').mockImplementation(() => undefined)
const exitSpy = vi.mocked(process.exit)
try {
await runCli({ rawArgs: [] })
expect(exitSpy).toHaveBeenCalledWith(1)
expect(consolaError).toHaveBeenCalled()
}
finally {
cleanup()
}
})
it('handles invalid TOON from stdin', async () => {
const cleanup = mockStdin('key: "unterminated string')
const consolaError = vi.spyOn(consola, 'error').mockImplementation(() => undefined)
const exitSpy = vi.mocked(process.exit)
try {
await runCli({ rawArgs: ['--decode'] })
expect(exitSpy).toHaveBeenCalledWith(1)
expect(consolaError).toHaveBeenCalled()
}
finally {
cleanup()
}
})
it('renders a TOON decode error with line context, source, and caret', async () => {
const cleanup = mockStdin('a:\n\tb: 1\n')
const consolaError = vi.spyOn(consola, 'error').mockImplementation(() => undefined)
const exitSpy = vi.mocked(process.exit)
try {
await runCli({ rawArgs: ['--decode'] })
expect(exitSpy).toHaveBeenCalledWith(1)
const errorCall = consolaError.mock.calls.at(0)
expect(errorCall).toBeDefined()
const [rendered] = errorCall!
expect(rendered).toEqual(expect.stringContaining('Failed to decode TOON at line 2:'))
expect(rendered).toEqual(expect.stringContaining(' 2 | →b: 1'))
expect(rendered).toEqual(expect.stringContaining(' ^'))
expect(rendered).not.toEqual(expect.stringMatching(/^\s+at \S+/m))
}
finally {
cleanup()
}
})
it('includes the stack trace when --verbose is passed', async () => {
const cleanup = mockStdin('a:\n\tb: 1\n')
const consolaError = vi.spyOn(consola, 'error').mockImplementation(() => undefined)
try {
await runCli({ rawArgs: ['--decode', '--verbose'] })
const errorCall = consolaError.mock.calls.at(0)
expect(errorCall).toBeDefined()
const [rendered] = errorCall!
expect(rendered).toEqual(expect.stringContaining('Failed to decode TOON at line 2:'))
expect(rendered).toEqual(expect.stringMatching(/at \S+/))
}
finally {
cleanup()
}
})
})
describe('stdin with options', () => {
it('encodes JSON from stdin with custom delimiter', async () => {
const data = { items: [1, 2, 3] }
const cleanup = mockStdin(JSON.stringify(data))
const writeChunks: string[] = []
vi.spyOn(process.stdout, 'write').mockImplementation((chunk) => {
writeChunks.push(String(chunk))
return true
})
try {
await runCli({ rawArgs: ['--delimiter', '|'] })
const fullOutput = writeChunks.join('')
expect(fullOutput).toBe(`${encode(data, { delimiter: '|' })}\n`)
}
finally {
cleanup()
}
})
it('encodes JSON from stdin with custom indent', async () => {
const data = {
nested: {
deep: { value: 1 },
},
}
const cleanup = mockStdin(JSON.stringify(data))
const writeChunks: string[] = []
vi.spyOn(process.stdout, 'write').mockImplementation((chunk) => {
writeChunks.push(String(chunk))
return true
})
try {
await runCli({ rawArgs: ['--indent', '4'] })
const fullOutput = writeChunks.join('')
expect(fullOutput).toBe(`${encode(data, { indent: 4 })}\n`)
}
finally {
cleanup()
}
})
it('decodes TOON from stdin with --no-strict', async () => {
const data = { test: true }
const toonInput = encode(data)
const cleanup = mockStdin(toonInput)
const writeChunks: string[] = []
vi.spyOn(process.stdout, 'write').mockImplementation((chunk) => {
writeChunks.push(String(chunk))
return true
})
try {
await runCli({ rawArgs: ['--decode', '--no-strict'] })
const fullOutput = writeChunks.join('')
// Remove trailing newline before parsing
const jsonOutput = fullOutput.endsWith('\n') ? fullOutput.slice(0, -1) : fullOutput
const result = JSON.parse(jsonOutput)
expect(result).toEqual(data)
}
finally {
cleanup()
}
})
})
describe('encode options', () => {
it('encodes with --keyFolding safe', async () => {
const data = {
data: {
metadata: {
items: ['a', 'b'],
},
},
}
const context = await createCliTestContext({
'input.json': JSON.stringify(data),
})
try {
await context.run(['input.json', '--keyFolding', 'safe', '--output', 'output.toon'])
const output = await context.read('output.toon')
const expected = encode(data, { keyFolding: 'safe' })
expect(output).toBe(expected)
}
finally {
await context.cleanup()
}
})
it('encodes with --flattenDepth', async () => {
const data = {
level1: {
level2: {
level3: {
value: 'deep',
},
},
},
}
const context = await createCliTestContext({
'input.json': JSON.stringify(data),
})
try {
await context.run(['input.json', '--keyFolding', 'safe', '--flattenDepth', '2', '--output', 'output.toon'])
const output = await context.read('output.toon')
const expected = encode(data, { keyFolding: 'safe', flattenDepth: 2 })
expect(output).toBe(expected)
}
finally {
await context.cleanup()
}
})
})
describe('decode options', () => {
it('decodes with --expandPaths safe', async () => {
const data = {
data: {
metadata: {
items: ['a', 'b'],
},
},
}
const toonInput = encode(data, { keyFolding: 'safe' })
const context = await createCliTestContext({
'input.toon': toonInput,
})
try {
await context.run(['input.toon', '--decode', '--expandPaths', 'safe', '--output', 'output.json'])
const output = await context.read('output.json')
const result = JSON.parse(output)
expect(result).toEqual(data)
}
finally {
await context.cleanup()
}
})
it('decodes with --indent for JSON formatting', async () => {
const data = {
a: 1,
b: [2, 3],
c: { nested: true },
}
const toonInput = encode(data, { indent: 4 })
const context = await createCliTestContext({
'input.toon': toonInput,
})
try {
await context.run(['input.toon', '--decode', '--indent', '4', '--output', 'output.json'])
const output = await context.read('output.json')
const result = JSON.parse(output)
expect(result).toEqual(data)
expect(output).toContain(' ') // Should have 4-space indentation
}
finally {
await context.cleanup()
}
})
it('decodes root primitive number', async () => {
const toonInput = '42'
const cleanup = mockStdin(toonInput)
const writeChunks: string[] = []
vi.spyOn(process.stdout, 'write').mockImplementation((chunk) => {
writeChunks.push(String(chunk))
return true
})
try {
await runCli({ rawArgs: ['--decode'] })
const fullOutput = writeChunks.join('')
expect(fullOutput).toBe('42\n')
}
finally {
cleanup()
}
})
it('decodes root primitive string', async () => {
const toonInput = '"Hello World"'
const cleanup = mockStdin(toonInput)
const writeChunks: string[] = []
vi.spyOn(process.stdout, 'write').mockImplementation((chunk) => {
writeChunks.push(String(chunk))
return true
})
try {
await runCli({ rawArgs: ['--decode'] })
const fullOutput = writeChunks.join('')
const jsonOutput = fullOutput.endsWith('\n') ? fullOutput.slice(0, -1) : fullOutput
expect(JSON.parse(jsonOutput)).toBe('Hello World')
}
finally {
cleanup()
}
})
it('decodes root primitive boolean', async () => {
const toonInput = 'true'
const cleanup = mockStdin(toonInput)
const writeChunks: string[] = []
vi.spyOn(process.stdout, 'write').mockImplementation((chunk) => {
writeChunks.push(String(chunk))
return true
})
try {
await runCli({ rawArgs: ['--decode'] })
const fullOutput = writeChunks.join('')
expect(fullOutput).toBe('true\n')
}
finally {
cleanup()
}
})
})
describe('streaming output', () => {
it('streams large JSON to TOON file with identical output', async () => {
const data = {
items: Array.from({ length: 1000 }, (_, i) => ({
id: i,
name: `Item ${i}`,
value: Math.random(),
})),
}
const context = await createCliTestContext({
'large-input.json': JSON.stringify(data, undefined, 2),
})
const consolaSuccess = vi.spyOn(consola, 'success').mockImplementation(() => undefined)
try {
await context.run(['large-input.json', '--output', 'output.toon'])
const output = await context.read('output.toon')
// Verify streaming produces identical output to `encode()`
const expected = encode(data, {
delimiter: DEFAULT_DELIMITER,
indent: 2,
})
expect(output).toBe(expected)
expect(consolaSuccess).toHaveBeenCalledWith(expect.stringMatching(/Encoded .* → .*/))
}
finally {
await context.cleanup()
}
})
it('streams large TOON to JSON file with streaming decode', async () => {
const data = {
records: Array.from({ length: 1000 }, (_, i) => ({
id: i,
title: `Record ${i}`,
score: Math.random() * 100,
})),
}
const toonContent = encode(data, {
delimiter: DEFAULT_DELIMITER,
indent: 2,
})
const context = await createCliTestContext({
'large-input.toon': toonContent,
})
const consolaSuccess = vi.spyOn(consola, 'success').mockImplementation(() => undefined)
try {
await context.run(['large-input.toon', '--decode', '--output', 'output.json'])
const output = await context.read('output.json')
const result = JSON.parse(output)
expect(result).toEqual(data)
expect(consolaSuccess).toHaveBeenCalledWith(expect.stringMatching(/Decoded .* → .*/))
}
finally {
await context.cleanup()
}
})
it('streams to stdout using process.stdout.write', async () => {
const data = {
users: [
{ id: 1, name: 'Alice' },
{ id: 2, name: 'Bob' },
],
}
const context = await createCliTestContext({
'input.json': JSON.stringify(data),
})
const writeChunks: string[] = []
const writeSpy = vi.spyOn(process.stdout, 'write').mockImplementation((chunk) => {
writeChunks.push(String(chunk))
return true
})
try {
await context.run(['input.json'])
expect(writeSpy).toHaveBeenCalled()
// Verify complete output matches `encode()`
const fullOutput = writeChunks.join('')
const expected = `${encode(data)}\n`
expect(fullOutput).toBe(expected)
}
finally {
await context.cleanup()
}
})
it('handles empty object streaming correctly', async () => {
const data = {}
const context = await createCliTestContext({
'empty.json': JSON.stringify(data),
})
try {
await context.run(['empty.json', '--output', 'output.toon'])
const output = await context.read('output.toon')
expect(output).toBe(encode(data))
}
finally {
await context.cleanup()
}
})
it('handles single-line output streaming correctly', async () => {
const data = { key: 'value' }
const context = await createCliTestContext({
'single.json': JSON.stringify(data),
})
try {
await context.run(['single.json', '--output', 'output.toon'])
const output = await context.read('output.toon')
expect(output).toBe(encode(data))
}
finally {
await context.cleanup()
}
})
it('uses non-streaming path when stats are enabled', async () => {
const data = {
items: [
{ id: 1, value: 'test' },
{ id: 2, value: 'data' },
],
}
const context = await createCliTestContext({
'input.json': JSON.stringify(data),
})
const consoleLogSpy = vi.spyOn(console, 'log').mockImplementation(() => undefined)
const consolaInfo = vi.spyOn(consola, 'info').mockImplementation(() => undefined)
const consolaSuccess = vi.spyOn(consola, 'success').mockImplementation(() => undefined)
try {
await context.run(['input.json', '--stats'])
expect(consolaInfo).toHaveBeenCalledWith(expect.stringMatching(/Token estimates:/))
expect(consolaSuccess).toHaveBeenCalledWith(expect.stringMatching(/Saved.*tokens/))
expect(consoleLogSpy).toHaveBeenCalledWith(encode(data))
}
finally {
await context.cleanup()
}
})
})
describe('error handling', () => {
@@ -110,7 +697,7 @@ describe('toon CLI', () => {
'input.json': JSON.stringify({ value: 1 }),
})
const consolaError = vi.spyOn(consola, 'error').mockImplementation(() => undefined)
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => undefined)
const exitSpy = vi.mocked(process.exit)
try {
@@ -118,7 +705,7 @@ describe('toon CLI', () => {
expect(exitSpy).toHaveBeenCalledWith(1)
const errorCall = consolaError.mock.calls.at(0)
const errorCall = consoleError.mock.calls.at(0)
expect(errorCall).toBeDefined()
const [error] = errorCall!
expect(error).toBeInstanceOf(Error)
@@ -134,7 +721,7 @@ describe('toon CLI', () => {
'input.json': JSON.stringify({ value: 1 }),
})
const consolaError = vi.spyOn(consola, 'error').mockImplementation(() => undefined)
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => undefined)
const exitSpy = vi.mocked(process.exit)
try {
@@ -142,7 +729,7 @@ describe('toon CLI', () => {
expect(exitSpy).toHaveBeenCalledWith(1)
const errorCall = consolaError.mock.calls.at(0)
const errorCall = consoleError.mock.calls.at(0)
expect(errorCall).toBeDefined()
const [error] = errorCall!
expect(error).toBeInstanceOf(Error)
@@ -169,5 +756,77 @@ describe('toon CLI', () => {
await context.cleanup()
}
})
it('rejects invalid --keyFolding value', async () => {
const context = await createCliTestContext({
'input.json': JSON.stringify({ value: 1 }),
})
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => undefined)
const exitSpy = vi.mocked(process.exit)
try {
await context.run(['input.json', '--keyFolding', 'invalid'])
expect(exitSpy).toHaveBeenCalledWith(1)
const errorCall = consoleError.mock.calls.at(0)
expect(errorCall).toBeDefined()
const [error] = errorCall!
expect(error).toBeInstanceOf(Error)
expect(error.message).toContain('Invalid keyFolding value')
}
finally {
await context.cleanup()
}
})
it('rejects invalid --expandPaths value', async () => {
const context = await createCliTestContext({
'input.toon': 'key: value',
})
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => undefined)
const exitSpy = vi.mocked(process.exit)
try {
await context.run(['input.toon', '--decode', '--expandPaths', 'invalid'])
expect(exitSpy).toHaveBeenCalledWith(1)
const errorCall = consoleError.mock.calls.at(0)
expect(errorCall).toBeDefined()
const [error] = errorCall!
expect(error).toBeInstanceOf(Error)
expect(error.message).toContain('Invalid expandPaths value')
}
finally {
await context.cleanup()
}
})
it('rejects invalid --flattenDepth value', async () => {
const context = await createCliTestContext({
'input.json': JSON.stringify({ value: 1 }),
})
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => undefined)
const exitSpy = vi.mocked(process.exit)
try {
await context.run(['input.json', '--flattenDepth', '-1'])
expect(exitSpy).toHaveBeenCalledWith(1)
const errorCall = consoleError.mock.calls.at(0)
expect(errorCall).toBeDefined()
const [error] = errorCall!
expect(error).toBeInstanceOf(Error)
expect(error.message).toContain('Invalid flattenDepth value')
}
finally {
await context.cleanup()
}
})
})
})
+423
View File
@@ -0,0 +1,423 @@
import type { JsonStreamEvent } from '../../toon/src/types'
import { describe, expect, it } from 'vitest'
import { jsonStreamFromEvents } from '../src/json-from-events'
describe('jsonStreamFromEvents', () => {
describe('primitives', () => {
it('converts null event', async () => {
const events = [
{ type: 'primitive' as const, value: null },
]
expect(await join(jsonStreamFromEvents(asyncEvents(events), 0))).toBe(JSON.stringify(null))
expect(await join(jsonStreamFromEvents(asyncEvents(events), 2))).toBe(JSON.stringify(null, null, 2))
})
it('converts boolean events', async () => {
const eventsTrue = [{ type: 'primitive' as const, value: true }]
const eventsFalse = [{ type: 'primitive' as const, value: false }]
expect(await join(jsonStreamFromEvents(asyncEvents(eventsTrue), 0))).toBe(JSON.stringify(true))
expect(await join(jsonStreamFromEvents(asyncEvents(eventsFalse), 0))).toBe(JSON.stringify(false))
expect(await join(jsonStreamFromEvents(asyncEvents(eventsTrue), 2))).toBe(JSON.stringify(true, null, 2))
})
it('converts number events', async () => {
const events0 = [{ type: 'primitive' as const, value: 0 }]
const events42 = [{ type: 'primitive' as const, value: 42 }]
const eventsNeg = [{ type: 'primitive' as const, value: -17 }]
const eventsFloat = [{ type: 'primitive' as const, value: 3.14159 }]
expect(await join(jsonStreamFromEvents(asyncEvents(events0), 0))).toBe(JSON.stringify(0))
expect(await join(jsonStreamFromEvents(asyncEvents(events42), 0))).toBe(JSON.stringify(42))
expect(await join(jsonStreamFromEvents(asyncEvents(eventsNeg), 0))).toBe(JSON.stringify(-17))
expect(await join(jsonStreamFromEvents(asyncEvents(eventsFloat), 0))).toBe(JSON.stringify(3.14159))
expect(await join(jsonStreamFromEvents(asyncEvents(events42), 2))).toBe(JSON.stringify(42, null, 2))
})
it('converts string events', async () => {
const eventsEmpty = [{ type: 'primitive' as const, value: '' }]
const eventsHello = [{ type: 'primitive' as const, value: 'hello' }]
const eventsQuotes = [{ type: 'primitive' as const, value: 'with "quotes"' }]
expect(await join(jsonStreamFromEvents(asyncEvents(eventsEmpty), 0))).toBe(JSON.stringify(''))
expect(await join(jsonStreamFromEvents(asyncEvents(eventsHello), 0))).toBe(JSON.stringify('hello'))
expect(await join(jsonStreamFromEvents(asyncEvents(eventsQuotes), 0))).toBe(JSON.stringify('with "quotes"'))
})
})
describe('empty containers', () => {
it('converts empty array events', async () => {
const events = [
{ type: 'startArray' as const, length: 0 },
{ type: 'endArray' as const },
]
expect(await join(jsonStreamFromEvents(asyncEvents(events), 0))).toBe(JSON.stringify([], null, 0))
expect(await join(jsonStreamFromEvents(asyncEvents(events), 2))).toBe(JSON.stringify([], null, 2))
})
it('converts empty object events', async () => {
const events = [
{ type: 'startObject' as const },
{ type: 'endObject' as const },
]
expect(await join(jsonStreamFromEvents(asyncEvents(events), 0))).toBe(JSON.stringify({}, null, 0))
expect(await join(jsonStreamFromEvents(asyncEvents(events), 2))).toBe(JSON.stringify({}, null, 2))
})
})
describe('arrays', () => {
it('converts simple array events with compact formatting', async () => {
const events = [
{ type: 'startArray' as const, length: 3 },
{ type: 'primitive' as const, value: 1 },
{ type: 'primitive' as const, value: 2 },
{ type: 'primitive' as const, value: 3 },
{ type: 'endArray' as const },
]
const value = [1, 2, 3]
expect(await join(jsonStreamFromEvents(asyncEvents(events), 0))).toBe(JSON.stringify(value, null, 0))
})
it('converts simple array events with pretty formatting', async () => {
const events = [
{ type: 'startArray' as const, length: 3 },
{ type: 'primitive' as const, value: 1 },
{ type: 'primitive' as const, value: 2 },
{ type: 'primitive' as const, value: 3 },
{ type: 'endArray' as const },
]
const value = [1, 2, 3]
expect(await join(jsonStreamFromEvents(asyncEvents(events), 2))).toBe(JSON.stringify(value, null, 2))
})
it('converts mixed-type array events', async () => {
const events = [
{ type: 'startArray' as const, length: 5 },
{ type: 'primitive' as const, value: 1 },
{ type: 'primitive' as const, value: 'two' },
{ type: 'primitive' as const, value: true },
{ type: 'primitive' as const, value: null },
{ type: 'startObject' as const },
{ type: 'key' as const, key: 'key' },
{ type: 'primitive' as const, value: 'value' },
{ type: 'endObject' as const },
{ type: 'endArray' as const },
]
const value = [1, 'two', true, null, { key: 'value' }]
expect(await join(jsonStreamFromEvents(asyncEvents(events), 0))).toBe(JSON.stringify(value, null, 0))
expect(await join(jsonStreamFromEvents(asyncEvents(events), 2))).toBe(JSON.stringify(value, null, 2))
})
it('converts nested array events', async () => {
const events = [
{ type: 'startArray' as const, length: 3 },
{ type: 'startArray' as const, length: 2 },
{ type: 'primitive' as const, value: 1 },
{ type: 'primitive' as const, value: 2 },
{ type: 'endArray' as const },
{ type: 'startArray' as const, length: 2 },
{ type: 'primitive' as const, value: 3 },
{ type: 'primitive' as const, value: 4 },
{ type: 'endArray' as const },
{ type: 'startArray' as const, length: 2 },
{ type: 'primitive' as const, value: 5 },
{ type: 'primitive' as const, value: 6 },
{ type: 'endArray' as const },
{ type: 'endArray' as const },
]
const value = [[1, 2], [3, 4], [5, 6]]
expect(await join(jsonStreamFromEvents(asyncEvents(events), 0))).toBe(JSON.stringify(value, null, 0))
expect(await join(jsonStreamFromEvents(asyncEvents(events), 2))).toBe(JSON.stringify(value, null, 2))
})
})
describe('objects', () => {
it('converts simple object events with compact formatting', async () => {
const events = [
{ type: 'startObject' as const },
{ type: 'key' as const, key: 'a' },
{ type: 'primitive' as const, value: 1 },
{ type: 'key' as const, key: 'b' },
{ type: 'primitive' as const, value: 2 },
{ type: 'key' as const, key: 'c' },
{ type: 'primitive' as const, value: 3 },
{ type: 'endObject' as const },
]
const value = { a: 1, b: 2, c: 3 }
expect(await join(jsonStreamFromEvents(asyncEvents(events), 0))).toBe(JSON.stringify(value, null, 0))
})
it('converts simple object events with pretty formatting', async () => {
const events = [
{ type: 'startObject' as const },
{ type: 'key' as const, key: 'a' },
{ type: 'primitive' as const, value: 1 },
{ type: 'key' as const, key: 'b' },
{ type: 'primitive' as const, value: 2 },
{ type: 'key' as const, key: 'c' },
{ type: 'primitive' as const, value: 3 },
{ type: 'endObject' as const },
]
const value = { a: 1, b: 2, c: 3 }
expect(await join(jsonStreamFromEvents(asyncEvents(events), 2))).toBe(JSON.stringify(value, null, 2))
})
it('converts object events with mixed value types', async () => {
const events = [
{ type: 'startObject' as const },
{ type: 'key' as const, key: 'num' },
{ type: 'primitive' as const, value: 42 },
{ type: 'key' as const, key: 'str' },
{ type: 'primitive' as const, value: 'hello' },
{ type: 'key' as const, key: 'bool' },
{ type: 'primitive' as const, value: true },
{ type: 'key' as const, key: 'nil' },
{ type: 'primitive' as const, value: null },
{ type: 'key' as const, key: 'arr' },
{ type: 'startArray' as const, length: 3 },
{ type: 'primitive' as const, value: 1 },
{ type: 'primitive' as const, value: 2 },
{ type: 'primitive' as const, value: 3 },
{ type: 'endArray' as const },
{ type: 'endObject' as const },
]
const value = {
num: 42,
str: 'hello',
bool: true,
nil: null,
arr: [1, 2, 3],
}
expect(await join(jsonStreamFromEvents(asyncEvents(events), 0))).toBe(JSON.stringify(value, null, 0))
expect(await join(jsonStreamFromEvents(asyncEvents(events), 2))).toBe(JSON.stringify(value, null, 2))
})
it('converts nested object events', async () => {
const events = [
{ type: 'startObject' as const },
{ type: 'key' as const, key: 'level1' },
{ type: 'startObject' as const },
{ type: 'key' as const, key: 'level2' },
{ type: 'startObject' as const },
{ type: 'key' as const, key: 'level3' },
{ type: 'primitive' as const, value: 'deep' },
{ type: 'endObject' as const },
{ type: 'endObject' as const },
{ type: 'endObject' as const },
]
const value = {
level1: {
level2: {
level3: 'deep',
},
},
}
expect(await join(jsonStreamFromEvents(asyncEvents(events), 0))).toBe(JSON.stringify(value, null, 0))
expect(await join(jsonStreamFromEvents(asyncEvents(events), 2))).toBe(JSON.stringify(value, null, 2))
})
it('handles special characters in keys', async () => {
const events = [
{ type: 'startObject' as const },
{ type: 'key' as const, key: 'normal-key' },
{ type: 'primitive' as const, value: 1 },
{ type: 'key' as const, key: 'key with spaces' },
{ type: 'primitive' as const, value: 2 },
{ type: 'key' as const, key: 'key:with:colons' },
{ type: 'primitive' as const, value: 3 },
{ type: 'key' as const, key: 'key"with"quotes' },
{ type: 'primitive' as const, value: 4 },
{ type: 'endObject' as const },
]
const value = {
'normal-key': 1,
'key with spaces': 2,
'key:with:colons': 3,
'key"with"quotes': 4,
}
expect(await join(jsonStreamFromEvents(asyncEvents(events), 0))).toBe(JSON.stringify(value, null, 0))
expect(await join(jsonStreamFromEvents(asyncEvents(events), 2))).toBe(JSON.stringify(value, null, 2))
})
})
describe('complex nested structures', () => {
it('converts object containing arrays', async () => {
const events = [
{ type: 'startObject' as const },
{ type: 'key' as const, key: 'name' },
{ type: 'primitive' as const, value: 'Alice' },
{ type: 'key' as const, key: 'scores' },
{ type: 'startArray' as const, length: 3 },
{ type: 'primitive' as const, value: 95 },
{ type: 'primitive' as const, value: 87 },
{ type: 'primitive' as const, value: 92 },
{ type: 'endArray' as const },
{ type: 'key' as const, key: 'metadata' },
{ type: 'startObject' as const },
{ type: 'key' as const, key: 'tags' },
{ type: 'startArray' as const, length: 2 },
{ type: 'primitive' as const, value: 'math' },
{ type: 'primitive' as const, value: 'science' },
{ type: 'endArray' as const },
{ type: 'endObject' as const },
{ type: 'endObject' as const },
]
const value = {
name: 'Alice',
scores: [95, 87, 92],
metadata: {
tags: ['math', 'science'],
},
}
expect(await join(jsonStreamFromEvents(asyncEvents(events), 0))).toBe(JSON.stringify(value, null, 0))
expect(await join(jsonStreamFromEvents(asyncEvents(events), 2))).toBe(JSON.stringify(value, null, 2))
})
it('converts array of objects', async () => {
const events = [
{ type: 'startArray' as const, length: 3 },
{ type: 'startObject' as const },
{ type: 'key' as const, key: 'id' },
{ type: 'primitive' as const, value: 1 },
{ type: 'key' as const, key: 'name' },
{ type: 'primitive' as const, value: 'Alice' },
{ type: 'endObject' as const },
{ type: 'startObject' as const },
{ type: 'key' as const, key: 'id' },
{ type: 'primitive' as const, value: 2 },
{ type: 'key' as const, key: 'name' },
{ type: 'primitive' as const, value: 'Bob' },
{ type: 'endObject' as const },
{ type: 'startObject' as const },
{ type: 'key' as const, key: 'id' },
{ type: 'primitive' as const, value: 3 },
{ type: 'key' as const, key: 'name' },
{ type: 'primitive' as const, value: 'Charlie' },
{ type: 'endObject' as const },
{ type: 'endArray' as const },
]
const value = [
{ id: 1, name: 'Alice' },
{ id: 2, name: 'Bob' },
{ id: 3, name: 'Charlie' },
]
expect(await join(jsonStreamFromEvents(asyncEvents(events), 0))).toBe(JSON.stringify(value, null, 0))
expect(await join(jsonStreamFromEvents(asyncEvents(events), 2))).toBe(JSON.stringify(value, null, 2))
})
})
describe('indentation levels', () => {
const events = [
{ type: 'startObject' as const },
{ type: 'key' as const, key: 'a' },
{ type: 'startArray' as const, length: 2 },
{ type: 'primitive' as const, value: 1 },
{ type: 'primitive' as const, value: 2 },
{ type: 'endArray' as const },
{ type: 'key' as const, key: 'b' },
{ type: 'startObject' as const },
{ type: 'key' as const, key: 'c' },
{ type: 'primitive' as const, value: 3 },
{ type: 'endObject' as const },
{ type: 'endObject' as const },
]
const value = { a: [1, 2], b: { c: 3 } }
it('handles indent=0 (compact)', async () => {
expect(await join(jsonStreamFromEvents(asyncEvents(events), 0))).toBe(JSON.stringify(value, null, 0))
})
it('handles indent=2', async () => {
expect(await join(jsonStreamFromEvents(asyncEvents(events), 2))).toBe(JSON.stringify(value, null, 2))
})
it('handles indent=4', async () => {
expect(await join(jsonStreamFromEvents(asyncEvents(events), 4))).toBe(JSON.stringify(value, null, 4))
})
it('handles indent=8', async () => {
expect(await join(jsonStreamFromEvents(asyncEvents(events), 8))).toBe(JSON.stringify(value, null, 8))
})
})
describe('error handling', () => {
it('throws on mismatched endObject event', async () => {
const events = [
{ type: 'startArray' as const, length: 0 },
{ type: 'endObject' as const }, // Wrong closing event
]
await expect(async () => {
await join(jsonStreamFromEvents(asyncEvents(events), 0))
}).rejects.toThrow('Mismatched endObject event')
})
it('throws on mismatched endArray event', async () => {
const events = [
{ type: 'startObject' as const },
{ type: 'endArray' as const }, // Wrong closing event
]
await expect(async () => {
await join(jsonStreamFromEvents(asyncEvents(events), 0))
}).rejects.toThrow('Mismatched endArray event')
})
it('throws on key event outside object context', async () => {
const events = [
{ type: 'key' as const, key: 'invalid' },
{ type: 'primitive' as const, value: 1 },
]
await expect(async () => {
await join(jsonStreamFromEvents(asyncEvents(events), 0))
}).rejects.toThrow('Key event outside of object context')
})
it('throws on primitive in object without preceding key', async () => {
const events = [
{ type: 'startObject' as const },
{ type: 'primitive' as const, value: 'invalid' }, // No key before primitive
{ type: 'endObject' as const },
]
await expect(async () => {
await join(jsonStreamFromEvents(asyncEvents(events), 0))
}).rejects.toThrow('Primitive event in object without preceding key')
})
it('throws on incomplete event stream', async () => {
const events = [
{ type: 'startObject' as const },
{ type: 'key' as const, key: 'name' },
{ type: 'primitive' as const, value: 'Alice' },
// Missing `endObject`
]
await expect(async () => {
await join(jsonStreamFromEvents(asyncEvents(events), 0))
}).rejects.toThrow('Incomplete event stream: unclosed objects or arrays')
})
})
})
/**
* Converts array of events to async iterable.
*/
async function* asyncEvents(events: JsonStreamEvent[]): AsyncIterable<JsonStreamEvent> {
for (const event of events) {
await Promise.resolve()
yield event
}
}
/**
* Joins chunks from an async iterable into a single string.
*/
async function join(iter: AsyncIterable<string>): Promise<string> {
const chunks: string[] = []
for await (const chunk of iter) {
chunks.push(chunk)
}
return chunks.join('')
}
@@ -0,0 +1,245 @@
import { describe, expect, it } from 'vitest'
import { jsonStringifyLines } from '../src/json-stringify-stream'
describe('jsonStringifyLines', () => {
describe('primitives', () => {
it('stringifies null', () => {
expect(join(jsonStringifyLines(null, 0))).toBe(JSON.stringify(null))
expect(join(jsonStringifyLines(null, 2))).toBe(JSON.stringify(null, null, 2))
})
it('stringifies booleans', () => {
expect(join(jsonStringifyLines(true, 0))).toBe(JSON.stringify(true))
expect(join(jsonStringifyLines(false, 0))).toBe(JSON.stringify(false))
expect(join(jsonStringifyLines(true, 2))).toBe(JSON.stringify(true, null, 2))
})
it('stringifies numbers', () => {
expect(join(jsonStringifyLines(0, 0))).toBe(JSON.stringify(0))
expect(join(jsonStringifyLines(42, 0))).toBe(JSON.stringify(42))
expect(join(jsonStringifyLines(-17, 0))).toBe(JSON.stringify(-17))
expect(join(jsonStringifyLines(3.14159, 0))).toBe(JSON.stringify(3.14159))
expect(join(jsonStringifyLines(1e10, 2))).toBe(JSON.stringify(1e10, null, 2))
})
it('stringifies strings', () => {
expect(join(jsonStringifyLines('', 0))).toBe(JSON.stringify(''))
expect(join(jsonStringifyLines('hello', 0))).toBe(JSON.stringify('hello'))
expect(join(jsonStringifyLines('with "quotes"', 0))).toBe(JSON.stringify('with "quotes"'))
expect(join(jsonStringifyLines('with\nnewlines', 2))).toBe(JSON.stringify('with\nnewlines', null, 2))
expect(join(jsonStringifyLines('with\ttabs', 0))).toBe(JSON.stringify('with\ttabs'))
})
it('converts undefined to null', () => {
expect(join(jsonStringifyLines(undefined, 0))).toBe('null')
expect(join(jsonStringifyLines(undefined, 2))).toBe('null')
})
})
describe('empty containers', () => {
it('stringifies empty arrays', () => {
expect(join(jsonStringifyLines([], 0))).toBe(JSON.stringify([], null, 0))
expect(join(jsonStringifyLines([], 2))).toBe(JSON.stringify([], null, 2))
})
it('stringifies empty objects', () => {
expect(join(jsonStringifyLines({}, 0))).toBe(JSON.stringify({}, null, 0))
expect(join(jsonStringifyLines({}, 2))).toBe(JSON.stringify({}, null, 2))
})
})
describe('arrays', () => {
it('stringifies arrays with compact formatting (indent=0)', () => {
const value = [1, 2, 3]
expect(join(jsonStringifyLines(value, 0))).toBe(JSON.stringify(value, null, 0))
})
it('stringifies arrays with pretty formatting (indent=2)', () => {
const value = [1, 2, 3]
expect(join(jsonStringifyLines(value, 2))).toBe(JSON.stringify(value, null, 2))
})
it('stringifies mixed-type arrays', () => {
const value = [1, 'two', true, null, { key: 'value' }]
expect(join(jsonStringifyLines(value, 0))).toBe(JSON.stringify(value, null, 0))
expect(join(jsonStringifyLines(value, 2))).toBe(JSON.stringify(value, null, 2))
})
it('stringifies nested arrays', () => {
const value = [[1, 2], [3, 4], [5, 6]]
expect(join(jsonStringifyLines(value, 0))).toBe(JSON.stringify(value, null, 0))
expect(join(jsonStringifyLines(value, 2))).toBe(JSON.stringify(value, null, 2))
})
it('stringifies deeply nested arrays', () => {
const value = [[[1]], [[2]], [[3]]]
expect(join(jsonStringifyLines(value, 2))).toBe(JSON.stringify(value, null, 2))
expect(join(jsonStringifyLines(value, 4))).toBe(JSON.stringify(value, null, 4))
})
})
describe('objects', () => {
it('stringifies simple objects with compact formatting', () => {
const value = { a: 1, b: 2, c: 3 }
expect(join(jsonStringifyLines(value, 0))).toBe(JSON.stringify(value, null, 0))
})
it('stringifies simple objects with pretty formatting', () => {
const value = { a: 1, b: 2, c: 3 }
expect(join(jsonStringifyLines(value, 2))).toBe(JSON.stringify(value, null, 2))
})
it('stringifies objects with mixed value types', () => {
const value = {
num: 42,
str: 'hello',
bool: true,
nil: null,
arr: [1, 2, 3],
}
expect(join(jsonStringifyLines(value, 0))).toBe(JSON.stringify(value, null, 0))
expect(join(jsonStringifyLines(value, 2))).toBe(JSON.stringify(value, null, 2))
})
it('stringifies nested objects', () => {
const value = {
level1: {
level2: {
level3: 'deep',
},
},
}
expect(join(jsonStringifyLines(value, 0))).toBe(JSON.stringify(value, null, 0))
expect(join(jsonStringifyLines(value, 2))).toBe(JSON.stringify(value, null, 2))
})
it('preserves key order', () => {
const value = { z: 1, a: 2, m: 3 }
expect(join(jsonStringifyLines(value, 0))).toBe(JSON.stringify(value, null, 0))
expect(join(jsonStringifyLines(value, 2))).toBe(JSON.stringify(value, null, 2))
})
it('handles special characters in keys', () => {
const value = {
'normal-key': 1,
'key with spaces': 2,
'key:with:colons': 3,
'key"with"quotes': 4,
}
expect(join(jsonStringifyLines(value, 0))).toBe(JSON.stringify(value, null, 0))
expect(join(jsonStringifyLines(value, 2))).toBe(JSON.stringify(value, null, 2))
})
})
describe('complex nested structures', () => {
it('stringifies objects containing arrays', () => {
const value = {
name: 'Alice',
scores: [95, 87, 92],
metadata: {
tags: ['math', 'science'],
},
}
expect(join(jsonStringifyLines(value, 0))).toBe(JSON.stringify(value, null, 0))
expect(join(jsonStringifyLines(value, 2))).toBe(JSON.stringify(value, null, 2))
})
it('stringifies arrays of objects', () => {
const value = [
{ id: 1, name: 'Alice' },
{ id: 2, name: 'Bob' },
{ id: 3, name: 'Charlie' },
]
expect(join(jsonStringifyLines(value, 0))).toBe(JSON.stringify(value, null, 0))
expect(join(jsonStringifyLines(value, 2))).toBe(JSON.stringify(value, null, 2))
})
it('stringifies deeply nested mixed structures', () => {
const value = {
users: [
{
name: 'Alice',
roles: ['admin', 'user'],
settings: {
theme: 'dark',
notifications: true,
},
},
{
name: 'Bob',
roles: ['user'],
settings: {
theme: 'light',
notifications: false,
},
},
],
count: 2,
}
expect(join(jsonStringifyLines(value, 0))).toBe(JSON.stringify(value, null, 0))
expect(join(jsonStringifyLines(value, 2))).toBe(JSON.stringify(value, null, 2))
})
})
describe('indentation levels', () => {
const value = { a: [1, 2], b: { c: 3 } }
it('handles indent=0 (compact)', () => {
expect(join(jsonStringifyLines(value, 0))).toBe(JSON.stringify(value, null, 0))
})
it('handles indent=2', () => {
expect(join(jsonStringifyLines(value, 2))).toBe(JSON.stringify(value, null, 2))
})
it('handles indent=4', () => {
expect(join(jsonStringifyLines(value, 4))).toBe(JSON.stringify(value, null, 4))
})
it('handles indent=8', () => {
expect(join(jsonStringifyLines(value, 8))).toBe(JSON.stringify(value, null, 8))
})
})
describe('edge cases', () => {
it('handles arrays with undefined values (converted to null)', () => {
const value = [1, undefined, 3]
const expected = JSON.stringify(value, null, 2)
expect(join(jsonStringifyLines(value, 2))).toBe(expected)
})
it('handles single-element arrays', () => {
const value = [42]
expect(join(jsonStringifyLines(value, 0))).toBe(JSON.stringify(value, null, 0))
expect(join(jsonStringifyLines(value, 2))).toBe(JSON.stringify(value, null, 2))
})
it('handles single-property objects', () => {
const value = { only: 'one' }
expect(join(jsonStringifyLines(value, 0))).toBe(JSON.stringify(value, null, 0))
expect(join(jsonStringifyLines(value, 2))).toBe(JSON.stringify(value, null, 2))
})
it('handles objects with many properties', () => {
const value: Record<string, number> = {}
for (let i = 0; i < 100; i++) {
value[`key${i}`] = i
}
expect(join(jsonStringifyLines(value, 0))).toBe(JSON.stringify(value, null, 0))
expect(join(jsonStringifyLines(value, 2))).toBe(JSON.stringify(value, null, 2))
})
it('handles large arrays', () => {
const value = Array.from({ length: 1000 }, (_, i) => i)
expect(join(jsonStringifyLines(value, 0))).toBe(JSON.stringify(value, null, 0))
expect(join(jsonStringifyLines(value, 2))).toBe(JSON.stringify(value, null, 2))
})
})
})
/**
* Joins chunks from an iterable into a single string.
*/
function join(iter: Iterable<string>): string {
return Array.from(iter).join('')
}
+18
View File
@@ -2,6 +2,7 @@ import * as fsp from 'node:fs/promises'
import * as os from 'node:os'
import * as path from 'node:path'
import process from 'node:process'
import { Readable } from 'node:stream'
import { runMain } from 'citty'
import { mainCommand } from '../src/index'
@@ -76,3 +77,20 @@ async function writeFiles(baseDir: string, files: FileRecord): Promise<void> {
}),
)
}
export function mockStdin(input: string): () => void {
const mockStream = Readable.from([input])
const originalStdin = process.stdin
Object.defineProperty(process, 'stdin', {
value: mockStream,
writable: true,
})
return () => {
Object.defineProperty(process, 'stdin', {
value: originalStdin,
writable: true,
})
}
}
+2 -2
View File
@@ -1,7 +1,7 @@
import type { UserConfig, UserConfigFn } from 'tsdown/config'
import type { UserConfig } from 'tsdown/config'
import { defineConfig } from 'tsdown/config'
const config: UserConfig | UserConfigFn = defineConfig({
const config: UserConfig = defineConfig({
entry: {
index: 'src/cli-entry.ts',
},
+923
View File
@@ -0,0 +1,923 @@
![TOON logo with stepbystep guide](./.github/og.png)
# Token-Oriented Object Notation (TOON)
[![CI](https://github.com/toon-format/toon/actions/workflows/ci.yml/badge.svg)](https://github.com/toon-format/toon/actions)
[![npm version](https://img.shields.io/npm/v/@toon-format/toon.svg?labelColor=1b1b1f&color=fef3c0)](https://www.npmjs.com/package/@toon-format/toon)
[![SPEC v3.3](https://img.shields.io/badge/spec-v3.3-fef3c0?labelColor=1b1b1f)](https://github.com/toon-format/spec)
[![npm downloads (total)](https://img.shields.io/npm/dt/@toon-format/toon.svg?labelColor=1b1b1f&color=fef3c0)](https://www.npmjs.com/package/@toon-format/toon)
[![License: MIT](https://img.shields.io/badge/license-MIT-fef3c0?labelColor=1b1b1f)](./LICENSE)
**Token-Oriented Object Notation** is a compact, human-readable encoding of the JSON data model that minimizes tokens and makes structure easy for models to follow. It's intended for *LLM input* as a drop-in, lossless representation of your existing JSON.
TOON combines YAML's indentation-based structure for nested objects with a CSV-style tabular layout for uniform arrays. TOON's sweet spot is uniform arrays of objects (multiple fields per row, same structure across items), achieving CSV-like compactness while adding explicit structure that helps LLMs parse and validate data reliably. For deeply nested or non-uniform data, JSON may be more efficient.
The similarity to CSV is intentional: CSV is simple and ubiquitous, and TOON aims to keep that familiarity while remaining a lossless, drop-in representation of JSON for Large Language Models.
Think of it as a translation layer: use JSON programmatically, and encode it as TOON for LLM input.
> [!TIP]
> The TOON format is stable, but also an idea in progress. Nothing's set in stone help shape where it goes by contributing to the [spec](https://github.com/toon-format/spec) or sharing feedback.
## Table of Contents
- [Why TOON?](#why-toon)
- [Key Features](#key-features)
- [When Not to Use TOON](#when-not-to-use-toon)
- [Benchmarks](#benchmarks)
- [Installation & Quick Start](#installation--quick-start)
- [Playgrounds](#playgrounds)
- [Editor Support](#editor-support)
- [CLI](#cli)
- [Format Overview](#format-overview)
- [Using TOON with LLMs](#using-toon-with-llms)
- [Documentation](#documentation)
- [Other Implementations](#other-implementations)
- [📋 Full Specification](https://github.com/toon-format/spec/blob/main/SPEC.md)
## Why TOON?
AI is becoming cheaper and more accessible, but larger context windows allow for larger data inputs as well. **LLM tokens still cost money** and standard JSON is verbose and token-expensive:
```json
{
"context": {
"task": "Our favorite hikes together",
"location": "Boulder",
"season": "spring_2025"
},
"friends": ["ana", "luis", "sam"],
"hikes": [
{
"id": 1,
"name": "Blue Lake Trail",
"distanceKm": 7.5,
"elevationGain": 320,
"companion": "ana",
"wasSunny": true
},
{
"id": 2,
"name": "Ridge Overlook",
"distanceKm": 9.2,
"elevationGain": 540,
"companion": "luis",
"wasSunny": false
},
{
"id": 3,
"name": "Wildflower Loop",
"distanceKm": 5.1,
"elevationGain": 180,
"companion": "sam",
"wasSunny": true
}
]
}
```
<details>
<summary>YAML already conveys the same information with <strong>fewer tokens</strong>.</summary>
```yaml
context:
task: Our favorite hikes together
location: Boulder
season: spring_2025
friends:
- ana
- luis
- sam
hikes:
- id: 1
name: Blue Lake Trail
distanceKm: 7.5
elevationGain: 320
companion: ana
wasSunny: true
- id: 2
name: Ridge Overlook
distanceKm: 9.2
elevationGain: 540
companion: luis
wasSunny: false
- id: 3
name: Wildflower Loop
distanceKm: 5.1
elevationGain: 180
companion: sam
wasSunny: true
```
</details>
TOON conveys the same information with **even fewer tokens** combining YAML-like indentation with CSV-style tabular arrays:
```yaml
context:
task: Our favorite hikes together
location: Boulder
season: spring_2025
friends[3]: ana,luis,sam
hikes[3]{id,name,distanceKm,elevationGain,companion,wasSunny}:
1,Blue Lake Trail,7.5,320,ana,true
2,Ridge Overlook,9.2,540,luis,false
3,Wildflower Loop,5.1,180,sam,true
```
## Key Features
- 📊 **Token-Efficient & Accurate:** TOON reaches 76.4% accuracy (vs JSON's 75.0%) while using ~40% fewer tokens in mixed-structure benchmarks across 4 models.
- 🔁 **JSON Data Model:** Encodes the same objects, arrays, and primitives as JSON with deterministic, lossless round-trips.
- 🛤️ **LLM-Friendly Guardrails:** Explicit [N] lengths and {fields} headers give models a clear schema to follow, improving parsing reliability.
- 📐 **Minimal Syntax:** Uses indentation instead of braces and minimizes quoting, giving YAML-like readability with CSV-style compactness.
- 🧺 **Tabular Arrays:** Uniform arrays of objects collapse into tables that declare fields once and stream row values line by line.
- 🌐 **Multi-Language Ecosystem:** Spec-driven implementations in TypeScript, Python, Go, Rust, .NET, and other languages.
## Media Type & File Extension
By convention, TOON files use the `.toon` extension and the provisional media type `text/toon` for HTTP and content-typeaware contexts. TOON documents are always UTF-8 encoded; the `charset=utf-8` parameter may be specified but defaults to UTF-8 when omitted. See [SPEC.md §17](https://github.com/toon-format/spec/blob/main/SPEC.md#17-iana-considerations) for normative details.
## When Not to Use TOON
TOON excels with uniform arrays of objects, but there are cases where other formats are better:
- **Deeply nested or non-uniform structures** (tabular eligibility ≈ 0%): JSON-compact often uses fewer tokens. Example: complex configuration objects with many nested levels.
- **Semi-uniform arrays** (~4060% tabular eligibility): Token savings diminish. Prefer JSON if your pipelines already rely on it.
- **Pure tabular data**: CSV is smaller than TOON for flat tables. TOON adds minimal overhead (~510%) to provide structure (array length declarations, field headers, delimiter scoping) that improves LLM reliability.
- **Latency-critical applications**: If end-to-end response time is your top priority, benchmark on your exact setup. Some deployments (especially local/quantized models like Ollama) may process compact JSON faster despite TOON's lower token count. Measure TTFT, tokens/sec, and total time for both formats and use whichever is faster.
See [benchmarks](#benchmarks) for concrete comparisons across different data structures.
## Benchmarks
Benchmarks are organized into two tracks to ensure fair comparisons:
- **Mixed-Structure Track**: Datasets with nested or semi-uniform structures (TOON vs JSON, YAML, XML). CSV excluded as it cannot properly represent these structures.
- **Flat-Only Track**: Datasets with flat tabular structures where CSV is applicable (CSV vs TOON vs JSON, YAML, XML).
### Retrieval Accuracy
<!-- automd:file src="./benchmarks/results/retrieval-accuracy.md" -->
Benchmarks test LLM comprehension across different input formats using 209 data retrieval questions on 4 models.
<details>
<summary><strong>Show Dataset Catalog</strong></summary>
#### Dataset Catalog
| Dataset | Rows | Structure | CSV Support | Eligibility |
| ------- | ---- | --------- | ----------- | ----------- |
| Uniform employee records | 100 | uniform | ✓ | 100% |
| E-commerce orders with nested structures | 50 | nested | ✗ | 33% |
| Time-series analytics data | 60 | uniform | ✓ | 100% |
| Top 100 GitHub repositories | 100 | uniform | ✓ | 100% |
| Semi-uniform event logs | 75 | semi-uniform | ✗ | 50% |
| Deeply nested configuration | 11 | deep | ✗ | 0% |
| Valid complete dataset (control) | 20 | uniform | ✓ | 100% |
| Array truncated: 3 rows removed from end | 17 | uniform | ✓ | 100% |
| Extra rows added beyond declared length | 23 | uniform | ✓ | 100% |
| Inconsistent field count (missing salary in row 10) | 20 | uniform | ✓ | 100% |
| Missing required fields (no email in multiple rows) | 20 | uniform | ✓ | 100% |
**Structure classes:**
- **uniform**: All objects have identical fields with primitive values
- **semi-uniform**: Mix of uniform and non-uniform structures
- **nested**: Objects with nested structures (nested objects or arrays)
- **deep**: Highly nested with minimal tabular eligibility
**CSV Support:** ✓ (supported), ✗ (not supported would require lossy flattening)
**Eligibility:** Percentage of arrays that qualify for TOON's tabular format (uniform objects with primitive values)
</details>
#### Efficiency Ranking (Accuracy per 1K Tokens)
Each format ranked by efficiency (accuracy percentage per 1,000 tokens):
```
TOON ████████████████████ 27.7 acc%/1K tok │ 76.4% acc │ 2,759 tokens
JSON compact █████████████████░░░ 23.7 acc%/1K tok │ 73.7% acc │ 3,104 tokens
YAML ██████████████░░░░░░ 19.9 acc%/1K tok │ 74.5% acc │ 3,749 tokens
JSON ████████████░░░░░░░░ 16.4 acc%/1K tok │ 75.0% acc │ 4,587 tokens
XML ██████████░░░░░░░░░░ 13.8 acc%/1K tok │ 72.1% acc │ 5,221 tokens
```
*Efficiency score = (Accuracy % ÷ Tokens) × 1,000. Higher is better.*
> [!TIP]
> TOON achieves **76.4%** accuracy (vs JSON's 75.0%) while using **39.9% fewer tokens**.
**Note on CSV:** Excluded from ranking as it only supports 109 of 209 questions (flat tabular data only). While CSV is highly token-efficient for simple tabular data, it cannot represent nested structures that other formats handle.
#### Per-Model Accuracy
Accuracy across 4 LLMs on 209 data retrieval questions:
```
claude-haiku-4-5-20251001
→ TOON ████████████░░░░░░░░ 59.8% (125/209)
JSON ███████████░░░░░░░░░ 57.4% (120/209)
YAML ███████████░░░░░░░░░ 56.0% (117/209)
XML ███████████░░░░░░░░░ 55.5% (116/209)
JSON compact ███████████░░░░░░░░░ 55.0% (115/209)
CSV ██████████░░░░░░░░░░ 50.5% (55/109)
gemini-3-flash-preview
XML ████████████████████ 98.1% (205/209)
JSON ███████████████████░ 97.1% (203/209)
YAML ███████████████████░ 97.1% (203/209)
→ TOON ███████████████████░ 96.7% (202/209)
JSON compact ███████████████████░ 96.7% (202/209)
CSV ███████████████████░ 96.3% (105/109)
gpt-5-nano
→ TOON ██████████████████░░ 90.9% (190/209)
JSON compact ██████████████████░░ 90.9% (190/209)
JSON ██████████████████░░ 89.0% (186/209)
CSV ██████████████████░░ 89.0% (97/109)
YAML █████████████████░░░ 87.1% (182/209)
XML ████████████████░░░░ 80.9% (169/209)
grok-4-1-fast-non-reasoning
→ TOON ████████████░░░░░░░░ 58.4% (122/209)
YAML ████████████░░░░░░░░ 57.9% (121/209)
JSON ███████████░░░░░░░░░ 56.5% (118/209)
XML ███████████░░░░░░░░░ 54.1% (113/209)
JSON compact ██████████░░░░░░░░░░ 52.2% (109/209)
CSV ██████████░░░░░░░░░░ 51.4% (56/109)
```
> [!TIP]
> TOON achieves **76.4% accuracy** (vs JSON's 75.0%) while using **39.9% fewer tokens** on these datasets.
<details>
<summary><strong>Performance by dataset, model, and question type</strong></summary>
#### Performance by Question Type
| Question Type | TOON | JSON | YAML | JSON compact | XML | CSV |
| ------------- | ---- | ---- | ---- | ---- | ---- | ---- |
| Field Retrieval | 99.6% | 99.3% | 98.5% | 98.5% | 98.9% | 100.0% |
| Aggregation | 61.9% | 61.9% | 59.9% | 58.3% | 54.4% | 50.9% |
| Filtering | 56.8% | 53.1% | 56.3% | 55.2% | 51.6% | 50.9% |
| Structure Awareness | 89.0% | 87.0% | 84.0% | 84.0% | 81.0% | 85.9% |
| Structural Validation | 70.0% | 60.0% | 60.0% | 55.0% | 85.0% | 80.0% |
#### Performance by Dataset
##### Uniform employee records
| Format | Accuracy | Tokens | Correct/Total |
| ------ | -------- | ------ | ------------- |
| `csv` | 73.2% | 2,334 | 120/164 |
| `toon` | 73.2% | 2,498 | 120/164 |
| `json-compact` | 73.8% | 3,924 | 121/164 |
| `yaml` | 73.8% | 4,959 | 121/164 |
| `json-pretty` | 73.8% | 6,331 | 121/164 |
| `xml` | 74.4% | 7,296 | 122/164 |
##### E-commerce orders with nested structures
| Format | Accuracy | Tokens | Correct/Total |
| ------ | -------- | ------ | ------------- |
| `toon` | 82.3% | 7,458 | 135/164 |
| `json-compact` | 78.7% | 7,110 | 129/164 |
| `yaml` | 79.9% | 8,755 | 131/164 |
| `json-pretty` | 79.3% | 11,234 | 130/164 |
| `xml` | 77.4% | 12,649 | 127/164 |
##### Time-series analytics data
| Format | Accuracy | Tokens | Correct/Total |
| ------ | -------- | ------ | ------------- |
| `csv` | 75.0% | 1,411 | 90/120 |
| `toon` | 78.3% | 1,553 | 94/120 |
| `json-compact` | 74.2% | 2,354 | 89/120 |
| `yaml` | 75.8% | 2,954 | 91/120 |
| `json-pretty` | 75.0% | 3,681 | 90/120 |
| `xml` | 72.5% | 4,389 | 87/120 |
##### Top 100 GitHub repositories
| Format | Accuracy | Tokens | Correct/Total |
| ------ | -------- | ------ | ------------- |
| `csv` | 65.9% | 8,527 | 87/132 |
| `toon` | 66.7% | 8,779 | 88/132 |
| `yaml` | 65.2% | 13,141 | 86/132 |
| `json-compact` | 59.8% | 11,464 | 79/132 |
| `json-pretty` | 63.6% | 15,157 | 84/132 |
| `xml` | 56.1% | 17,105 | 74/132 |
##### Semi-uniform event logs
| Format | Accuracy | Tokens | Correct/Total |
| ------ | -------- | ------ | ------------- |
| `json-compact` | 68.3% | 4,839 | 82/120 |
| `toon` | 65.0% | 5,819 | 78/120 |
| `json-pretty` | 69.2% | 6,817 | 83/120 |
| `yaml` | 61.7% | 5,847 | 74/120 |
| `xml` | 58.3% | 7,729 | 70/120 |
##### Deeply nested configuration
| Format | Accuracy | Tokens | Correct/Total |
| ------ | -------- | ------ | ------------- |
| `json-compact` | 90.5% | 568 | 105/116 |
| `toon` | 94.8% | 655 | 110/116 |
| `yaml` | 93.1% | 675 | 108/116 |
| `json-pretty` | 92.2% | 924 | 107/116 |
| `xml` | 91.4% | 1,013 | 106/116 |
##### Valid complete dataset (control)
| Format | Accuracy | Tokens | Correct/Total |
| ------ | -------- | ------ | ------------- |
| `toon` | 100.0% | 535 | 4/4 |
| `json-compact` | 100.0% | 787 | 4/4 |
| `yaml` | 100.0% | 992 | 4/4 |
| `json-pretty` | 100.0% | 1,274 | 4/4 |
| `xml` | 25.0% | 1,462 | 1/4 |
| `csv` | 0.0% | 483 | 0/4 |
##### Array truncated: 3 rows removed from end
| Format | Accuracy | Tokens | Correct/Total |
| ------ | -------- | ------ | ------------- |
| `csv` | 100.0% | 413 | 4/4 |
| `xml` | 100.0% | 1,243 | 4/4 |
| `toon` | 0.0% | 462 | 0/4 |
| `json-pretty` | 0.0% | 1,085 | 0/4 |
| `yaml` | 0.0% | 843 | 0/4 |
| `json-compact` | 0.0% | 670 | 0/4 |
##### Extra rows added beyond declared length
| Format | Accuracy | Tokens | Correct/Total |
| ------ | -------- | ------ | ------------- |
| `csv` | 100.0% | 550 | 4/4 |
| `toon` | 75.0% | 605 | 3/4 |
| `json-compact` | 75.0% | 901 | 3/4 |
| `xml` | 100.0% | 1,678 | 4/4 |
| `yaml` | 75.0% | 1,138 | 3/4 |
| `json-pretty` | 50.0% | 1,460 | 2/4 |
##### Inconsistent field count (missing salary in row 10)
| Format | Accuracy | Tokens | Correct/Total |
| ------ | -------- | ------ | ------------- |
| `csv` | 100.0% | 480 | 4/4 |
| `json-compact` | 100.0% | 782 | 4/4 |
| `yaml` | 100.0% | 985 | 4/4 |
| `toon` | 100.0% | 1,008 | 4/4 |
| `json-pretty` | 100.0% | 1,266 | 4/4 |
| `xml` | 100.0% | 1,453 | 4/4 |
##### Missing required fields (no email in multiple rows)
| Format | Accuracy | Tokens | Correct/Total |
| ------ | -------- | ------ | ------------- |
| `csv` | 100.0% | 340 | 4/4 |
| `xml` | 100.0% | 1,409 | 4/4 |
| `toon` | 75.0% | 974 | 3/4 |
| `json-pretty` | 50.0% | 1,225 | 2/4 |
| `yaml` | 25.0% | 951 | 1/4 |
| `json-compact` | 0.0% | 750 | 0/4 |
#### Performance by Model
##### claude-haiku-4-5-20251001
| Format | Accuracy | Correct/Total |
| ------ | -------- | ------------- |
| `toon` | 59.8% | 125/209 |
| `json-pretty` | 57.4% | 120/209 |
| `yaml` | 56.0% | 117/209 |
| `xml` | 55.5% | 116/209 |
| `json-compact` | 55.0% | 115/209 |
| `csv` | 50.5% | 55/109 |
##### gemini-3-flash-preview
| Format | Accuracy | Correct/Total |
| ------ | -------- | ------------- |
| `xml` | 98.1% | 205/209 |
| `json-pretty` | 97.1% | 203/209 |
| `yaml` | 97.1% | 203/209 |
| `toon` | 96.7% | 202/209 |
| `json-compact` | 96.7% | 202/209 |
| `csv` | 96.3% | 105/109 |
##### gpt-5-nano
| Format | Accuracy | Correct/Total |
| ------ | -------- | ------------- |
| `toon` | 90.9% | 190/209 |
| `json-compact` | 90.9% | 190/209 |
| `json-pretty` | 89.0% | 186/209 |
| `csv` | 89.0% | 97/109 |
| `yaml` | 87.1% | 182/209 |
| `xml` | 80.9% | 169/209 |
##### grok-4-1-fast-non-reasoning
| Format | Accuracy | Correct/Total |
| ------ | -------- | ------------- |
| `toon` | 58.4% | 122/209 |
| `yaml` | 57.9% | 121/209 |
| `json-pretty` | 56.5% | 118/209 |
| `xml` | 54.1% | 113/209 |
| `json-compact` | 52.2% | 109/209 |
| `csv` | 51.4% | 56/109 |
</details>
#### What's Being Measured
This benchmark tests **LLM comprehension and data retrieval accuracy** across different input formats. Each LLM receives formatted data and must answer questions about it. This does **not** test the model's ability to generate TOON output only to read and understand it.
#### Datasets Tested
Eleven datasets designed to test different structural patterns and validation capabilities:
**Primary datasets:**
1. **Tabular** (100 employee records): Uniform objects with identical fields optimal for TOON's tabular format.
2. **Nested** (50 e-commerce orders): Complex structures with nested customer objects and item arrays.
3. **Analytics** (60 days of metrics): Time-series data with dates and numeric values.
4. **GitHub** (100 repositories): Real-world data from top GitHub repos by stars.
5. **Event Logs** (75 logs): Semi-uniform data with ~50% flat logs and ~50% with nested error objects.
6. **Nested Config** (1 configuration): Deeply nested configuration with minimal tabular eligibility.
**Structural validation datasets:**
7. **Control**: Valid complete dataset (baseline for validation)
8. **Truncated**: Array with 3 rows removed from end (tests `[N]` length detection)
9. **Extra rows**: Array with 3 additional rows beyond declared length
10. **Width mismatch**: Inconsistent field count (missing salary in row 10)
11. **Missing fields**: Systematic field omissions (no email in multiple rows)
#### Question Types
209 questions are generated dynamically across five categories:
- **Field retrieval (33%)**: Direct value lookups or values that can be read straight off a record (including booleans and simple counts such as array lengths)
- Example: "What is Alice's salary?" → `75000`
- Example: "How many items are in order ORD-0042?" → `3`
- Example: "What is the customer name for order ORD-0042?" → `John Doe`
- **Aggregation (30%)**: Dataset-level totals and averages plus single-condition filters (counts, sums, min/max comparisons)
- Example: "How many employees work in Engineering?" → `17`
- Example: "What is the total revenue across all orders?" → `45123.50`
- Example: "How many employees have salary > 80000?" → `23`
- **Filtering (23%)**: Multi-condition queries requiring compound logic (AND constraints across fields)
- Example: "How many employees in Sales have salary > 80000?" → `5`
- Example: "How many active employees have more than 10 years of experience?" → `8`
- **Structure awareness (12%)**: Tests format-native structural affordances (TOON's `[N]` count and `{fields}`, CSV's header row)
- Example: "How many employees are in the dataset?" → `100`
- Example: "List the field names for employees" → `id, name, email, department, salary, yearsExperience, active`
- Example: "What is the department of the last employee?" → `Sales`
- **Structural validation (2%)**: Tests ability to detect incomplete, truncated, or corrupted data using structural metadata
- Example: "Is this data complete and valid?" → `YES` (control dataset) or `NO` (corrupted datasets)
- Tests TOON's `[N]` length validation and `{fields}` consistency checking
- Demonstrates CSV's lack of structural validation capabilities
#### Evaluation Process
1. **Format conversion**: Each dataset is converted to all 6 formats (TOON, JSON, YAML, JSON compact, XML, CSV).
2. **Query LLM**: Each model receives formatted data + question in a prompt and extracts the answer.
3. **Validate deterministically**: Answers are validated using type-aware comparison (e.g., `50000` = `$50,000`, `Engineering` = `engineering`, `2025-01-01` = `January 1, 2025`) without requiring an LLM judge.
#### Models & Configuration
- **Models tested**: `claude-haiku-4-5-20251001`, `gemini-3-flash-preview`, `gpt-5-nano`, `grok-4-1-fast-non-reasoning`
- **Token counting**: Using `gpt-tokenizer` with `o200k_base` encoding (GPT-5 tokenizer)
- **Temperature**: Not set (models use their defaults)
- **Total evaluations**: 209 questions × 6 formats × 4 models = 5,016 LLM calls
<!-- /automd -->
### Token Efficiency
Token counts are measured using the GPT-5 `o200k_base` tokenizer via [`gpt-tokenizer`](https://github.com/niieani/gpt-tokenizer). Savings are calculated against formatted JSON (2-space indentation) as the primary baseline, with additional comparisons to compact JSON (minified), YAML, and XML. Actual savings vary by model and tokenizer.
The benchmarks test datasets across different structural patterns (uniform, semi-uniform, nested, deeply nested) to show where TOON excels and where other formats may be better.
<!-- automd:file src="./benchmarks/results/token-efficiency.md" -->
#### Mixed-Structure Track
Datasets with nested or semi-uniform structures. CSV excluded as it cannot properly represent these structures.
```
🛒 E-commerce orders with nested structures ┊ Tabular: 33%
TOON █████████████░░░░░░░ 73,126 tokens
├─ vs JSON (33.3%) 109,599 tokens
├─ vs JSON compact (+5.3%) 69,459 tokens
├─ vs YAML (14.4%) 85,415 tokens
└─ vs XML (40.7%) 123,344 tokens
🧾 Semi-uniform event logs ┊ Tabular: 50%
TOON █████████████████░░░ 154,084 tokens
├─ vs JSON (15.0%) 181,201 tokens
├─ vs JSON compact (+19.9%) 128,529 tokens
├─ vs YAML (0.8%) 155,397 tokens
└─ vs XML (25.2%) 205,859 tokens
🧩 Deeply nested configuration ┊ Tabular: 0%
TOON ██████████████░░░░░░ 620 tokens
├─ vs JSON (31.9%) 911 tokens
├─ vs JSON compact (+11.1%) 558 tokens
├─ vs YAML (6.3%) 662 tokens
└─ vs XML (38.2%) 1,003 tokens
──────────────────────────────────── Total ────────────────────────────────────
TOON ████████████████░░░░ 227,830 tokens
├─ vs JSON (21.9%) 291,711 tokens
├─ vs JSON compact (+14.7%) 198,546 tokens
├─ vs YAML (5.7%) 241,474 tokens
└─ vs XML (31.0%) 330,206 tokens
```
#### Flat-Only Track
Datasets with flat tabular structures where CSV is applicable.
```
👥 Uniform employee records ┊ Tabular: 100%
CSV ███████████████████░ 47,102 tokens
TOON ████████████████████ 49,919 tokens (+6.0% vs CSV)
├─ vs JSON (60.7%) 127,063 tokens
├─ vs JSON compact (36.9%) 79,059 tokens
├─ vs YAML (50.1%) 100,011 tokens
└─ vs XML (65.9%) 146,579 tokens
📈 Time-series analytics data ┊ Tabular: 100%
CSV ██████████████████░░ 8,383 tokens
TOON ████████████████████ 9,115 tokens (+8.7% vs CSV)
├─ vs JSON (59.0%) 22,245 tokens
├─ vs JSON compact (35.9%) 14,211 tokens
├─ vs YAML (49.0%) 17,858 tokens
└─ vs XML (65.8%) 26,616 tokens
⭐ Top 100 GitHub repositories ┊ Tabular: 100%
CSV ███████████████████░ 8,512 tokens
TOON ████████████████████ 8,744 tokens (+2.7% vs CSV)
├─ vs JSON (42.3%) 15,144 tokens
├─ vs JSON compact (23.7%) 11,454 tokens
├─ vs YAML (33.4%) 13,128 tokens
└─ vs XML (48.9%) 17,095 tokens
──────────────────────────────────── Total ────────────────────────────────────
CSV ███████████████████░ 63,997 tokens
TOON ████████████████████ 67,778 tokens (+5.9% vs CSV)
├─ vs JSON (58.8%) 164,452 tokens
├─ vs JSON compact (35.3%) 104,724 tokens
├─ vs YAML (48.3%) 130,997 tokens
└─ vs XML (64.4%) 190,290 tokens
```
<details>
<summary><strong>Show detailed examples</strong></summary>
#### 📈 Time-series analytics data
**Savings:** 13,130 tokens (59.0% reduction vs JSON)
**JSON** (22,245 tokens):
```json
{
"metrics": [
{
"date": "2025-01-01",
"views": 6138,
"clicks": 174,
"conversions": 12,
"revenue": 2712.49,
"bounceRate": 0.35
},
{
"date": "2025-01-02",
"views": 4616,
"clicks": 274,
"conversions": 34,
"revenue": 9156.29,
"bounceRate": 0.56
},
{
"date": "2025-01-03",
"views": 4460,
"clicks": 143,
"conversions": 8,
"revenue": 1317.98,
"bounceRate": 0.59
},
{
"date": "2025-01-04",
"views": 4740,
"clicks": 125,
"conversions": 13,
"revenue": 2934.77,
"bounceRate": 0.37
},
{
"date": "2025-01-05",
"views": 6428,
"clicks": 369,
"conversions": 19,
"revenue": 1317.24,
"bounceRate": 0.3
}
]
}
```
**TOON** (9,115 tokens):
```
metrics[5]{date,views,clicks,conversions,revenue,bounceRate}:
2025-01-01,6138,174,12,2712.49,0.35
2025-01-02,4616,274,34,9156.29,0.56
2025-01-03,4460,143,8,1317.98,0.59
2025-01-04,4740,125,13,2934.77,0.37
2025-01-05,6428,369,19,1317.24,0.3
```
---
#### ⭐ Top 100 GitHub repositories
**Savings:** 6,400 tokens (42.3% reduction vs JSON)
**JSON** (15,144 tokens):
```json
{
"repositories": [
{
"id": 28457823,
"name": "freeCodeCamp",
"repo": "freeCodeCamp/freeCodeCamp",
"description": "freeCodeCamp.org's open-source codebase and curriculum. Learn math, programming,…",
"createdAt": "2014-12-24T17:49:19Z",
"updatedAt": "2025-10-28T11:58:08Z",
"pushedAt": "2025-10-28T10:17:16Z",
"stars": 430886,
"watchers": 8583,
"forks": 42146,
"defaultBranch": "main"
},
{
"id": 132750724,
"name": "build-your-own-x",
"repo": "codecrafters-io/build-your-own-x",
"description": "Master programming by recreating your favorite technologies from scratch.",
"createdAt": "2018-05-09T12:03:18Z",
"updatedAt": "2025-10-28T12:37:11Z",
"pushedAt": "2025-10-10T18:45:01Z",
"stars": 430877,
"watchers": 6332,
"forks": 40453,
"defaultBranch": "master"
},
{
"id": 21737465,
"name": "awesome",
"repo": "sindresorhus/awesome",
"description": "😎 Awesome lists about all kinds of interesting topics",
"createdAt": "2014-07-11T13:42:37Z",
"updatedAt": "2025-10-28T12:40:21Z",
"pushedAt": "2025-10-27T17:57:31Z",
"stars": 410052,
"watchers": 8017,
"forks": 32029,
"defaultBranch": "main"
}
]
}
```
**TOON** (8,744 tokens):
```
repositories[3]{id,name,repo,description,createdAt,updatedAt,pushedAt,stars,watchers,forks,defaultBranch}:
28457823,freeCodeCamp,freeCodeCamp/freeCodeCamp,"freeCodeCamp.org's open-source codebase and curriculum. Learn math, programming,…","2014-12-24T17:49:19Z","2025-10-28T11:58:08Z","2025-10-28T10:17:16Z",430886,8583,42146,main
132750724,build-your-own-x,codecrafters-io/build-your-own-x,Master programming by recreating your favorite technologies from scratch.,"2018-05-09T12:03:18Z","2025-10-28T12:37:11Z","2025-10-10T18:45:01Z",430877,6332,40453,master
21737465,awesome,sindresorhus/awesome,😎 Awesome lists about all kinds of interesting topics,"2014-07-11T13:42:37Z","2025-10-28T12:40:21Z","2025-10-27T17:57:31Z",410052,8017,32029,main
```
</details>
<!-- /automd -->
## Installation & Quick Start
### CLI (No Installation Required)
Try TOON instantly with npx:
```bash
# Convert JSON to TOON
npx @toon-format/cli input.json -o output.toon
# Pipe from stdin
echo '{"name": "Ada", "role": "dev"}' | npx @toon-format/cli
```
See the [CLI section](#cli) for all options and examples.
### TypeScript Library
```bash
# npm
npm install @toon-format/toon
# pnpm
pnpm add @toon-format/toon
# yarn
yarn add @toon-format/toon
```
**Example usage:**
```ts
import { encode } from '@toon-format/toon'
const data = {
users: [
{ id: 1, name: 'Alice', role: 'admin' },
{ id: 2, name: 'Bob', role: 'user' }
]
}
console.log(encode(data))
// users[2]{id,name,role}:
// 1,Alice,admin
// 2,Bob,user
```
**Streaming large datasets:**
```ts
import { encodeLines } from '@toon-format/toon'
const largeData = await fetchThousandsOfRecords()
// Memory-efficient streaming for large data
for (const line of encodeLines(largeData)) {
process.stdout.write(`${line}\n`)
}
```
> [!TIP]
> For streaming decode APIs, see [`decodeFromLines()`](https://toonformat.dev/reference/api#decodefromlines-lines-options) and [`decodeStream()`](https://toonformat.dev/reference/api#decodestream-source-options).
**Transforming values with replacer:**
```ts
import { encode } from '@toon-format/toon'
// Remove sensitive fields
const user = { name: 'Alice', password: 'secret', email: 'alice@example.com' }
const safe = encode(user, {
replacer: (key, value) => key === 'password' ? undefined : value
})
// name: Alice
// email: alice@example.com
// Transform values
const data = { status: 'active', count: 5 }
const transformed = encode(data, {
replacer: (key, value) =>
typeof value === 'string' ? value.toUpperCase() : value
})
// status: ACTIVE
// count: 5
```
> [!TIP]
> The `replacer` function provides fine-grained control over encoding, similar to `JSON.stringify`'s replacer but with path tracking. See the [API Reference](https://toonformat.dev/reference/api#replacer-function) for more examples.
## Playgrounds
Experiment with TOON format interactively using these tools for token comparison, format conversion, and validation.
### Official Playground
The [TOON Playground](https://toonformat.dev/playground) lets you convert JSON or YAML to TOON in real time, compare token counts, and share your experiments via URL.
### Community Playgrounds
- [Format Tokenization Playground](https://www.curiouslychase.com/playground/format-tokenization-exploration)
- [TOON Tools](https://toontools.vercel.app/)
## Editor Support
### VS Code
[TOON Language Support](https://marketplace.visualstudio.com/items?itemName=vishalraut.vscode-toon) Syntax highlighting, validation, conversion, and token analysis.
```bash
code --install-extension vishalraut.vscode-toon
```
### Tree-sitter Grammar
[tree-sitter-toon](https://github.com/3swordman/tree-sitter-toon) Grammar for Tree-sitter-compatible editors (Neovim, Helix, Emacs, Zed).
### Neovim
[toon.nvim](https://github.com/thalesgelinger/toon.nvim) Lua-based plugin.
### Other Editors
Use YAML syntax highlighting as a close approximation.
## CLI
Command-line tool for quick JSON↔TOON conversions, token analysis, and pipeline integration. Auto-detects format from file extension, supports stdin/stdout workflows, and offers delimiter options for maximum efficiency.
```bash
# Encode JSON to TOON (auto-detected)
npx @toon-format/cli input.json -o output.toon
# Decode TOON to JSON (auto-detected)
npx @toon-format/cli data.toon -o output.json
# Pipe from stdin (no argument needed)
cat data.json | npx @toon-format/cli
echo '{"name": "Ada"}' | npx @toon-format/cli
# Output to stdout
npx @toon-format/cli input.json
# Show token savings
npx @toon-format/cli data.json --stats
```
> [!TIP]
> See the full [CLI documentation](https://toonformat.dev/cli/) for all options, examples, and advanced usage.
## Format Overview
Detailed syntax references, implementation guides, and quick lookups for understanding and using the TOON format.
- [Format Overview](https://toonformat.dev/guide/format-overview) Complete syntax documentation
- [Syntax Cheatsheet](https://toonformat.dev/reference/syntax-cheatsheet) Quick reference
- [API Reference](https://toonformat.dev/reference/api) Encode/decode usage (TypeScript)
## Using TOON with LLMs
TOON works best when you show the format instead of describing it. The structure is self-documenting models parse it naturally once they see the pattern. Wrap data in ` ```toon` code blocks for input, and show the expected header template when asking models to generate TOON. Use tab delimiters for even better token efficiency.
Follow the detailed [LLM integration guide](https://toonformat.dev/guide/llm-prompts) for strategies, examples, and validation techniques.
## Documentation
Comprehensive guides, references, and resources to help you get the most out of the TOON format and tools.
### Getting Started
- [Introduction & Installation](https://toonformat.dev/guide/getting-started) What TOON is, when to use it, first steps
- [Format Overview](https://toonformat.dev/guide/format-overview) Complete syntax with examples
- [Benchmarks](https://toonformat.dev/guide/benchmarks) Accuracy & token efficiency results
### Tools & Integration
- [CLI](https://toonformat.dev/cli/) Command-line tool for JSON↔TOON conversions
- [Playgrounds](https://toonformat.dev/ecosystem/tools-and-playgrounds) Interactive tools
- [Tooner](https://github.com/chaindead/tooner) MCP proxy that converts JSON tool responses to TOON
- [Using TOON with LLMs](https://toonformat.dev/guide/llm-prompts) Prompting strategies & validation
### References
- [API Reference](https://toonformat.dev/reference/api) TypeScript/JavaScript encode/decode API
- [Syntax Cheatsheet](https://toonformat.dev/reference/syntax-cheatsheet) Quick format lookup
- [Specification](https://github.com/toon-format/spec/blob/main/SPEC.md) Normative rules for implementers
## Other Implementations
TOON has official and community implementations across multiple languages including Python, Rust, Go, Java, Swift, .NET, and many more.
See the full list of implementations in the [documentation](https://toonformat.dev/ecosystem/implementations).
## Credits
- Logo design by [鈴木ックス(SZKX)](https://x.com/szkx_art)
## License
[MIT](./LICENSE) License © 2025-PRESENT [Johann Schopplich](https://github.com/johannschopplich)
+7 -7
View File
@@ -1,9 +1,9 @@
{
"name": "@toon-format/toon",
"type": "module",
"version": "1.0.0",
"packageManager": "pnpm@10.20.0",
"description": "Token-Oriented Object Notation (TOON) A compact, deterministic JSON format for LLM prompts",
"version": "2.3.1",
"packageManager": "pnpm@11.13.0",
"description": "Token-Oriented Object Notation (TOON) Compact, human-readable, schema-aware encoding of JSON for LLM prompts",
"author": "Johann Schopplich <hello@johannschopplich.com>",
"license": "MIT",
"homepage": "https://toonformat.dev",
@@ -25,11 +25,11 @@
"sideEffects": false,
"exports": {
".": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
"types": "./dist/index.d.mts",
"default": "./dist/index.mjs"
}
},
"types": "./dist/index.d.ts",
"types": "./dist/index.d.mts",
"files": [
"dist"
],
@@ -38,6 +38,6 @@
"test": "vitest"
},
"devDependencies": {
"@toon-format/spec": "^2.0.0"
"@toon-format/spec": "^3.3.2"
}
}
File diff suppressed because it is too large Load Diff
+47
View File
@@ -0,0 +1,47 @@
import type { ParsedLine } from '../types.ts'
/**
* Error thrown by the TOON decoder when input cannot be parsed.
*
* Extends `SyntaxError` so existing `instanceof SyntaxError` checks keep working.
* Adds structured location fields for programmatic consumers and richer CLI output.
*/
export class ToonDecodeError extends SyntaxError {
/** 1-based line number where the error was detected, if known. */
readonly line?: number
/** Raw source line (including indentation) where the error was detected, if known. */
readonly source?: string
constructor(message: string, context?: { line?: number, source?: string, cause?: unknown }) {
const prefix = context?.line !== undefined ? `Line ${context.line}: ` : ''
super(prefix + message, context?.cause !== undefined ? { cause: context.cause } : undefined)
this.name = 'ToonDecodeError'
this.line = context?.line
this.source = context?.source
}
}
/**
* Runs `fn` and re-throws any non-`ToonDecodeError` `Error` as a `ToonDecodeError`
* with line context attached and the original error preserved as `cause`.
*
* Pure parser helpers (parser.ts, string-utils.ts) don't know which line they're
* parsing; this wrapper is how the streaming decoder enriches their errors.
*/
export function withLine<T>(line: ParsedLine, fn: () => T): T {
try {
return fn()
}
catch (error) {
if (error instanceof ToonDecodeError)
throw error
if (error instanceof Error) {
throw new ToonDecodeError(error.message, {
line: line.lineNumber,
source: line.raw,
cause: error,
})
}
throw error
}
}
+206
View File
@@ -0,0 +1,206 @@
import type { JsonObject, JsonStreamEvent, JsonValue } from '../types.ts'
import { setOwnProperty } from '../shared/object-utils.ts'
import { QUOTED_KEY_MARKER } from './expand.ts'
// #region Build context types
type BuildContext
= | { type: 'object', obj: JsonObject, currentKey?: string, quotedKeys: Set<string> }
| { type: 'array', arr: JsonValue[] }
interface BuildState {
stack: BuildContext[]
root: JsonValue | undefined
}
// #endregion
// #region Synchronous AST builder
export function buildValueFromEvents(events: Iterable<JsonStreamEvent>): JsonValue {
const state: BuildState = { stack: [], root: undefined }
for (const event of events) {
applyEvent(state, event)
}
return finalizeState(state)
}
// #endregion
// #region Asynchronous AST builder
export async function buildValueFromEventsAsync(events: AsyncIterable<JsonStreamEvent>): Promise<JsonValue> {
const state: BuildState = { stack: [], root: undefined }
for await (const event of events) {
applyEvent(state, event)
}
return finalizeState(state)
}
// #endregion
// #region Shared event handlers
function applyEvent(state: BuildState, event: JsonStreamEvent): void {
const { stack } = state
switch (event.type) {
case 'startObject': {
const obj: JsonObject = {}
const quotedKeys = new Set<string>()
if (stack.length === 0) {
// Root object
stack.push({ type: 'object', obj, quotedKeys })
}
else {
const parent = stack[stack.length - 1]!
if (parent.type === 'object') {
if (parent.currentKey === undefined) {
throw new Error('Object startObject event without preceding key')
}
setOwnProperty(parent.obj, parent.currentKey, obj)
parent.currentKey = undefined
}
else if (parent.type === 'array') {
parent.arr.push(obj)
}
stack.push({ type: 'object', obj, quotedKeys })
}
break
}
case 'endObject': {
if (stack.length === 0) {
throw new Error('Unexpected endObject event')
}
const context = stack.pop()!
if (context.type !== 'object') {
throw new Error('Mismatched endObject event')
}
// Attach quoted keys metadata if any keys were quoted
if (context.quotedKeys.size > 0) {
Object.defineProperty(context.obj, QUOTED_KEY_MARKER, {
value: context.quotedKeys,
enumerable: false,
writable: false,
configurable: false,
})
}
if (stack.length === 0) {
state.root = context.obj
}
break
}
case 'startArray': {
const arr: JsonValue[] = []
if (stack.length === 0) {
// Root array
stack.push({ type: 'array', arr })
}
else {
const parent = stack[stack.length - 1]!
if (parent.type === 'object') {
if (parent.currentKey === undefined) {
throw new Error('Array startArray event without preceding key')
}
setOwnProperty(parent.obj, parent.currentKey, arr)
parent.currentKey = undefined
}
else if (parent.type === 'array') {
parent.arr.push(arr)
}
stack.push({ type: 'array', arr })
}
break
}
case 'endArray': {
if (stack.length === 0) {
throw new Error('Unexpected endArray event')
}
const context = stack.pop()!
if (context.type !== 'array') {
throw new Error('Mismatched endArray event')
}
if (stack.length === 0) {
state.root = context.arr
}
break
}
case 'key': {
if (stack.length === 0) {
throw new Error('Key event outside of object context')
}
const parent = stack[stack.length - 1]!
if (parent.type !== 'object') {
throw new Error('Key event in non-object context')
}
parent.currentKey = event.key
// Track quoted keys for path expansion
if (event.wasQuoted) {
parent.quotedKeys.add(event.key)
}
break
}
case 'primitive': {
if (stack.length === 0) {
// Root primitive
state.root = event.value
}
else {
const parent = stack[stack.length - 1]!
if (parent.type === 'object') {
if (parent.currentKey === undefined) {
throw new Error('Primitive event without preceding key in object')
}
setOwnProperty(parent.obj, parent.currentKey, event.value)
parent.currentKey = undefined
}
else if (parent.type === 'array') {
parent.arr.push(event.value)
}
}
break
}
}
}
function finalizeState(state: BuildState): JsonValue {
if (state.stack.length !== 0) {
throw new Error('Incomplete event stream: stack not empty at end')
}
if (state.root === undefined) {
throw new Error('No root value built from events')
}
return state.root
}
// #endregion
+30 -24
View File
@@ -1,7 +1,8 @@
import type { JsonObject, JsonValue } from '../types'
import { DOT } from '../constants'
import { isJsonObject } from '../encode/normalize'
import { isIdentifierSegment } from '../shared/validation'
import type { JsonObject, JsonValue } from '../types.ts'
import { DOT } from '../constants.ts'
import { isJsonObject } from '../encode/normalize.ts'
import { getOwnProperty, setOwnProperty } from '../shared/object-utils.ts'
import { isIdentifierSegment } from '../shared/validation.ts'
// #region Path expansion (safe)
@@ -12,16 +13,12 @@ import { isIdentifierSegment } from '../shared/validation'
export const QUOTED_KEY_MARKER: unique symbol = Symbol('quotedKey')
/**
* Type for objects that may have quoted key metadata attached.
* Objects that may have quoted key metadata attached.
*/
export interface ObjectWithQuotedKeys extends JsonObject {
[QUOTED_KEY_MARKER]?: Set<string>
}
function canMerge(a: JsonValue, b: JsonValue): a is JsonObject {
return isJsonObject(a) && isJsonObject(b)
}
/**
* Expands dotted keys into nested objects in safe mode.
*
@@ -77,7 +74,7 @@ export function expandPathsSafe(value: JsonValue, strict: boolean): JsonValue {
const expandedValue = expandPathsSafe(keyValue, strict)
// Check for conflicts with already-expanded keys
if (key in expandedObject) {
if (Object.hasOwn(expandedObject, key)) {
const conflictingValue = expandedObject[key]!
// If both are objects, try to merge them
if (canMerge(conflictingValue, expandedValue)) {
@@ -91,12 +88,12 @@ export function expandPathsSafe(value: JsonValue, strict: boolean): JsonValue {
)
}
// Non-strict: overwrite (LWW)
expandedObject[key] = expandedValue
setOwnProperty(expandedObject, key, expandedValue)
}
}
else {
// No conflict - insert directly
expandedObject[key] = expandedValue
setOwnProperty(expandedObject, key, expandedValue)
}
}
@@ -135,12 +132,12 @@ function insertPathSafe(
// Walk to the penultimate segment, creating objects as needed
for (let i = 0; i < segments.length - 1; i++) {
const currentSegment = segments[i]!
const segmentValue = currentNode[currentSegment]
const segmentValue = getOwnProperty(currentNode, currentSegment)
if (segmentValue === undefined) {
// Create new intermediate object
const newObj: JsonObject = {}
currentNode[currentSegment] = newObj
setOwnProperty(currentNode, currentSegment, newObj)
currentNode = newObj
}
else if (isJsonObject(segmentValue)) {
@@ -156,18 +153,18 @@ function insertPathSafe(
}
// Non-strict: overwrite with new object
const newObj: JsonObject = {}
currentNode[currentSegment] = newObj
setOwnProperty(currentNode, currentSegment, newObj)
currentNode = newObj
}
}
// Insert at the final segment
const lastSeg = segments[segments.length - 1]!
const destinationValue = currentNode[lastSeg]
const destinationValue = getOwnProperty(currentNode, lastSeg)
if (destinationValue === undefined) {
// No conflict - insert directly
currentNode[lastSeg] = value
setOwnProperty(currentNode, lastSeg, value)
}
else if (canMerge(destinationValue, value)) {
// Both are objects - deep merge
@@ -181,7 +178,7 @@ function insertPathSafe(
)
}
// Non-strict: overwrite (LWW)
currentNode[lastSeg] = value
setOwnProperty(currentNode, lastSeg, value)
}
}
@@ -205,13 +202,14 @@ function mergeObjects(
strict: boolean,
): void {
for (const [key, sourceValue] of Object.entries(source)) {
const targetValue = target[key]
if (targetValue === undefined) {
if (!Object.hasOwn(target, key)) {
// Key doesn't exist in target - copy it
target[key] = sourceValue
setOwnProperty(target, key, sourceValue)
continue
}
else if (canMerge(targetValue, sourceValue)) {
const targetValue = target[key] as JsonValue
if (canMerge(targetValue, sourceValue)) {
// Both are objects - recursively merge
mergeObjects(targetValue as JsonObject, sourceValue as JsonObject, strict)
}
@@ -223,9 +221,17 @@ function mergeObjects(
)
}
// Non-strict: overwrite (LWW)
target[key] = sourceValue
setOwnProperty(target, key, sourceValue)
}
}
}
// #endregion
// #region Type Guards
function canMerge(a: JsonValue, b: JsonValue): a is JsonObject {
return isJsonObject(a) && isJsonObject(b)
}
// #endregion
+92 -36
View File
@@ -1,46 +1,53 @@
import type { ArrayHeaderInfo, Delimiter, JsonPrimitive } from '../types'
import { BACKSLASH, CLOSE_BRACE, CLOSE_BRACKET, COLON, DELIMITERS, DOUBLE_QUOTE, FALSE_LITERAL, NULL_LITERAL, OPEN_BRACE, OPEN_BRACKET, PIPE, TAB, TRUE_LITERAL } from '../constants'
import { isBooleanOrNullLiteral, isNumericLiteral } from '../shared/literal-utils'
import { findClosingQuote, findUnquotedChar, unescapeString } from '../shared/string-utils'
import type { ArrayHeaderInfo, Delimiter, JsonPrimitive } from '../types.ts'
import { BACKSLASH, CLOSE_BRACE, CLOSE_BRACKET, COLON, DELIMITERS, DOUBLE_QUOTE, FALSE_LITERAL, NULL_LITERAL, OPEN_BRACE, OPEN_BRACKET, PIPE, TAB, TRUE_LITERAL } from '../constants.ts'
import { isBooleanOrNullLiteral, isNumericLiteral } from '../shared/literal-utils.ts'
import { findClosingQuote, findUnquotedChar, unescapeString } from '../shared/string-utils.ts'
// #region Array header parsing
export function parseArrayHeaderLine(
content: string,
defaultDelimiter: Delimiter,
strict: boolean = false,
): { header: ArrayHeaderInfo, inlineValues?: string } | undefined {
const trimmed = content.trimStart()
const trimmedToken = content.trimStart()
// Find the bracket segment, accounting for quoted keys that may contain brackets
let bracketStart = -1
// For quoted keys, find bracket after closing quote (not inside the quoted string)
if (trimmed.startsWith(DOUBLE_QUOTE)) {
const closingQuoteIndex = findClosingQuote(trimmed, 0)
if (trimmedToken.startsWith(DOUBLE_QUOTE)) {
const closingQuoteIndex = findClosingQuote(trimmedToken, 0)
if (closingQuoteIndex === -1) {
return
}
const afterQuote = trimmed.slice(closingQuoteIndex + 1)
const afterQuote = trimmedToken.slice(closingQuoteIndex + 1)
if (!afterQuote.startsWith(OPEN_BRACKET)) {
return
}
// Calculate position in original content and find bracket after the quoted key
const leadingWhitespace = content.length - trimmed.length
const leadingWhitespace = content.length - trimmedToken.length
const keyEndIndex = leadingWhitespace + closingQuoteIndex + 1
bracketStart = content.indexOf(OPEN_BRACKET, keyEndIndex)
}
else {
// Unquoted key - find first bracket
bracketStart = content.indexOf(OPEN_BRACKET)
bracketStart = findUnquotedChar(content, OPEN_BRACKET)
}
if (bracketStart === -1) {
return
}
const bracketEnd = content.indexOf(CLOSE_BRACKET, bracketStart)
// A header key can't contain an unquoted colon, so this is a key-value line
const firstColonIndex = findUnquotedChar(content, COLON)
if (firstColonIndex !== -1 && firstColonIndex < bracketStart) {
return
}
const bracketEnd = findUnquotedChar(content, CLOSE_BRACKET, bracketStart)
if (bracketEnd === -1) {
return
}
@@ -50,20 +57,43 @@ export function parseArrayHeaderLine(
let braceEnd = colonIndex
// Check for fields segment (braces come after bracket)
const braceStart = content.indexOf(OPEN_BRACE, bracketEnd)
if (braceStart !== -1 && braceStart < content.indexOf(COLON, bracketEnd)) {
const foundBraceEnd = content.indexOf(CLOSE_BRACE, braceStart)
const braceStart = findUnquotedChar(content, OPEN_BRACE, bracketEnd)
if (braceStart !== -1 && braceStart < findUnquotedChar(content, COLON, bracketEnd)) {
const gapBeforeBrace = content.slice(bracketEnd + 1, braceStart)
if (gapBeforeBrace !== '') {
if (strict) {
const trimmedGap = gapBeforeBrace.trim()
throw new SyntaxError(trimmedGap === ''
? `Unexpected whitespace between bracket and fields segment`
: `Unexpected content "${trimmedGap}" between bracket and fields segment`)
}
return
}
const foundBraceEnd = findUnquotedChar(content, CLOSE_BRACE, braceStart)
if (foundBraceEnd !== -1) {
braceEnd = foundBraceEnd + 1
}
}
// Now find colon after brackets and braces
colonIndex = content.indexOf(COLON, Math.max(bracketEnd, braceEnd))
colonIndex = findUnquotedChar(content, COLON, Math.max(bracketEnd, braceEnd))
if (colonIndex === -1) {
return
}
const gapStart = Math.max(bracketEnd + 1, braceEnd)
const gapBeforeColon = content.slice(gapStart, colonIndex)
if (gapBeforeColon !== '') {
if (strict) {
const trimmedGap = gapBeforeColon.trim()
throw new SyntaxError(trimmedGap === ''
? `Unexpected whitespace between bracket segment and colon`
: `Unexpected content "${trimmedGap}" between bracket segment and colon`)
}
return
}
// Extract and parse the key (might be quoted)
let key: string | undefined
if (bracketStart > 0) {
@@ -72,15 +102,15 @@ export function parseArrayHeaderLine(
}
const afterColon = content.slice(colonIndex + 1).trim()
const bracketContent = content.slice(bracketStart + 1, bracketEnd)
// Try to parse bracket segment
let parsedBracket: ReturnType<typeof parseBracketSegment>
try {
parsedBracket = parseBracketSegment(bracketContent, defaultDelimiter)
}
catch {
catch (error) {
if (strict)
throw error
return
}
@@ -89,9 +119,17 @@ export function parseArrayHeaderLine(
// Check for fields segment
let fields: string[] | undefined
if (braceStart !== -1 && braceStart < colonIndex) {
const foundBraceEnd = content.indexOf(CLOSE_BRACE, braceStart)
const foundBraceEnd = findUnquotedChar(content, CLOSE_BRACE, braceStart)
if (foundBraceEnd !== -1 && foundBraceEnd < colonIndex) {
const fieldsContent = content.slice(braceStart + 1, foundBraceEnd)
const mismatchedDelimiter = findUnquotedMismatchedDelimiter(fieldsContent, delimiter)
if (mismatchedDelimiter !== undefined) {
if (strict)
throw new SyntaxError(`Header delimiter mismatch: bracket declares "${formatDelimiter(delimiter)}" but fields segment contains unquoted "${formatDelimiter(mismatchedDelimiter)}"`)
return
}
fields = parseDelimitedValues(fieldsContent, delimiter).map(field => parseStringLiteral(field.trim()))
}
}
@@ -107,6 +145,8 @@ export function parseArrayHeaderLine(
}
}
const BRACKET_LENGTH_PATTERN = /^(?:0|[1-9]\d*)$/
export function parseBracketSegment(
seg: string,
defaultDelimiter: Delimiter,
@@ -124,12 +164,28 @@ export function parseBracketSegment(
content = content.slice(0, -1)
}
const length = Number.parseInt(content, 10)
if (Number.isNaN(length)) {
throw new TypeError(`Invalid array length: ${seg}`)
if (!BRACKET_LENGTH_PATTERN.test(content)) {
throw new SyntaxError(`Invalid array length: "${seg}" (expected non-negative integer with no leading zeros)`)
}
return { length, delimiter }
return { length: Number.parseInt(content, 10), delimiter }
}
const DELIMITER_CANDIDATES: readonly Delimiter[] = [',', '\t', '|']
function findUnquotedMismatchedDelimiter(content: string, activeDelimiter: Delimiter): Delimiter | undefined {
for (const candidate of DELIMITER_CANDIDATES) {
if (candidate === activeDelimiter)
continue
if (findUnquotedChar(content, candidate) !== -1)
return candidate
}
}
function formatDelimiter(delimiter: Delimiter): string {
if (delimiter === '\t')
return '\\t'
return delimiter
}
// #endregion
@@ -196,37 +252,37 @@ export function mapRowValuesToPrimitives(values: string[]): JsonPrimitive[] {
// #region Primitive and key parsing
export function parsePrimitiveToken(token: string): JsonPrimitive {
const trimmed = token.trim()
const trimmedToken = token.trim()
// Empty token
if (!trimmed) {
if (!trimmedToken) {
return ''
}
// Quoted string (if starts with quote, it MUST be properly quoted)
if (trimmed.startsWith(DOUBLE_QUOTE)) {
return parseStringLiteral(trimmed)
if (trimmedToken.startsWith(DOUBLE_QUOTE)) {
return parseStringLiteral(trimmedToken)
}
// Boolean or null literals
if (isBooleanOrNullLiteral(trimmed)) {
if (trimmed === TRUE_LITERAL)
if (isBooleanOrNullLiteral(trimmedToken)) {
if (trimmedToken === TRUE_LITERAL)
return true
if (trimmed === FALSE_LITERAL)
if (trimmedToken === FALSE_LITERAL)
return false
if (trimmed === NULL_LITERAL)
if (trimmedToken === NULL_LITERAL)
return null
}
// Numeric literal
if (isNumericLiteral(trimmed)) {
const parsedNumber = Number.parseFloat(trimmed)
if (isNumericLiteral(trimmedToken)) {
const parsedNumber = Number.parseFloat(trimmedToken)
// Normalize negative zero to positive zero
return Object.is(parsedNumber, -0) ? 0 : parsedNumber
}
// Unquoted string
return trimmed
return trimmedToken
}
export function parseStringLiteral(token: string): string {
@@ -305,11 +361,11 @@ export function parseKeyToken(content: string, start: number): { key: string, en
// #region Array content detection helpers
export function isArrayHeaderAfterHyphen(content: string): boolean {
export function isArrayHeaderContent(content: string): boolean {
return content.trim().startsWith(OPEN_BRACKET) && findUnquotedChar(content, COLON) !== -1
}
export function isObjectFirstFieldAfterHyphen(content: string): boolean {
export function isKeyValueContent(content: string): boolean {
return findUnquotedChar(content, COLON) !== -1
}
+96 -91
View File
@@ -1,109 +1,114 @@
import type { BlankLineInfo, Depth, ParsedLine } from '../types'
import { SPACE, TAB } from '../constants'
import type { BlankLineInfo, Depth, ParsedLine } from '../types.ts'
import { SPACE, TAB } from '../constants.ts'
import { ToonDecodeError } from './errors.ts'
export interface ScanResult {
lines: ParsedLine[]
// #region Scan state
export interface StreamingScanState {
lineNumber: number
blankLines: BlankLineInfo[]
}
export class LineCursor {
private lines: ParsedLine[]
private index: number
private blankLines: BlankLineInfo[]
constructor(lines: ParsedLine[], blankLines: BlankLineInfo[] = []) {
this.lines = lines
this.index = 0
this.blankLines = blankLines
}
getBlankLines(): BlankLineInfo[] {
return this.blankLines
}
peek(): ParsedLine | undefined {
return this.lines[this.index]
}
next(): ParsedLine | undefined {
return this.lines[this.index++]
}
current(): ParsedLine | undefined {
return this.index > 0 ? this.lines[this.index - 1] : undefined
}
advance(): void {
this.index++
}
atEnd(): boolean {
return this.index >= this.lines.length
}
get length(): number {
return this.lines.length
}
peekAtDepth(targetDepth: Depth): ParsedLine | undefined {
const line = this.peek()
return line?.depth === targetDepth ? line : undefined
export function createScanState(): StreamingScanState {
return {
lineNumber: 0,
blankLines: [],
}
}
export function toParsedLines(source: string, indentSize: number, strict: boolean): ScanResult {
if (!source.trim()) {
return { lines: [], blankLines: [] }
// #endregion
// #region Line parsing
export function parseLineIncremental(
raw: string,
state: StreamingScanState,
indentSize: number,
strict: boolean,
): ParsedLine | undefined {
state.lineNumber++
const lineNumber = state.lineNumber
// Count leading spaces
let indent = 0
while (indent < raw.length && raw[indent] === SPACE) {
indent++
}
const lines = source.split('\n')
const parsed: ParsedLine[] = []
const blankLines: BlankLineInfo[] = []
for (let i = 0; i < lines.length; i++) {
const raw = lines[i]!
const lineNumber = i + 1
let indent = 0
while (indent < raw.length && raw[indent] === SPACE) {
indent++
}
const content = raw.slice(indent)
// Track blank lines
if (!content.trim()) {
const depth = computeDepthFromIndent(indent, indentSize)
blankLines.push({ lineNumber, indent, depth })
continue
}
const content = raw.slice(indent)
// Track blank lines
if (!content.trim()) {
const depth = computeDepthFromIndent(indent, indentSize)
// Strict mode validation
if (strict) {
// Find the full leading whitespace region (spaces and tabs)
let whitespaceEndIndex = 0
while (whitespaceEndIndex < raw.length && (raw[whitespaceEndIndex] === SPACE || raw[whitespaceEndIndex] === TAB)) {
whitespaceEndIndex++
}
// Check for tabs in leading whitespace (before actual content)
if (raw.slice(0, whitespaceEndIndex).includes(TAB)) {
throw new SyntaxError(`Line ${lineNumber}: Tabs are not allowed in indentation in strict mode`)
}
// Check for exact multiples of indentSize
if (indent > 0 && indent % indentSize !== 0) {
throw new SyntaxError(`Line ${lineNumber}: Indentation must be exact multiple of ${indentSize}, but found ${indent} spaces`)
}
}
parsed.push({ raw, indent, content, depth, lineNumber })
state.blankLines.push({ lineNumber, indent, depth })
return undefined
}
return { lines: parsed, blankLines }
const depth = computeDepthFromIndent(indent, indentSize)
// Strict mode validation
if (strict) {
// Find the full leading whitespace region (spaces and tabs)
let whitespaceEndIndex = 0
while (
whitespaceEndIndex < raw.length
&& (raw[whitespaceEndIndex] === SPACE || raw[whitespaceEndIndex] === TAB)
) {
whitespaceEndIndex++
}
// Check for tabs in leading whitespace (before actual content)
if (raw.slice(0, whitespaceEndIndex).includes(TAB)) {
throw new ToonDecodeError(
'Tabs are not allowed in indentation in strict mode',
{ line: lineNumber, source: raw },
)
}
// Check for exact multiples of indentSize
if (indent > 0 && indent % indentSize !== 0) {
throw new ToonDecodeError(
`Indentation must be exact multiple of ${indentSize}, but found ${indent} spaces`,
{ line: lineNumber, source: raw },
)
}
}
return { raw, indent, content, depth, lineNumber }
}
export function* parseLinesSync(
source: Iterable<string>,
indentSize: number,
strict: boolean,
state: StreamingScanState,
): Generator<ParsedLine> {
for (const raw of source) {
const parsedLine = parseLineIncremental(raw, state, indentSize, strict)
if (parsedLine !== undefined) {
yield parsedLine
}
}
}
export async function* parseLinesAsync(
source: AsyncIterable<string>,
indentSize: number,
strict: boolean,
state: StreamingScanState,
): AsyncGenerator<ParsedLine> {
for await (const raw of source) {
const parsedLine = parseLineIncremental(raw, state, indentSize, strict)
if (parsedLine !== undefined) {
yield parsedLine
}
}
}
function computeDepthFromIndent(indentSpaces: number, indentSize: number): Depth {
return Math.floor(indentSpaces / indentSize)
}
// #endregion
+34 -18
View File
@@ -1,6 +1,9 @@
import type { ArrayHeaderInfo, BlankLineInfo, Delimiter, Depth, ResolvedDecodeOptions } from '../types'
import type { LineCursor } from './scanner'
import { COLON, LIST_ITEM_PREFIX } from '../constants'
import type { ArrayHeaderInfo, BlankLineInfo, Delimiter, Depth, ParsedLine } from '../types.ts'
import { COLON, LIST_ITEM_PREFIX } from '../constants.ts'
import { findUnquotedChar } from '../shared/string-utils.ts'
import { ToonDecodeError } from './errors.ts'
// #region Count and structure validation
/**
* Asserts that the actual count matches the expected count in strict mode.
@@ -9,10 +12,14 @@ export function assertExpectedCount(
actual: number,
expected: number,
itemType: string,
options: ResolvedDecodeOptions,
options: { strict: boolean },
line: ParsedLine,
): void {
if (options.strict && actual !== expected) {
throw new RangeError(`Expected ${expected} ${itemType}, but got ${actual}`)
throw new ToonDecodeError(
`Expected ${expected} ${itemType}, but got ${actual}`,
{ line: line.lineNumber, source: line.raw },
)
}
}
@@ -20,13 +27,15 @@ export function assertExpectedCount(
* Validates that there are no extra list items beyond the expected count.
*/
export function validateNoExtraListItems(
cursor: LineCursor,
nextLine: ParsedLine | undefined,
itemDepth: Depth,
expectedCount: number,
): void {
const nextLine = cursor.peek()
if (nextLine?.depth === itemDepth && nextLine.content.startsWith(LIST_ITEM_PREFIX)) {
throw new RangeError(`Expected ${expectedCount} list array items, but found more`)
throw new ToonDecodeError(
`Expected ${expectedCount} list array items, but found more`,
{ line: nextLine.lineNumber, source: nextLine.raw },
)
}
}
@@ -34,17 +43,19 @@ export function validateNoExtraListItems(
* Validates that there are no extra tabular rows beyond the expected count.
*/
export function validateNoExtraTabularRows(
cursor: LineCursor,
nextLine: ParsedLine | undefined,
rowDepth: Depth,
header: ArrayHeaderInfo,
): void {
const nextLine = cursor.peek()
if (
nextLine?.depth === rowDepth
&& !nextLine.content.startsWith(LIST_ITEM_PREFIX)
&& isDataRow(nextLine.content, header.delimiter)
) {
throw new RangeError(`Expected ${header.length} tabular rows, but found more`)
throw new ToonDecodeError(
`Expected ${header.length} tabular rows, but found more`,
{ line: nextLine.lineNumber, source: nextLine.raw },
)
}
}
@@ -62,25 +73,28 @@ export function validateNoBlankLinesInRange(
return
// Find blank lines within the range
// Note: We don't filter by depth because ANY blank line between array items is an error,
// regardless of its indentation level
const firstBlank = blankLines.find(
blank => blank.lineNumber > startLine && blank.lineNumber < endLine,
)
if (firstBlank) {
throw new SyntaxError(
`Line ${firstBlank.lineNumber}: Blank lines inside ${context} are not allowed in strict mode`,
throw new ToonDecodeError(
`Blank lines inside ${context} are not allowed in strict mode`,
{ line: firstBlank.lineNumber },
)
}
}
// #endregion
// #region Row classification helpers
/**
* Checks if a line is a data row (vs a key-value pair) in a tabular array.
*/
function isDataRow(content: string, delimiter: Delimiter): boolean {
const colonPos = content.indexOf(COLON)
const delimiterPos = content.indexOf(delimiter)
export function isDataRow(content: string, delimiter: Delimiter): boolean {
const colonPos = findUnquotedChar(content, COLON)
const delimiterPos = findUnquotedChar(content, delimiter)
// No colon = definitely a data row
if (colonPos === -1) {
@@ -95,3 +109,5 @@ function isDataRow(content: string, delimiter: Delimiter): boolean {
// Colon before delimiter or no delimiter = key-value pair
return false
}
// #endregion
+145 -101
View File
@@ -1,34 +1,42 @@
import type { Depth, JsonArray, JsonObject, JsonPrimitive, JsonValue, ResolvedEncodeOptions } from '../types'
import { DOT, LIST_ITEM_MARKER } from '../constants'
import { tryFoldKeyChain } from './folding'
import { isArrayOfArrays, isArrayOfObjects, isArrayOfPrimitives, isEmptyObject, isJsonArray, isJsonObject, isJsonPrimitive } from './normalize'
import { encodeAndJoinPrimitives, encodeKey, encodePrimitive, formatHeader } from './primitives'
import { LineWriter } from './writer'
import type { Depth, JsonArray, JsonObject, JsonPrimitive, JsonValue, ResolvedEncodeOptions } from '../types.ts'
import { DOT, LIST_ITEM_MARKER, LIST_ITEM_PREFIX } from '../constants.ts'
import { tryFoldKeyChain } from './folding.ts'
import { isArrayOfArrays, isArrayOfObjects, isArrayOfPrimitives, isEmptyObject, isJsonArray, isJsonObject, isJsonPrimitive } from './normalize.ts'
import { encodeAndJoinPrimitives, encodeKey, encodePrimitive, formatHeader } from './primitives.ts'
// #region Encode normalized JsonValue
export function encodeValue(value: JsonValue, options: ResolvedEncodeOptions): string {
export function* encodeJsonValue(value: JsonValue, options: ResolvedEncodeOptions, depth: Depth): Generator<string> {
if (isJsonPrimitive(value)) {
return encodePrimitive(value, options.delimiter)
}
// Primitives at root level are returned as a single line
const encodedPrimitive = encodePrimitive(value, options.delimiter)
const writer = new LineWriter(options.indent)
if (encodedPrimitive !== '')
yield encodedPrimitive
return
}
if (isJsonArray(value)) {
encodeArray(undefined, value, writer, 0, options)
yield* encodeArrayLines(undefined, value, depth, options)
}
else if (isJsonObject(value)) {
encodeObject(value, writer, 0, options)
yield* encodeObjectLines(value, depth, options)
}
return writer.toString()
}
// #endregion
// #region Object encoding
export function encodeObject(value: JsonObject, writer: LineWriter, depth: Depth, options: ResolvedEncodeOptions, rootLiteralKeys?: Set<string>, pathPrefix?: string, remainingDepth?: number): void {
export function* encodeObjectLines(
value: JsonObject,
depth: Depth,
options: ResolvedEncodeOptions,
rootLiteralKeys?: Set<string>,
pathPrefix?: string,
remainingDepth?: number,
): Generator<string> {
const keys = Object.keys(value)
// At root level (depth 0), collect all literal dotted keys for collision checking
@@ -39,11 +47,20 @@ export function encodeObject(value: JsonObject, writer: LineWriter, depth: Depth
const effectiveFlattenDepth = remainingDepth ?? options.flattenDepth
for (const [key, val] of Object.entries(value)) {
encodeKeyValuePair(key, val, writer, depth, options, keys, rootLiteralKeys, pathPrefix, effectiveFlattenDepth)
yield* encodeKeyValuePairLines(key, val, depth, options, keys, rootLiteralKeys, pathPrefix, effectiveFlattenDepth)
}
}
export function encodeKeyValuePair(key: string, value: JsonValue, writer: LineWriter, depth: Depth, options: ResolvedEncodeOptions, siblings?: readonly string[], rootLiteralKeys?: Set<string>, pathPrefix?: string, flattenDepth?: number): void {
export function* encodeKeyValuePairLines(
key: string,
value: JsonValue,
depth: Depth,
options: ResolvedEncodeOptions,
siblings?: readonly string[],
rootLiteralKeys?: Set<string>,
pathPrefix?: string,
flattenDepth?: number,
): Generator<string> {
const currentPath = pathPrefix ? `${pathPrefix}${DOT}${key}` : key
const effectiveFlattenDepth = flattenDepth ?? options.flattenDepth
@@ -59,44 +76,43 @@ export function encodeKeyValuePair(key: string, value: JsonValue, writer: LineWr
if (remainder === undefined) {
// The folded chain ended at a leaf (primitive, array, or empty object)
if (isJsonPrimitive(leafValue)) {
writer.push(depth, `${encodedFoldedKey}: ${encodePrimitive(leafValue, options.delimiter)}`)
yield indentedLine(depth, `${encodedFoldedKey}: ${encodePrimitive(leafValue, options.delimiter)}`, options.indent)
return
}
else if (isJsonArray(leafValue)) {
encodeArray(foldedKey, leafValue, writer, depth, options)
yield* encodeArrayLines(foldedKey, leafValue, depth, options)
return
}
else if (isJsonObject(leafValue) && isEmptyObject(leafValue)) {
writer.push(depth, `${encodedFoldedKey}:`)
yield indentedLine(depth, `${encodedFoldedKey}:`, options.indent)
return
}
}
// Case 2: Partially folded with a tail object
if (isJsonObject(remainder)) {
writer.push(depth, `${encodedFoldedKey}:`)
yield indentedLine(depth, `${encodedFoldedKey}:`, options.indent)
// Calculate remaining depth budget (subtract segments already folded)
const remainingDepth = effectiveFlattenDepth - segmentCount
const foldedPath = pathPrefix ? `${pathPrefix}${DOT}${foldedKey}` : foldedKey
encodeObject(remainder, writer, depth + 1, options, rootLiteralKeys, foldedPath, remainingDepth)
yield* encodeObjectLines(remainder, depth + 1, options, rootLiteralKeys, foldedPath, remainingDepth)
return
}
}
}
// No folding applied - use standard encoding
const encodedKey = encodeKey(key)
if (isJsonPrimitive(value)) {
writer.push(depth, `${encodedKey}: ${encodePrimitive(value, options.delimiter)}`)
yield indentedLine(depth, `${encodedKey}: ${encodePrimitive(value, options.delimiter)}`, options.indent)
}
else if (isJsonArray(value)) {
encodeArray(key, value, writer, depth, options)
yield* encodeArrayLines(key, value, depth, options)
}
else if (isJsonObject(value)) {
writer.push(depth, `${encodedKey}:`)
yield indentedLine(depth, `${encodedKey}:`, options.indent)
if (!isEmptyObject(value)) {
encodeObject(value, writer, depth + 1, options, rootLiteralKeys, currentPath, effectiveFlattenDepth)
yield* encodeObjectLines(value, depth + 1, options, rootLiteralKeys, currentPath, effectiveFlattenDepth)
}
}
}
@@ -105,23 +121,22 @@ export function encodeKeyValuePair(key: string, value: JsonValue, writer: LineWr
// #region Array encoding
export function encodeArray(
export function* encodeArrayLines(
key: string | undefined,
value: JsonArray,
writer: LineWriter,
depth: Depth,
options: ResolvedEncodeOptions,
): void {
): Generator<string> {
if (value.length === 0) {
const header = formatHeader(0, { key, delimiter: options.delimiter })
writer.push(depth, header)
const line = key != null ? `${encodeKey(key)}: []` : '[]'
yield indentedLine(depth, line, options.indent)
return
}
// Primitive array
if (isArrayOfPrimitives(value)) {
const arrayLine = encodeInlineArrayLine(value, options.delimiter, key)
writer.push(depth, arrayLine)
yield indentedLine(depth, arrayLine, options.indent)
return
}
@@ -129,7 +144,7 @@ export function encodeArray(
if (isArrayOfArrays(value)) {
const allPrimitiveArrays = value.every(arr => isArrayOfPrimitives(arr))
if (allPrimitiveArrays) {
encodeArrayOfArraysAsListItems(key, value, writer, depth, options)
yield* encodeArrayOfArraysAsListItemsLines(key, value, depth, options)
return
}
}
@@ -138,36 +153,35 @@ export function encodeArray(
if (isArrayOfObjects(value)) {
const header = extractTabularHeader(value)
if (header) {
encodeArrayOfObjectsAsTabular(key, value, header, writer, depth, options)
yield* encodeArrayOfObjectsAsTabularLines(key, value, header, depth, options)
}
else {
encodeMixedArrayAsListItems(key, value, writer, depth, options)
yield* encodeMixedArrayAsListItemsLines(key, value, depth, options)
}
return
}
// Mixed array: fallback to expanded format
encodeMixedArrayAsListItems(key, value, writer, depth, options)
yield* encodeMixedArrayAsListItemsLines(key, value, depth, options)
}
// #endregion
// #region Array of arrays (expanded format)
export function encodeArrayOfArraysAsListItems(
export function* encodeArrayOfArraysAsListItemsLines(
prefix: string | undefined,
values: readonly JsonArray[],
writer: LineWriter,
depth: Depth,
options: ResolvedEncodeOptions,
): void {
): Generator<string> {
const header = formatHeader(values.length, { key: prefix, delimiter: options.delimiter })
writer.push(depth, header)
yield indentedLine(depth, header, options.indent)
for (const arr of values) {
if (isArrayOfPrimitives(arr)) {
const arrayLine = encodeInlineArrayLine(arr, options.delimiter)
writer.pushListItem(depth + 1, arrayLine)
yield indentedListItem(depth + 1, arrayLine, options.indent)
}
}
}
@@ -175,10 +189,10 @@ export function encodeArrayOfArraysAsListItems(
export function encodeInlineArrayLine(values: readonly JsonPrimitive[], delimiter: string, prefix?: string): string {
const header = formatHeader(values.length, { key: prefix, delimiter })
const joinedValue = encodeAndJoinPrimitives(values, delimiter)
// Only add space if there are values
if (values.length === 0) {
if (values.length === 0)
return header
}
return `${header} ${joinedValue}`
}
@@ -186,18 +200,17 @@ export function encodeInlineArrayLine(values: readonly JsonPrimitive[], delimite
// #region Array of objects (tabular format)
export function encodeArrayOfObjectsAsTabular(
export function* encodeArrayOfObjectsAsTabularLines(
prefix: string | undefined,
rows: readonly JsonObject[],
header: readonly string[],
writer: LineWriter,
depth: Depth,
options: ResolvedEncodeOptions,
): void {
): Generator<string> {
const formattedHeader = formatHeader(rows.length, { key: prefix, fields: header, delimiter: options.delimiter })
writer.push(depth, `${formattedHeader}`)
yield indentedLine(depth, formattedHeader, options.indent)
writeTabularRows(rows, header, writer, depth + 1, options)
yield* writeTabularRowsLines(rows, header, depth + 1, options)
}
export function extractTabularHeader(rows: readonly JsonObject[]): string[] | undefined {
@@ -228,7 +241,7 @@ export function isTabularArray(
// Check that all header keys exist in the row and all values are primitives
for (const key of header) {
if (!(key in row)) {
if (!Object.hasOwn(row, key)) {
return false
}
if (!isJsonPrimitive(row[key])) {
@@ -240,17 +253,16 @@ export function isTabularArray(
return true
}
function writeTabularRows(
function* writeTabularRowsLines(
rows: readonly JsonObject[],
header: readonly string[],
writer: LineWriter,
depth: Depth,
options: ResolvedEncodeOptions,
): void {
): Generator<string> {
for (const row of rows) {
const values = header.map(key => row[key])
const joinedValue = encodeAndJoinPrimitives(values as JsonPrimitive[], options.delimiter)
writer.push(depth, joinedValue)
yield indentedLine(depth, joinedValue, options.indent)
}
}
@@ -258,78 +270,89 @@ function writeTabularRows(
// #region Array of objects (expanded format)
export function encodeMixedArrayAsListItems(
export function* encodeMixedArrayAsListItemsLines(
prefix: string | undefined,
items: readonly JsonValue[],
writer: LineWriter,
depth: Depth,
options: ResolvedEncodeOptions,
): void {
): Generator<string> {
const header = formatHeader(items.length, { key: prefix, delimiter: options.delimiter })
writer.push(depth, header)
yield indentedLine(depth, header, options.indent)
for (const item of items) {
encodeListItemValue(item, writer, depth + 1, options)
yield* encodeListItemValueLines(item, depth + 1, options)
}
}
export function encodeObjectAsListItem(obj: JsonObject, writer: LineWriter, depth: Depth, options: ResolvedEncodeOptions): void {
export function* encodeObjectAsListItemLines(
obj: JsonObject,
depth: Depth,
options: ResolvedEncodeOptions,
): Generator<string> {
if (isEmptyObject(obj)) {
writer.push(depth, LIST_ITEM_MARKER)
yield indentedLine(depth, LIST_ITEM_MARKER, options.indent)
return
}
const entries = Object.entries(obj)
const [firstKey, firstValue] = entries[0]!
const restEntries = entries.slice(1)
// Check if first field is a tabular array
if (isJsonArray(firstValue) && isArrayOfObjects(firstValue)) {
const header = extractTabularHeader(firstValue)
if (header) {
// Tabular array as first field
const formattedHeader = formatHeader(firstValue.length, { key: firstKey, fields: header, delimiter: options.delimiter })
yield indentedListItem(depth, formattedHeader, options.indent)
yield* writeTabularRowsLines(firstValue, header, depth + 2, options)
if (restEntries.length > 0) {
const restObj: JsonObject = Object.fromEntries(restEntries)
yield* encodeObjectLines(restObj, depth + 1, options)
}
return
}
}
const encodedKey = encodeKey(firstKey)
if (isJsonPrimitive(firstValue)) {
writer.pushListItem(depth, `${encodedKey}: ${encodePrimitive(firstValue, options.delimiter)}`)
// Primitive value: `- key: value`
const encodedValue = encodePrimitive(firstValue, options.delimiter)
yield indentedListItem(depth, `${encodedKey}: ${encodedValue}`, options.indent)
}
else if (isJsonArray(firstValue)) {
if (isArrayOfPrimitives(firstValue)) {
// Inline format for primitive arrays
const arrayPropertyLine = encodeInlineArrayLine(firstValue, options.delimiter, firstKey)
writer.pushListItem(depth, arrayPropertyLine)
if (firstValue.length === 0) {
// Empty array: `- key: []`
yield indentedListItem(depth, `${encodedKey}: []`, options.indent)
}
else if (isArrayOfObjects(firstValue)) {
// Check if array of objects can use tabular format
const header = extractTabularHeader(firstValue)
if (header) {
// Tabular format for uniform arrays of objects
const formattedHeader = formatHeader(firstValue.length, { key: firstKey, fields: header, delimiter: options.delimiter })
writer.pushListItem(depth, formattedHeader)
writeTabularRows(firstValue, header, writer, depth + 1, options)
}
else {
// Fall back to list format for non-uniform arrays of objects
writer.pushListItem(depth, `${encodedKey}[${firstValue.length}]:`)
for (const item of firstValue) {
encodeObjectAsListItem(item, writer, depth + 1, options)
}
}
else if (isArrayOfPrimitives(firstValue)) {
// Inline primitive array: `- key[N]: values`
const arrayLine = encodeInlineArrayLine(firstValue, options.delimiter)
yield indentedListItem(depth, `${encodedKey}${arrayLine}`, options.indent)
}
else {
// Complex arrays on separate lines (array of arrays, etc.)
writer.pushListItem(depth, `${encodedKey}[${firstValue.length}]:`)
// Non-inline array: `- key[N]:` with items at depth + 2
const header = formatHeader(firstValue.length, { delimiter: options.delimiter })
yield indentedListItem(depth, `${encodedKey}${header}`, options.indent)
// Encode array contents at depth + 1
for (const item of firstValue) {
encodeListItemValue(item, writer, depth + 1, options)
yield* encodeListItemValueLines(item, depth + 2, options)
}
}
}
else if (isJsonObject(firstValue)) {
writer.pushListItem(depth, `${encodedKey}:`)
// Object value: `- key:` with fields at depth + 2
yield indentedListItem(depth, `${encodedKey}:`, options.indent)
if (!isEmptyObject(firstValue)) {
encodeObject(firstValue, writer, depth + 2, options)
yield* encodeObjectLines(firstValue, depth + 2, options)
}
}
// Remaining entries on indented lines
for (let i = 1; i < entries.length; i++) {
const [key, value] = entries[i]!
encodeKeyValuePair(key, value, writer, depth + 1, options)
if (restEntries.length > 0) {
const restObj: JsonObject = Object.fromEntries(restEntries)
yield* encodeObjectLines(restObj, depth + 1, options)
}
}
@@ -337,22 +360,43 @@ export function encodeObjectAsListItem(obj: JsonObject, writer: LineWriter, dept
// #region List item encoding helpers
function encodeListItemValue(
function* encodeListItemValueLines(
value: JsonValue,
writer: LineWriter,
depth: Depth,
options: ResolvedEncodeOptions,
): void {
): Generator<string> {
if (isJsonPrimitive(value)) {
writer.pushListItem(depth, encodePrimitive(value, options.delimiter))
yield indentedListItem(depth, encodePrimitive(value, options.delimiter), options.indent)
}
else if (isJsonArray(value) && isArrayOfPrimitives(value)) {
const arrayLine = encodeInlineArrayLine(value, options.delimiter)
writer.pushListItem(depth, arrayLine)
else if (isJsonArray(value)) {
if (isArrayOfPrimitives(value)) {
const arrayLine = encodeInlineArrayLine(value, options.delimiter)
yield indentedListItem(depth, arrayLine, options.indent)
}
else {
const header = formatHeader(value.length, { delimiter: options.delimiter })
yield indentedListItem(depth, header, options.indent)
for (const item of value) {
yield* encodeListItemValueLines(item, depth + 1, options)
}
}
}
else if (isJsonObject(value)) {
encodeObjectAsListItem(value, writer, depth, options)
yield* encodeObjectAsListItemLines(value, depth, options)
}
}
// #endregion
// #region Indentation helpers
function indentedLine(depth: Depth, content: string, indentSize: number): string {
const indentation = ' '.repeat(indentSize * depth)
return indentation + content
}
function indentedListItem(depth: Depth, content: string, indentSize: number): string {
return indentedLine(depth, LIST_ITEM_PREFIX + content, indentSize)
}
// #endregion

Some files were not shown because too many files have changed in this diff Show More