Compare commits

...

20 Commits

Author SHA1 Message Date
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
30 changed files with 1135 additions and 916 deletions
+1 -1
View File
@@ -4,7 +4,7 @@ The TOON specification has moved to a dedicated repository: [github.com/toon-for
## Current Version
**Version 3.0** (2025-11-24)
**Version 3.2** (2026-05-20)
## Quick Links
+9 -9
View File
@@ -8,21 +8,21 @@
"fetch:github-repos": "node scripts/fetch-github-repos.ts"
},
"devDependencies": {
"@ai-sdk/anthropic": "^3.0.76",
"@ai-sdk/google": "^3.0.71",
"@ai-sdk/openai": "^3.0.63",
"@ai-sdk/anthropic": "^3.0.78",
"@ai-sdk/google": "^3.0.75",
"@ai-sdk/openai": "^3.0.64",
"@ai-sdk/provider": "^3.0.10",
"@ai-sdk/xai": "^3.0.89",
"@clack/prompts": "^1.3.0",
"@ai-sdk/xai": "^3.0.91",
"@clack/prompts": "^1.4.0",
"@faker-js/faker": "^10.4.0",
"ai": "^6.0.177",
"ai": "^6.0.185",
"csv-stringify": "^6.7.0",
"fast-xml-parser": "^5.7.3",
"fast-xml-parser": "^5.8.0",
"gpt-tokenizer": "^3.4.0",
"ofetch": "^1.5.1",
"p-map": "^7.0.4",
"p-queue": "^9.2.0",
"p-queue": "^9.3.0",
"unstorage": "^1.17.5",
"yaml": "^2.8.4"
"yaml": "^2.9.0"
}
}
@@ -4,14 +4,27 @@ 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'>> {
json: string
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 = {
@@ -83,17 +96,19 @@ const DEFAULT_JSON = JSON.stringify(PRESETS.hikes, undefined, 2)
const SHARE_URL_LIMIT = 8 * 1024
// Input state
const jsonInput = ref(DEFAULT_JSON)
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 formattedJson = computed(() => {
const formattedInput = computed(() => {
try {
return formatJson(JSON.parse(jsonInput.value))
const data = parseInput(inputText.value, inputFormat.value)
return inputFormat.value === 'yaml' ? stringifyInputYaml(data) : formatJson(data)
}
catch {
return jsonInput.value
return inputText.value
}
})
@@ -106,7 +121,7 @@ const flattenDepth = ref(2)
// Encoding output
const encodingResult = computed(() => {
try {
const parsedInput = JSON.parse(jsonInput.value)
const parsedInput = parseInput(inputText.value, inputFormat.value)
return {
output: encode(parsedInput, {
indent: indent.value,
@@ -118,9 +133,10 @@ const encodingResult = computed(() => {
}
}
catch (error) {
const fallback = inputFormat.value === 'yaml' ? 'Invalid YAML' : 'Invalid JSON'
return {
output: '',
error: error instanceof Error ? error.message : 'Invalid JSON',
error: error instanceof Error ? error.message : fallback,
}
}
})
@@ -129,18 +145,18 @@ const error = computed(() => encodingResult.value.error)
// Token analysis
const tokenizer = shallowRef<typeof import('gpt-tokenizer') | undefined>()
const jsonTokens = computed(() =>
tokenizer.value?.encode(formattedJson.value).length,
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 (!jsonTokens.value || !toonTokens.value)
if (!inputTokens.value || !toonTokens.value)
return
const diff = jsonTokens.value - toonTokens.value
const percent = Math.abs((diff / jsonTokens.value) * 100).toFixed(1)
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 }
@@ -165,13 +181,25 @@ const updateUrl = useDebounceFn(() => {
window.history.replaceState(null, '', `#${hash}`)
}, 300)
watch([jsonInput, delimiter, indent, keyFolding, flattenDepth, jsonFormat], () => {
watch([inputText, delimiter, indent, keyFolding, flattenDepth, jsonFormat, inputFormat], () => {
updateUrl()
})
watch(jsonFormat, () => {
if (inputFormat.value !== 'json')
return
try {
jsonInput.value = formatJson(JSON.parse(jsonInput.value))
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 {}
})
@@ -185,12 +213,13 @@ onMounted(() => {
const state = decodeState(hash)
if (state) {
jsonInput.value = state.json
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'
}
})
@@ -200,7 +229,8 @@ function formatJson(value: unknown) {
function encodeState() {
const state: PlaygroundState = {
json: jsonInput.value,
input: inputText.value,
inputFormat: inputFormat.value,
delimiter: delimiter.value,
indent: indent.value,
keyFolding: keyFolding.value,
@@ -224,7 +254,8 @@ function decodeState(hash: string) {
}
function loadPreset(name: keyof typeof PRESETS) {
jsonInput.value = formatJson(PRESETS[name])
const data = PRESETS[name]
inputText.value = inputFormat.value === 'yaml' ? stringifyInputYaml(data) : formatJson(data)
}
async function copyShareUrl() {
@@ -247,11 +278,22 @@ async function loadTokenizer() {
<!-- Header -->
<header class="playground-header">
<h1>Playground</h1>
<p>Experiment with JSON to TOON encoding in real-time.</p>
<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">
@@ -312,7 +354,7 @@ async function loadTokenizer() {
</select>
</VPInput>
<VPInput id="jsonFormat" label="JSON Baseline">
<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 }}
@@ -347,24 +389,24 @@ async function loadTokenizer() {
<!-- Editor Container -->
<div class="editor-container">
<!-- JSON Input -->
<!-- Input -->
<div class="editor-pane">
<div class="pane-header">
<span class="pane-title">JSON Input</span>
<span class="pane-title">{{ inputFormat === 'yaml' ? 'YAML Input' : 'JSON Input' }}</span>
<span class="pane-stats">
<span class="stat-primary" title="Token count using selected JSON baseline format">{{ jsonTokens ?? '…' }} tokens</span>
<span class="stat-secondary">{{ formattedJson.length }} chars</span>
<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="json-input"
v-model="jsonInput"
id="input"
v-model="inputText"
class="editor-textarea"
spellcheck="false"
aria-label="JSON input"
:aria-describedby="error ? 'json-error' : undefined"
:aria-label="inputFormat === 'yaml' ? 'YAML input' : 'JSON input'"
:aria-describedby="error ? 'parse-error' : undefined"
:aria-invalid="!!error"
placeholder="Enter JSON here…"
:placeholder="inputFormat === 'yaml' ? 'Enter YAML here…' : 'Enter JSON here…'"
/>
</div>
@@ -392,7 +434,7 @@ async function loadTokenizer() {
@click="copy()"
/>
<pre v-if="!error"><code>{{ toonOutput }}</code></pre>
<div v-else id="json-error" role="alert" class="error-message">
<div v-else id="parse-error" role="alert" class="error-message">
{{ error }}
</div>
</div>
+1 -1
View File
@@ -12,7 +12,7 @@ const config: Theme = {
extends: DefaultTheme,
enhanceApp({ app }) {
app.config.globalProperties.$spec = {
version: '3.0',
version: '3.2',
}
app.component('CopyOrDownloadAsMarkdownButtons', CopyOrDownloadAsMarkdownButtons)
app.component('PlaygroundLayout', PlaygroundLayout)
+4 -4
View File
@@ -91,7 +91,7 @@ cat data.toon | toon --decode
:::
By convention, TOON files use the `.toon` extension and the provisional media type `text/toon` (see [spec §18.2](https://github.com/toon-format/spec/blob/main/SPEC.md#182-provisional-media-type)).
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
@@ -156,7 +156,7 @@ When using the `--stats` flag with encode, the CLI builds the full TOON string o
| `--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 |
| `--no-strict` | Skip decode validation; 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`) |
@@ -225,13 +225,13 @@ Tab delimiters often tokenize more efficiently than commas and reduce the need f
### Lenient Decoding
Skip validation for faster processing:
Skip validation for faster, more forgiving decoding:
```bash
toon data.toon --no-strict -o output.json
```
Lenient mode (`--no-strict`) disables strict validation checks like array count matching, indentation multiples, and delimiter consistency. Use this when you trust the input and want faster decoding.
With `--no-strict`, the decoder stops enforcing array count matches, indentation multiples, and delimiter consistency. 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
+3 -1
View File
@@ -39,6 +39,7 @@ Community members have created implementations in additional languages:
| **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) |
@@ -49,10 +50,11 @@ Community members have created implementations in additional languages:
| **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** (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) |
| **Python** (Rust backend) | [toons](https://github.com/alesanfra/toons) | [@alesanfra](https://github.com/alesanfra) |
| **Zig** | [toon-zig](https://github.com/LatentEvals/toon-zig) | [@montanaflynn](https://github.com/montanaflynn) |
## Contributing an Implementation
+7 -1
View File
@@ -10,7 +10,7 @@ Experiment with TOON format interactively using these tools for token comparison
### Official Playground
The [TOON Playground](/playground) lets you convert JSON to TOON in real-time, compare token counts, and share your experiments via URL.
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
@@ -74,3 +74,9 @@ 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.
+24 -11
View File
@@ -96,7 +96,7 @@ The header `items[2]{sku,qty,price}:` declares:
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) and primitive values only (no nested arrays/objects).
> 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
@@ -157,15 +157,27 @@ pairs[2]:
Each inner array gets its own header on the list-item line.
### Empty Arrays
Empty arrays have special representations:
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[0]:
items[3]:
- summary
- id: 1
name: Ada
- [2]:
- id: 2
- status: draft
```
The header declares length zero, with no elements following.
### 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
@@ -214,7 +226,7 @@ items[2|]{sku|name|qty|price}:
:::
Tab and pipe delimiters are explicitly encoded in the header brackets and field braces. Commas don't require quoting when tab or pipe is active, and vice versa.
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.
@@ -293,8 +305,8 @@ TOON quotes strings **only when necessary** to maximize token efficiency. A stri
- 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"`, or `"05"` with leading zeros)
- It contains special characters: colon (`:`), quote (`"`), backslash (`\`), brackets, braces, or control characters (newline, tab, carriage return)
- 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
@@ -307,7 +319,7 @@ note: This has inner spaces
### Escape Sequences
In quoted strings and keys, only five escape sequences are valid:
In quoted strings and keys, six escape sequences are valid:
| Character | Escape |
|-----------|--------|
@@ -316,8 +328,9 @@ In quoted strings and keys, only five escape sequences are valid:
| Newline (U+000A) | `\n` |
| Carriage return (U+000D) | `\r` |
| Tab (U+0009) | `\t` |
| Any other U+0000U+001F control character | `\uXXXX` |
All other escape sequences (e.g., `\x`, `\u`) are invalid and will cause an error in strict mode.
Other escapes (e.g., `\x`, `\0`, `\b`) are always rejected, as are lone-surrogate `\uXXXX` values (U+D800U+DFFF).
### Type Conversions
+1 -1
View File
@@ -184,7 +184,7 @@ 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 §18.2](https://github.com/toon-format/spec/blob/main/SPEC.md#182-provisional-media-type).
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
+3 -3
View File
@@ -87,7 +87,7 @@ catch (error) {
}
```
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).
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
@@ -117,10 +117,10 @@ for (const line of encodeLines(largeData, { delimiter: '\t' })) {
The CLI also supports streaming for memory-efficient JSON-to-TOON conversion:
```bash
toon large-dataset.json --output output.toon
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).
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:
+3 -2
View File
@@ -9,12 +9,13 @@
},
"devDependencies": {
"@vueuse/core": "^14.3.0",
"fflate": "^0.8.2",
"fflate": "^0.8.3",
"gpt-tokenizer": "^3.4.0",
"markdown-it-mathjax3": "^4.3.2",
"uint8array-extras": "^1.5.0",
"unocss": "^66.6.8",
"vitepress": "^1.6.4",
"vitepress-plugin-llms": "^1.12.2"
"vitepress-plugin-llms": "^1.12.2",
"yaml": "^2.9.0"
}
}
+13 -13
View File
@@ -68,6 +68,10 @@ Non-JSON-serializable values are normalized before encoding:
| `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
@@ -581,15 +585,18 @@ Configuration for [`decode()`](#decode-input-options) and [`decodeFromLines()`](
By default (`strict: true`), the decoder validates input strictly:
- **Invalid escape sequences**: Throws on `\x`, unterminated strings
- **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
- **Delimiter mismatches**: Throws when row delimiters don't match header
- **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 validation for lenient parsing.
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.
@@ -622,22 +629,13 @@ type JsonStreamEvent
| { type: 'primitive', value: JsonPrimitive }
```
### JSON Value Types
```ts
type JsonPrimitive = string | number | boolean | null
type JsonArray = readonly JsonValue[]
type JsonObject = { readonly [key: string]: JsonValue }
type JsonValue = JsonPrimitive | JsonArray | JsonObject
```
### Delimiters
```ts
import { DEFAULT_DELIMITER, DELIMITERS } from '@toon-format/toon'
DEFAULT_DELIMITER // ','
DELIMITERS // { comma: ',', tab: '\t', pipe: '|' }
DELIMITERS // { comma: ',', tab: '\t', pipe: '|' }
```
| Export | Description |
@@ -740,6 +738,8 @@ When multiple expanded keys construct overlapping paths, the decoder merges them
- 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.
+2 -2
View File
@@ -450,10 +450,10 @@ $$
For a field named `key`, JSON uses `{"key":[]}` in compact form, while TOON uses:
```yaml
key[0]:
key: []
```
Under this model, that yields a constant 3-byte advantage for TOON.
Under this model, that yields a constant 3-byte advantage for TOON. The legacy `key[0]:` form remains decodable for backward compatibility.
## Summary Table
+22 -23
View File
@@ -13,16 +13,16 @@ You don't need this page to *use* TOON. It's mainly for implementers and contrib
## Current Version
**Spec v{{ $spec.version }}** (2025-11-24) 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.
**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 [§18.2](https://github.com/toon-format/spec/blob/main/SPEC.md#182-provisional-media-type):
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, MUST be `utf-8` per the spec.
TOON documents are always UTF8 with LF (`\n`) line endings; the optional `charset` parameter, when present, is `utf-8`.
## Guided Tour of the Spec
@@ -49,7 +49,7 @@ Defines TOON's line-oriented, indentation-based notation and how to determine wh
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` are valid), and key encoding requirements.
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.
@@ -75,7 +75,7 @@ Defines conformance classes (encoder, decoder, validator), standardized options,
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 mismatches, syntax errors, indentation errors, structural errors, and path expansion conflicts.
**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
@@ -85,16 +85,16 @@ 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 Interoperability and Mappings](https://github.com/toon-format/spec/blob/main/SPEC.md#17-interoperability-and-mappings):
JSON/CSV/YAML mappings and conversion guidance.
[§18 IANA Considerations](https://github.com/toon-format/spec/blob/main/SPEC.md#18-iana-considerations):
[§17 IANA Considerations](https://github.com/toon-format/spec/blob/main/SPEC.md#17-iana-considerations):
Media type registration plans and provisional status.
[§19 TOON Core Profile](https://github.com/toon-format/spec/blob/main/SPEC.md#19-toon-core-profile-normative-subset):
Normative subset of the most common, memory-friendly rules. Useful for minimal implementations.
[§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.
[Appendix G: Host Type Normalization Examples](https://github.com/toon-format/spec/blob/main/SPEC.md#appendix-g-host-type-normalization-examples-informative):
[§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, and Rust 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):
@@ -104,16 +104,15 @@ Reference test suite at [github.com/toon-format/spec/tree/main/tests](https://gi
| Section | Topic | When to Read |
|---------|-------|--------------|
| §1-4 | Data model, normalization, decoding | Implementing encoders/decoders |
| §5-6 | Syntax, headers, root form | Implementing parsers |
| §14 | Data model, normalization, decoding | Implementing encoders/decoders |
| §56 | Syntax, headers, root form | Implementing parsers |
| §7 | Strings, keys, quoting, escaping | Implementing string handling |
| §8-10 | Objects, arrays, list items | Implementing structure encoding |
| §11-12 | Delimiters, indentation, whitespace | Implementing formatting and validation |
| §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 |
| §15-18 | Security, i18n, interoperability, media type | Operational and ecosystem considerations |
| §19 | Core profile | Minimal implementations |
| §20-21 | Versioning, extensibility, IP | Long-term stability and licensing |
| §1516 | Security, internationalization | Operational considerations |
| §1719 | IANA, versioning, IP | Ecosystem and licensing |
## Conformance Checklists
@@ -124,7 +123,7 @@ The spec includes three conformance checklists:
Key requirements:
- Produce UTF-8 with LF line endings
- Use consistent indentation (default 2 spaces, no tabs)
- Escape only `\\`, `\"`, `\n`, `\r`, `\t` in quoted strings; any other escape is invalid
- 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
@@ -150,7 +149,7 @@ Key requirements:
- 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.5).
- 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>
@@ -165,8 +164,8 @@ Validators should verify:
## Versioning
The spec uses semantic versioning (major.minor):
- **Major version** (e.g., v{{ $spec.version }}): Breaking changes, incompatible with previous versions
- **Minor version** (e.g., v1.5 → v1.6): Clarifications, additional requirements, or backward-compatible additions
- **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.
+12 -7
View File
@@ -178,7 +178,7 @@ pairs[2]:
```
```yaml [Empty Array]
items[0]:
items: []
```
:::
@@ -205,7 +205,7 @@ enabled: "true"
These strings must be quoted because they look like numbers/booleans.
### Strings with Active Delimiter
### Strings Containing Delimiters
::: code-group
@@ -221,7 +221,7 @@ note: "hello, world"
:::
Strings containing the active delimiter (comma by default) must be quoted.
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
@@ -263,8 +263,8 @@ Strings **must** be quoted if they:
- 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: `:`, `"`, `\`, `[`, `]`, `{`, `}`, newline, tab, carriage return
- Contain the active delimiter (comma by default, or tab/pipe if declared in header)
- 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:
@@ -276,7 +276,7 @@ note: This has inner spaces
## Escape Sequences
Only five escape sequences are valid in quoted strings:
Six escape sequences are valid in quoted strings:
| Character | Escape |
|-----------|--------|
@@ -285,8 +285,9 @@ Only five escape sequences are valid in quoted strings:
| Newline | `\n` |
| Carriage return | `\r` |
| Tab | `\t` |
| Any other U+0000U+001F control character | `\uXXXX` |
All other escapes (e.g., `\x`, `\u`) are invalid.
Other escapes (e.g., `\x`, `\0`, `\b`) are invalid, and lone-surrogate `\uXXXX` values (U+D800U+DFFF) are rejected.
## Array Headers
@@ -359,3 +360,7 @@ See [Format Overview Key Folding](/guide/format-overview#key-folding-optiona
| `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.
:::
+1
View File
@@ -16,6 +16,7 @@ const config: FlatConfigComposer<TypedFlatConfigItem, ConfigNames> = antfu({
'import/no-duplicates': 'off',
'style/no-tabs': 'off',
'yaml/quotes': 'off',
'yaml/indent': 'off',
},
})
+7 -7
View File
@@ -1,7 +1,7 @@
{
"name": "@toon-format/monorepo",
"type": "module",
"version": "2.2.0",
"version": "2.3.0",
"private": true,
"packageManager": "pnpm@10.33.4",
"scripts": {
@@ -17,16 +17,16 @@
"release": "bumpp -r"
},
"devDependencies": {
"@antfu/eslint-config": "^8.2.0",
"@commitlint/types": "^21.0.0",
"@types/node": "^25.6.2",
"@antfu/eslint-config": "^8.3.0",
"@commitlint/types": "^21.0.1",
"@types/node": "^25.9.1",
"automd": "^0.4.3",
"bumpp": "^11.1.0",
"eslint": "^10.3.0",
"eslint": "^10.4.0",
"eslint-flat-config-utils": "^3.2.0",
"tsdown": "^0.22.0",
"typescript": "^6.0.3",
"vite": "^8.0.11",
"vitest": "^4.1.5"
"vite": "^8.0.13",
"vitest": "^4.1.7"
}
}
+4 -2
View File
@@ -63,7 +63,7 @@ cat data.toon | toon --decode
| `--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 |
| `--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`) |
@@ -100,12 +100,14 @@ The `--delimiter` value must be the actual delimiter character. In bash/zsh, use
### 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:
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "@toon-format/cli",
"type": "module",
"version": "2.2.0",
"version": "2.3.0",
"packageManager": "pnpm@10.33.4",
"description": "CLI for JSON ↔ TOON conversion using @toon-format/toon",
"author": "Johann Schopplich <hello@johannschopplich.com>",
+1 -1
View File
@@ -46,7 +46,7 @@ const args: ArgsDef = {
},
strict: {
type: 'boolean',
description: 'Enable strict mode for decoding',
description: 'Strict decode validation (disable with --no-strict)',
default: true,
},
keyFolding: {
+12 -4
View File
@@ -4,7 +4,7 @@
[![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.0](https://img.shields.io/badge/spec-v3.0-fef3c0?labelColor=1b1b1f)](https://github.com/toon-format/spec)
[![SPEC v3.2](https://img.shields.io/badge/spec-v3.2-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)
@@ -134,9 +134,16 @@ hikes[3]{id,name,distanceKm,elevationGain,companion,wasSunny}:
- 🧺 **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.
## What's New in v3.2
- Strict mode rejects duplicate sibling keys; with `strict: false` the last value wins.
- Malformed array headers (`[03]`, `[1] foo:`, header/field delimiter mismatch) error in strict mode instead of silently degrading.
- Tabular form excludes arrays that contain an empty `{}` element those fall back to the expanded list form.
- Nested arrays-of-objects can appear as list items via an explicit `- [N]:` header.
## 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 §18.2](https://github.com/toon-format/spec/blob/main/SPEC.md#182-provisional-media-type) for normative details.
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
@@ -817,7 +824,7 @@ Experiment with TOON format interactively using these tools for token comparison
### Official Playground
The [TOON Playground](https://toonformat.dev/playground) lets you convert JSON to TOON in real-time, compare token counts, and share your experiments via URL.
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
@@ -898,8 +905,9 @@ Comprehensive guides, references, and resources to help you get the most out of
### Tools & Integration
- [CLI](https://toonformat.dev/cli/) Command-line tool for JSON↔TOON conversions
- [Using TOON with LLMs](https://toonformat.dev/guide/llm-prompts) Prompting strategies & validation
- [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
+2 -2
View File
@@ -1,7 +1,7 @@
{
"name": "@toon-format/toon",
"type": "module",
"version": "2.2.0",
"version": "2.3.0",
"packageManager": "pnpm@10.33.4",
"description": "Token-Oriented Object Notation (TOON) Compact, human-readable, schema-aware encoding of JSON for LLM prompts",
"author": "Johann Schopplich <hello@johannschopplich.com>",
@@ -38,6 +38,6 @@
"test": "vitest"
},
"devDependencies": {
"@toon-format/spec": "^3.0.3"
"@toon-format/spec": "^3.2.0"
}
}
+98 -20
View File
@@ -140,9 +140,16 @@ export function* decodeStreamSync(
return
}
if (first.content.trim() === '[]') {
cursor.advanceSync()
yield { type: 'startArray', length: 0 }
yield { type: 'endArray' }
return
}
// Check for root array
if (isArrayHeaderContent(first.content)) {
const headerInfo = withLine(first, () => parseArrayHeaderLine(first.content, DEFAULT_DELIMITER))
const headerInfo = withLine(first, () => parseArrayHeaderLine(first.content, DEFAULT_DELIMITER, resolvedOptions.strict))
if (headerInfo) {
cursor.advanceSync()
yield* decodeArrayFromHeaderSync(headerInfo.header, headerInfo.inlineValues, cursor, 0, resolvedOptions, first)
@@ -159,9 +166,17 @@ export function* decodeStreamSync(
return
}
if (!isKeyValueLineSync(first) && cursor.peekSync()?.depth === 0) {
throw new ToonDecodeError(
'Top-level document must start with a key-value or array-header line',
{ line: first.lineNumber, source: first.raw },
)
}
// Root object
const rootSeenKeys = resolvedOptions.strict ? new Set<string>() : undefined
yield { type: 'startObject' }
yield* decodeKeyValueSync(first, cursor, 0, resolvedOptions)
yield* decodeKeyValueSync(first, cursor, 0, resolvedOptions, rootSeenKeys)
// Process remaining object fields
while (!cursor.atEndSync()) {
@@ -171,23 +186,37 @@ export function* decodeStreamSync(
}
cursor.advanceSync()
yield* decodeKeyValueSync(line, cursor, 0, resolvedOptions)
yield* decodeKeyValueSync(line, cursor, 0, resolvedOptions, rootSeenKeys)
}
yield { type: 'endObject' }
}
function assertNoDuplicateKey(key: string, line: ParsedLine, seenKeys: Set<string> | undefined): void {
if (!seenKeys)
return
if (seenKeys.has(key)) {
throw new ToonDecodeError(
`Duplicate sibling key "${key}"`,
{ line: line.lineNumber, source: line.raw },
)
}
seenKeys.add(key)
}
function* decodeKeyValueSync(
line: ParsedLine,
cursor: StreamingLineCursor,
baseDepth: Depth,
options: DecoderContext,
seenKeys?: Set<string>,
): Generator<JsonStreamEvent> {
const content = line.content
// Check for array header first
const arrayHeader = withLine(line, () => parseArrayHeaderLine(content, DEFAULT_DELIMITER))
const arrayHeader = withLine(line, () => parseArrayHeaderLine(content, DEFAULT_DELIMITER, options.strict))
if (arrayHeader && arrayHeader.header.key !== undefined) {
assertNoDuplicateKey(arrayHeader.header.key, line, seenKeys)
yield { type: 'key', key: arrayHeader.header.key }
yield* decodeArrayFromHeaderSync(arrayHeader.header, arrayHeader.inlineValues, cursor, baseDepth, options, line)
return
@@ -198,6 +227,7 @@ function* decodeKeyValueSync(
const colonIndex = content.indexOf(COLON, key.length)
const rest = colonIndex >= 0 ? content.slice(colonIndex + 1).trim() : ''
assertNoDuplicateKey(key, line, seenKeys)
yield isQuoted ? { type: 'key', key, wasQuoted: true } : { type: 'key', key }
// No value after colon - expect nested object or empty
@@ -216,6 +246,12 @@ function* decodeKeyValueSync(
return
}
if (rest === '[]') {
yield { type: 'startArray', length: 0 }
yield { type: 'endArray' }
return
}
// Inline primitive value
yield { type: 'primitive', value: withLine(line, () => parsePrimitiveToken(rest)) }
}
@@ -226,6 +262,7 @@ function* decodeObjectFieldsSync(
options: DecoderContext,
): Generator<JsonStreamEvent> {
let computedDepth: Depth | undefined
const seenKeys = options.strict ? new Set<string>() : undefined
while (!cursor.atEndSync()) {
const line = cursor.peekSync()
@@ -239,7 +276,7 @@ function* decodeObjectFieldsSync(
if (line.depth === computedDepth) {
cursor.advanceSync()
yield* decodeKeyValueSync(line, cursor, computedDepth, options)
yield* decodeKeyValueSync(line, cursor, computedDepth, options, seenKeys)
}
else {
break
@@ -438,11 +475,17 @@ function* decodeListItemSync(
return
}
if (afterHyphen.trim() === '[]') {
yield { type: 'startArray', length: 0 }
yield { type: 'endArray' }
return
}
const itemLine: ParsedLine = { ...line, content: afterHyphen }
// Check for array header after hyphen
if (isArrayHeaderContent(afterHyphen)) {
const arrayHeader = withLine(itemLine, () => parseArrayHeaderLine(afterHyphen, DEFAULT_DELIMITER))
const arrayHeader = withLine(itemLine, () => parseArrayHeaderLine(afterHyphen, DEFAULT_DELIMITER, options.strict))
if (arrayHeader) {
yield* decodeArrayFromHeaderSync(arrayHeader.header, arrayHeader.inlineValues, cursor, baseDepth, options, itemLine)
return
@@ -450,10 +493,11 @@ function* decodeListItemSync(
}
// Check for tabular-first list-item object: `- key[N]{fields}:`
const headerInfo = withLine(itemLine, () => parseArrayHeaderLine(afterHyphen, DEFAULT_DELIMITER))
const headerInfo = withLine(itemLine, () => parseArrayHeaderLine(afterHyphen, DEFAULT_DELIMITER, options.strict))
if (headerInfo && headerInfo.header.key !== undefined && headerInfo.header.fields !== undefined) {
// Object with tabular array as first field
const header = headerInfo.header
const seenKeys = options.strict ? new Set<string>([header.key!]) : undefined
yield { type: 'startObject' }
yield { type: 'key', key: header.key! }
@@ -470,7 +514,7 @@ function* decodeListItemSync(
if (nextLine.depth === followDepth && !nextLine.content.startsWith(LIST_ITEM_PREFIX)) {
cursor.advanceSync()
yield* decodeKeyValueSync(nextLine, cursor, followDepth, options)
yield* decodeKeyValueSync(nextLine, cursor, followDepth, options, seenKeys)
}
else {
break
@@ -483,8 +527,9 @@ function* decodeListItemSync(
// Check for object first field after hyphen
if (isKeyValueContent(afterHyphen)) {
const seenKeys = options.strict ? new Set<string>() : undefined
yield { type: 'startObject' }
yield* decodeKeyValueSync(itemLine, cursor, baseDepth + 1, options)
yield* decodeKeyValueSync(itemLine, cursor, baseDepth + 1, options, seenKeys)
// Read subsequent fields
const followDepth = baseDepth + 1
@@ -496,7 +541,7 @@ function* decodeListItemSync(
if (nextLine.depth === followDepth && !nextLine.content.startsWith(LIST_ITEM_PREFIX)) {
cursor.advanceSync()
yield* decodeKeyValueSync(nextLine, cursor, followDepth, options)
yield* decodeKeyValueSync(nextLine, cursor, followDepth, options, seenKeys)
}
else {
break
@@ -559,9 +604,16 @@ export async function* decodeStream(
return
}
if (first.content.trim() === '[]') {
await cursor.advance()
yield { type: 'startArray', length: 0 }
yield { type: 'endArray' }
return
}
// Check for root array
if (isArrayHeaderContent(first.content)) {
const headerInfo = withLine(first, () => parseArrayHeaderLine(first.content, DEFAULT_DELIMITER))
const headerInfo = withLine(first, () => parseArrayHeaderLine(first.content, DEFAULT_DELIMITER, resolvedOptions.strict))
if (headerInfo) {
await cursor.advance()
yield* decodeArrayFromHeaderAsync(headerInfo.header, headerInfo.inlineValues, cursor, 0, resolvedOptions, first)
@@ -577,9 +629,17 @@ export async function* decodeStream(
return
}
if (!isKeyValueLineSync(first) && (await cursor.peek())?.depth === 0) {
throw new ToonDecodeError(
'Top-level document must start with a key-value or array-header line',
{ line: first.lineNumber, source: first.raw },
)
}
// Root object
const rootSeenKeys = resolvedOptions.strict ? new Set<string>() : undefined
yield { type: 'startObject' }
yield* decodeKeyValueAsync(first, cursor, 0, resolvedOptions)
yield* decodeKeyValueAsync(first, cursor, 0, resolvedOptions, rootSeenKeys)
// Process remaining object fields
while (!(await cursor.atEnd())) {
@@ -588,7 +648,7 @@ export async function* decodeStream(
break
}
await cursor.advance()
yield* decodeKeyValueAsync(line, cursor, 0, resolvedOptions)
yield* decodeKeyValueAsync(line, cursor, 0, resolvedOptions, rootSeenKeys)
}
yield { type: 'endObject' }
@@ -604,12 +664,14 @@ async function* decodeKeyValueAsync(
cursor: StreamingLineCursor,
baseDepth: Depth,
options: DecoderContext,
seenKeys?: Set<string>,
): AsyncGenerator<JsonStreamEvent> {
const content = line.content
// Check for array header first
const arrayHeader = withLine(line, () => parseArrayHeaderLine(content, DEFAULT_DELIMITER))
const arrayHeader = withLine(line, () => parseArrayHeaderLine(content, DEFAULT_DELIMITER, options.strict))
if (arrayHeader && arrayHeader.header.key !== undefined) {
assertNoDuplicateKey(arrayHeader.header.key, line, seenKeys)
yield { type: 'key', key: arrayHeader.header.key }
yield* decodeArrayFromHeaderAsync(arrayHeader.header, arrayHeader.inlineValues, cursor, baseDepth, options, line)
return
@@ -620,6 +682,7 @@ async function* decodeKeyValueAsync(
const colonIndex = content.indexOf(COLON, key.length)
const rest = colonIndex >= 0 ? content.slice(colonIndex + 1).trim() : ''
assertNoDuplicateKey(key, line, seenKeys)
yield isQuoted ? { type: 'key', key, wasQuoted: true } : { type: 'key', key }
// No value after colon - expect nested object or empty
@@ -638,6 +701,12 @@ async function* decodeKeyValueAsync(
return
}
if (rest === '[]') {
yield { type: 'startArray', length: 0 }
yield { type: 'endArray' }
return
}
// Inline primitive value
yield { type: 'primitive', value: withLine(line, () => parsePrimitiveToken(rest)) }
}
@@ -648,6 +717,7 @@ async function* decodeObjectFieldsAsync(
options: DecoderContext,
): AsyncGenerator<JsonStreamEvent> {
let computedDepth: Depth | undefined
const seenKeys = options.strict ? new Set<string>() : undefined
while (!(await cursor.atEnd())) {
const line = await cursor.peek()
@@ -661,7 +731,7 @@ async function* decodeObjectFieldsAsync(
if (line.depth === computedDepth) {
await cursor.advance()
yield* decodeKeyValueAsync(line, cursor, computedDepth, options)
yield* decodeKeyValueAsync(line, cursor, computedDepth, options, seenKeys)
}
else {
break
@@ -839,11 +909,17 @@ async function* decodeListItemAsync(
return
}
if (afterHyphen.trim() === '[]') {
yield { type: 'startArray', length: 0 }
yield { type: 'endArray' }
return
}
const itemLine: ParsedLine = { ...line, content: afterHyphen }
// Check for array header after hyphen
if (isArrayHeaderContent(afterHyphen)) {
const arrayHeader = withLine(itemLine, () => parseArrayHeaderLine(afterHyphen, DEFAULT_DELIMITER))
const arrayHeader = withLine(itemLine, () => parseArrayHeaderLine(afterHyphen, DEFAULT_DELIMITER, options.strict))
if (arrayHeader) {
yield* decodeArrayFromHeaderAsync(arrayHeader.header, arrayHeader.inlineValues, cursor, baseDepth, options, itemLine)
return
@@ -851,10 +927,11 @@ async function* decodeListItemAsync(
}
// Check for tabular-first list-item object: `- key[N]{fields}:`
const headerInfo = withLine(itemLine, () => parseArrayHeaderLine(afterHyphen, DEFAULT_DELIMITER))
const headerInfo = withLine(itemLine, () => parseArrayHeaderLine(afterHyphen, DEFAULT_DELIMITER, options.strict))
if (headerInfo && headerInfo.header.key !== undefined && headerInfo.header.fields !== undefined) {
// Object with tabular array as first field
const header = headerInfo.header
const seenKeys = options.strict ? new Set<string>([header.key!]) : undefined
yield { type: 'startObject' }
yield { type: 'key', key: header.key! }
@@ -871,7 +948,7 @@ async function* decodeListItemAsync(
if (nextLine.depth === followDepth && !nextLine.content.startsWith(LIST_ITEM_PREFIX)) {
await cursor.advance()
yield* decodeKeyValueAsync(nextLine, cursor, followDepth, options)
yield* decodeKeyValueAsync(nextLine, cursor, followDepth, options, seenKeys)
}
else {
break
@@ -884,8 +961,9 @@ async function* decodeListItemAsync(
// Check for object first field after hyphen
if (isKeyValueContent(afterHyphen)) {
const seenKeys = options.strict ? new Set<string>() : undefined
yield { type: 'startObject' }
yield* decodeKeyValueAsync(itemLine, cursor, baseDepth + 1, options)
yield* decodeKeyValueAsync(itemLine, cursor, baseDepth + 1, options, seenKeys)
// Read subsequent fields
const followDepth = baseDepth + 1
@@ -897,7 +975,7 @@ async function* decodeListItemAsync(
if (nextLine.depth === followDepth && !nextLine.content.startsWith(LIST_ITEM_PREFIX)) {
await cursor.advance()
yield* decodeKeyValueAsync(nextLine, cursor, followDepth, options)
yield* decodeKeyValueAsync(nextLine, cursor, followDepth, options, seenKeys)
}
else {
break
+48 -10
View File
@@ -8,6 +8,7 @@ import { findClosingQuote, findUnquotedChar, unescapeString } from '../shared/st
export function parseArrayHeaderLine(
content: string,
defaultDelimiter: Delimiter,
strict: boolean = false,
): { header: ArrayHeaderInfo, inlineValues?: string } | undefined {
const trimmedToken = content.trimStart()
@@ -52,9 +53,14 @@ export function parseArrayHeaderLine(
// Check for fields segment (braces come after bracket)
const braceStart = content.indexOf(OPEN_BRACE, bracketEnd)
if (braceStart !== -1 && braceStart < content.indexOf(COLON, bracketEnd)) {
// Validate: no extraneous content between bracket end and brace start
const gapBeforeBrace = content.slice(bracketEnd + 1, braceStart)
if (gapBeforeBrace.trim() !== '') {
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
}
@@ -70,10 +76,15 @@ export function parseArrayHeaderLine(
return
}
// Validate: no extraneous content between bracket/fields end and colon
const gapStart = Math.max(bracketEnd + 1, braceEnd)
const gapBeforeColon = content.slice(gapStart, colonIndex)
if (gapBeforeColon.trim() !== '') {
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
}
@@ -87,12 +98,13 @@ 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
}
@@ -104,6 +116,14 @@ export function parseArrayHeaderLine(
const foundBraceEnd = content.indexOf(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()))
}
}
@@ -119,6 +139,8 @@ export function parseArrayHeaderLine(
}
}
const BRACKET_LENGTH_PATTERN = /^(?:0|[1-9]\d*)$/
export function parseBracketSegment(
seg: string,
defaultDelimiter: Delimiter,
@@ -136,12 +158,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
+4 -5
View File
@@ -128,8 +128,8 @@ export function* encodeArrayLines(
options: ResolvedEncodeOptions,
): Generator<string> {
if (value.length === 0) {
const header = formatHeader(0, { key, delimiter: options.delimiter })
yield indentedLine(depth, header, options.indent)
const line = key != null ? `${encodeKey(key)}: []` : '[]'
yield indentedLine(depth, line, options.indent)
return
}
@@ -324,9 +324,8 @@ export function* encodeObjectAsListItemLines(
}
else if (isJsonArray(firstValue)) {
if (firstValue.length === 0) {
// Empty array: `- key[0]:`
const header = formatHeader(0, { delimiter: options.delimiter })
yield indentedListItem(depth, `${encodedKey}${header}`, options.indent)
// Empty array: `- key: []`
yield indentedListItem(depth, `${encodedKey}: []`, options.indent)
}
else if (isArrayOfPrimitives(firstValue)) {
// Inline primitive array: `- key[N]: values`
+10 -4
View File
@@ -39,9 +39,12 @@ export type {
* // age: 30
*
* encode({ users: [{ id: 1 }, { id: 2 }] })
* // users[]:
* // - id: 1
* // - id: 2
* // users[2]{id}:
* // 1
* // 2
*
* encode({ tags: [] })
* // tags: []
*
* encode(data, { indent: 4, keyFolding: 'safe' })
* ```
@@ -62,9 +65,12 @@ export function encode(input: unknown, options?: EncodeOptions): string {
* decode('name: Alice\nage: 30')
* // { name: 'Alice', age: 30 }
*
* decode('users[]:\n - id: 1\n - id: 2')
* decode('users[2]:\n - id: 1\n - id: 2')
* // { users: [{ id: 1 }, { id: 2 }] }
*
* decode('tags: []')
* // { tags: [] }
*
* decode(toonString, { strict: false, expandPaths: 'safe' })
* ```
*/
+21 -1
View File
@@ -5,6 +5,7 @@ import { BACKSLASH, CARRIAGE_RETURN, DOUBLE_QUOTE, NEWLINE, TAB } from '../const
*
* @remarks
* Handles backslashes, quotes, newlines, carriage returns, and tabs.
* Other U+0000U+001F control characters are emitted as `\uXXXX`.
*/
export function escapeString(value: string): string {
return value
@@ -13,13 +14,16 @@ export function escapeString(value: string): string {
.replace(/\n/g, `${BACKSLASH}n`)
.replace(/\r/g, `${BACKSLASH}r`)
.replace(/\t/g, `${BACKSLASH}t`)
// eslint-disable-next-line no-control-regex
.replace(/[\u0000-\u001F]/g, c => `${BACKSLASH}u${c.charCodeAt(0).toString(16).padStart(4, '0')}`)
}
/**
* Unescapes a string by processing escape sequences.
*
* @remarks
* Handles `\n`, `\t`, `\r`, `\\`, and `\"` escape sequences.
* Handles `\n`, `\t`, `\r`, `\\`, `\"`, and `\uXXXX` escape sequences.
* Lone surrogates in `\uXXXX` are rejected.
*/
export function unescapeString(value: string): string {
let unescaped = ''
@@ -57,6 +61,22 @@ export function unescapeString(value: string): string {
i += 2
continue
}
if (next === 'u') {
if (i + 6 > value.length) {
throw new SyntaxError(`Invalid escape sequence: truncated \\u escape at "${value.slice(i, i + 6)}"`)
}
const hex = value.slice(i + 2, i + 6)
if (!/^[0-9a-f]{4}$/i.test(hex)) {
throw new SyntaxError(`Invalid escape sequence: \\u must be followed by 4 hex digits, got "${hex}"`)
}
const codeUnit = Number.parseInt(hex, 16)
if (codeUnit >= 0xD800 && codeUnit <= 0xDFFF) {
throw new SyntaxError(`Invalid escape sequence: \\u${hex} is a lone surrogate; supplementary code points MUST appear as literal UTF-8`)
}
unescaped += String.fromCodePoint(codeUnit)
i += 6
continue
}
throw new SyntaxError(`Invalid escape sequence: \\${next}`)
}
+3 -2
View File
@@ -71,8 +71,9 @@ export function isSafeUnquoted(value: string, delimiter: string = DEFAULT_DELIMI
return false
}
// Check for control characters (newline, carriage return, tab - always need quoting/escaping)
if (/[\n\r\t]/.test(value)) {
// Check for control characters (any U+0000U+001F always need quoting/escaping)
// eslint-disable-next-line no-control-regex
if (/[\u0000-\u001F]/.test(value)) {
return false
}
+1 -1
View File
@@ -48,7 +48,7 @@ describe('JavaScript-specific type normalization', () => {
it('converts empty Set to empty array', () => {
const result = encode(new Set())
expect(result).toBe('[0]:')
expect(result).toBe('[]')
})
})
+746 -748
View File
File diff suppressed because it is too large Load Diff