mirror of
https://github.com/voson-wang/toon.git
synced 2026-01-29 23:34:10 +08:00
feat: parse nested tabular arrays in list items with bare hyphen (spec v2.1)
This commit is contained in:
@@ -4,7 +4,7 @@
|
|||||||
|
|
||||||
[](https://github.com/toon-format/toon/actions)
|
[](https://github.com/toon-format/toon/actions)
|
||||||
[](https://www.npmjs.com/package/@toon-format/toon)
|
[](https://www.npmjs.com/package/@toon-format/toon)
|
||||||
[](https://github.com/toon-format/spec)
|
[](https://github.com/toon-format/spec)
|
||||||
[](https://www.npmjs.com/package/@toon-format/toon)
|
[](https://www.npmjs.com/package/@toon-format/toon)
|
||||||
[](./LICENSE)
|
[](./LICENSE)
|
||||||
|
|
||||||
@@ -872,12 +872,12 @@ Comprehensive guides, references, and resources to help you get the most out of
|
|||||||
|
|
||||||
- [API Reference](https://toonformat.dev/reference/api) – TypeScript/JavaScript encode/decode API
|
- [API Reference](https://toonformat.dev/reference/api) – TypeScript/JavaScript encode/decode API
|
||||||
- [Syntax Cheatsheet](https://toonformat.dev/reference/syntax-cheatsheet) – Quick format lookup
|
- [Syntax Cheatsheet](https://toonformat.dev/reference/syntax-cheatsheet) – Quick format lookup
|
||||||
- [Specification v2.0](https://github.com/toon-format/spec/blob/main/SPEC.md) – Normative rules for implementers
|
- [Specification v2.1](https://github.com/toon-format/spec/blob/main/SPEC.md) – Normative rules for implementers
|
||||||
|
|
||||||
## Other Implementations
|
## Other Implementations
|
||||||
|
|
||||||
> [!NOTE]
|
> [!NOTE]
|
||||||
> When implementing TOON in other languages, please follow the [Specification](https://github.com/toon-format/spec/blob/main/SPEC.md) (currently v2.0) 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 implementations.
|
> When implementing TOON in other languages, please follow the [Specification](https://github.com/toon-format/spec/blob/main/SPEC.md) (currently v2.1) 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 implementations.
|
||||||
|
|
||||||
### Official Implementations
|
### Official Implementations
|
||||||
|
|
||||||
|
|||||||
2
SPEC.md
2
SPEC.md
@@ -4,7 +4,7 @@ The TOON specification has moved to a dedicated repository: [github.com/toon-for
|
|||||||
|
|
||||||
## Current Version
|
## Current Version
|
||||||
|
|
||||||
**Version 2.0** (2025-11-10)
|
**Version 2.1** (2025-11-23)
|
||||||
|
|
||||||
## Quick Links
|
## Quick Links
|
||||||
|
|
||||||
|
|||||||
@@ -8,21 +8,21 @@
|
|||||||
"fetch:github-repos": "tsx scripts/fetch-github-repos.ts"
|
"fetch:github-repos": "tsx scripts/fetch-github-repos.ts"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@ai-sdk/anthropic": "^2.0.44",
|
"@ai-sdk/anthropic": "^2.0.45",
|
||||||
"@ai-sdk/google": "^2.0.31",
|
"@ai-sdk/google": "^2.0.42",
|
||||||
"@ai-sdk/openai": "^2.0.65",
|
"@ai-sdk/openai": "^2.0.71",
|
||||||
"@ai-sdk/provider": "^2.0.0",
|
"@ai-sdk/provider": "^2.0.0",
|
||||||
"@ai-sdk/xai": "^2.0.32",
|
"@ai-sdk/xai": "^2.0.35",
|
||||||
"@clack/prompts": "^0.11.0",
|
"@clack/prompts": "^0.11.0",
|
||||||
"@faker-js/faker": "^10.1.0",
|
"@faker-js/faker": "^10.1.0",
|
||||||
"ai": "^5.0.92",
|
"ai": "^5.0.101",
|
||||||
"csv-stringify": "^6.6.0",
|
"csv-stringify": "^6.6.0",
|
||||||
"fast-xml-parser": "^5.3.1",
|
"fast-xml-parser": "^5.3.2",
|
||||||
"gpt-tokenizer": "^3.4.0",
|
"gpt-tokenizer": "^3.4.0",
|
||||||
"ofetch": "^1.5.1",
|
"ofetch": "^1.5.1",
|
||||||
"p-map": "^7.0.4",
|
"p-map": "^7.0.4",
|
||||||
"p-queue": "^9.0.0",
|
"p-queue": "^9.0.1",
|
||||||
"unstorage": "^1.17.2",
|
"unstorage": "^1.17.3",
|
||||||
"yaml": "^2.8.1"
|
"yaml": "^2.8.1"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ const config: Theme = {
|
|||||||
extends: DefaultTheme,
|
extends: DefaultTheme,
|
||||||
enhanceApp({ app }) {
|
enhanceApp({ app }) {
|
||||||
app.config.globalProperties.$spec = {
|
app.config.globalProperties.$spec = {
|
||||||
version: '2.0',
|
version: '2.1',
|
||||||
}
|
}
|
||||||
app.component('CopyOrDownloadAsMarkdownButtons', CopyOrDownloadAsMarkdownButtons)
|
app.component('CopyOrDownloadAsMarkdownButtons', CopyOrDownloadAsMarkdownButtons)
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ hero:
|
|||||||
text: CLI
|
text: CLI
|
||||||
link: /cli/
|
link: /cli/
|
||||||
- theme: alt
|
- theme: alt
|
||||||
text: Spec v2.0
|
text: Spec v2.1
|
||||||
link: /reference/spec
|
link: /reference/spec
|
||||||
|
|
||||||
features:
|
features:
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
"preview": "vitepress preview"
|
"preview": "vitepress preview"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"unocss": "^66.5.6",
|
"unocss": "^66.5.9",
|
||||||
"vitepress": "^1.6.4",
|
"vitepress": "^1.6.4",
|
||||||
"vitepress-plugin-llms": "^1.9.3"
|
"vitepress-plugin-llms": "^1.9.3"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ You don't need this page to *use* TOON. It's mainly for implementers and contrib
|
|||||||
|
|
||||||
## Current Version
|
## Current Version
|
||||||
|
|
||||||
**Spec v{{ $spec.version }}** (2025-11-10) is the current stable version.
|
**Spec v{{ $spec.version }}** (2025-11-23) is the current stable version.
|
||||||
|
|
||||||
The spec defines a provisional media type and file extension in §18.2:
|
The spec defines a provisional media type and file extension in §18.2:
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
"type": "module",
|
"type": "module",
|
||||||
"version": "1.3.0",
|
"version": "1.3.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"packageManager": "pnpm@10.21.0",
|
"packageManager": "pnpm@10.23.0",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "pnpm -r --filter=./packages/** run build",
|
"build": "pnpm -r --filter=./packages/** run build",
|
||||||
"automd": "automd",
|
"automd": "automd",
|
||||||
@@ -18,13 +18,13 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@antfu/eslint-config": "^6.2.0",
|
"@antfu/eslint-config": "^6.2.0",
|
||||||
"@types/node": "^24.10.0",
|
"@types/node": "^24.10.1",
|
||||||
"automd": "^0.4.2",
|
"automd": "^0.4.2",
|
||||||
"bumpp": "^10.3.1",
|
"bumpp": "^10.3.1",
|
||||||
"eslint": "^9.39.1",
|
"eslint": "^9.39.1",
|
||||||
"tsdown": "^0.16.2",
|
"tsdown": "^0.16.6",
|
||||||
"tsx": "^4.20.6",
|
"tsx": "^4.20.6",
|
||||||
"typescript": "^5.9.3",
|
"typescript": "^5.9.3",
|
||||||
"vitest": "^4.0.8"
|
"vitest": "^4.0.13"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
"name": "@toon-format/cli",
|
"name": "@toon-format/cli",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"version": "1.3.0",
|
"version": "1.3.0",
|
||||||
"packageManager": "pnpm@10.21.0",
|
"packageManager": "pnpm@10.23.0",
|
||||||
"description": "CLI for JSON ↔ TOON conversion using @toon-format/toon",
|
"description": "CLI for JSON ↔ TOON conversion using @toon-format/toon",
|
||||||
"author": "Johann Schopplich <hello@johannschopplich.com>",
|
"author": "Johann Schopplich <hello@johannschopplich.com>",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
"name": "@toon-format/toon",
|
"name": "@toon-format/toon",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"version": "1.3.0",
|
"version": "1.3.0",
|
||||||
"packageManager": "pnpm@10.21.0",
|
"packageManager": "pnpm@10.23.0",
|
||||||
"description": "Token-Oriented Object Notation (TOON) – Compact, human-readable, schema-aware encoding of JSON for LLM prompts",
|
"description": "Token-Oriented Object Notation (TOON) – Compact, human-readable, schema-aware encoding of JSON for LLM prompts",
|
||||||
"author": "Johann Schopplich <hello@johannschopplich.com>",
|
"author": "Johann Schopplich <hello@johannschopplich.com>",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
@@ -38,6 +38,6 @@
|
|||||||
"test": "vitest"
|
"test": "vitest"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@toon-format/spec": "^2.0.1"
|
"@toon-format/spec": "^2.1.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -411,10 +411,46 @@ function* decodeListItemSync(
|
|||||||
let afterHyphen: string
|
let afterHyphen: string
|
||||||
|
|
||||||
if (line.content === LIST_ITEM_MARKER) {
|
if (line.content === LIST_ITEM_MARKER) {
|
||||||
|
// Bare list item marker: either an empty object or fields at depth +1
|
||||||
|
const followDepth = baseDepth + 1
|
||||||
|
const nextLine = cursor.peekSync()
|
||||||
|
|
||||||
|
if (!nextLine || nextLine.depth < followDepth) {
|
||||||
|
// No fields at the next depth: treat as empty object
|
||||||
yield { type: 'startObject' }
|
yield { type: 'startObject' }
|
||||||
yield { type: 'endObject' }
|
yield { type: 'endObject' }
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (nextLine.depth === followDepth && !nextLine.content.startsWith(LIST_ITEM_PREFIX)) {
|
||||||
|
// Fields at depth +1: parse them as an object
|
||||||
|
yield { type: 'startObject' }
|
||||||
|
|
||||||
|
while (!cursor.atEndSync()) {
|
||||||
|
const fieldLine = cursor.peekSync()
|
||||||
|
if (!fieldLine || fieldLine.depth < followDepth) {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
if (fieldLine.depth === followDepth && !fieldLine.content.startsWith(LIST_ITEM_PREFIX)) {
|
||||||
|
cursor.advanceSync()
|
||||||
|
yield* decodeKeyValueSync(fieldLine.content, cursor, followDepth, options)
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
yield { type: 'endObject' }
|
||||||
|
return
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
// Next line is another list item or at a different depth: treat as empty object
|
||||||
|
yield { type: 'startObject' }
|
||||||
|
yield { type: 'endObject' }
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
else if (line.content.startsWith(LIST_ITEM_PREFIX)) {
|
else if (line.content.startsWith(LIST_ITEM_PREFIX)) {
|
||||||
afterHyphen = line.content.slice(LIST_ITEM_PREFIX.length)
|
afterHyphen = line.content.slice(LIST_ITEM_PREFIX.length)
|
||||||
}
|
}
|
||||||
@@ -509,7 +545,7 @@ export async function* decodeStream(
|
|||||||
// Get first line to determine root form
|
// Get first line to determine root form
|
||||||
const first = await cursor.peek()
|
const first = await cursor.peek()
|
||||||
if (!first) {
|
if (!first) {
|
||||||
// Empty input decodes to empty object (matches decode('') behavior)
|
// Empty input decodes to empty object
|
||||||
yield { type: 'startObject' }
|
yield { type: 'startObject' }
|
||||||
yield { type: 'endObject' }
|
yield { type: 'endObject' }
|
||||||
return
|
return
|
||||||
@@ -770,10 +806,46 @@ async function* decodeListItemAsync(
|
|||||||
let afterHyphen: string
|
let afterHyphen: string
|
||||||
|
|
||||||
if (line.content === LIST_ITEM_MARKER) {
|
if (line.content === LIST_ITEM_MARKER) {
|
||||||
|
// Bare list item marker: either an empty object or fields at depth +1
|
||||||
|
const followDepth = baseDepth + 1
|
||||||
|
const nextLine = await cursor.peek()
|
||||||
|
|
||||||
|
if (!nextLine || nextLine.depth < followDepth) {
|
||||||
|
// No fields at the next depth: treat as empty object
|
||||||
yield { type: 'startObject' }
|
yield { type: 'startObject' }
|
||||||
yield { type: 'endObject' }
|
yield { type: 'endObject' }
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (nextLine.depth === followDepth && !nextLine.content.startsWith(LIST_ITEM_PREFIX)) {
|
||||||
|
// Fields at depth +1: parse them as an object
|
||||||
|
yield { type: 'startObject' }
|
||||||
|
|
||||||
|
while (!cursor.atEnd()) {
|
||||||
|
const fieldLine = await cursor.peek()
|
||||||
|
if (!fieldLine || fieldLine.depth < followDepth) {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
if (fieldLine.depth === followDepth && !fieldLine.content.startsWith(LIST_ITEM_PREFIX)) {
|
||||||
|
await cursor.advance()
|
||||||
|
yield* decodeKeyValueAsync(fieldLine.content, cursor, followDepth, options)
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
yield { type: 'endObject' }
|
||||||
|
return
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
// Next line is another list item or at a different depth: treat as empty object
|
||||||
|
yield { type: 'startObject' }
|
||||||
|
yield { type: 'endObject' }
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
else if (line.content.startsWith(LIST_ITEM_PREFIX)) {
|
else if (line.content.startsWith(LIST_ITEM_PREFIX)) {
|
||||||
afterHyphen = line.content.slice(LIST_ITEM_PREFIX.length)
|
afterHyphen = line.content.slice(LIST_ITEM_PREFIX.length)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -101,7 +101,6 @@ export function* encodeKeyValuePairLines(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// No folding applied - use standard encoding
|
|
||||||
const encodedKey = encodeKey(key)
|
const encodedKey = encodeKey(key)
|
||||||
|
|
||||||
if (isJsonPrimitive(value)) {
|
if (isJsonPrimitive(value)) {
|
||||||
@@ -190,10 +189,10 @@ export function* encodeArrayOfArraysAsListItemsLines(
|
|||||||
export function encodeInlineArrayLine(values: readonly JsonPrimitive[], delimiter: string, prefix?: string): string {
|
export function encodeInlineArrayLine(values: readonly JsonPrimitive[], delimiter: string, prefix?: string): string {
|
||||||
const header = formatHeader(values.length, { key: prefix, delimiter })
|
const header = formatHeader(values.length, { key: prefix, delimiter })
|
||||||
const joinedValue = encodeAndJoinPrimitives(values, 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
|
||||||
}
|
|
||||||
return `${header} ${joinedValue}`
|
return `${header} ${joinedValue}`
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -296,57 +295,25 @@ export function* encodeObjectAsListItemLines(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const entries = Object.entries(obj)
|
const entries = Object.entries(obj)
|
||||||
const [firstKey, firstValue] = entries[0]!
|
|
||||||
const encodedKey = encodeKey(firstKey)
|
|
||||||
|
|
||||||
if (isJsonPrimitive(firstValue)) {
|
// Compact form only when the list-item object has a single tabular array field
|
||||||
yield indentedListItem(depth, `${encodedKey}: ${encodePrimitive(firstValue, options.delimiter)}`, options.indent)
|
if (entries.length === 1) {
|
||||||
}
|
const [key, value] = entries[0]!
|
||||||
else if (isJsonArray(firstValue)) {
|
|
||||||
if (isArrayOfPrimitives(firstValue)) {
|
if (isJsonArray(value) && isArrayOfObjects(value)) {
|
||||||
// Inline format for primitive arrays
|
const header = extractTabularHeader(value)
|
||||||
const arrayPropertyLine = encodeInlineArrayLine(firstValue, options.delimiter, firstKey)
|
|
||||||
yield indentedListItem(depth, arrayPropertyLine, options.indent)
|
|
||||||
}
|
|
||||||
else if (isArrayOfObjects(firstValue)) {
|
|
||||||
// Check if array of objects can use tabular format
|
|
||||||
const header = extractTabularHeader(firstValue)
|
|
||||||
if (header) {
|
if (header) {
|
||||||
// Tabular format for uniform arrays of objects
|
const formattedHeader = formatHeader(value.length, { key, fields: header, delimiter: options.delimiter })
|
||||||
const formattedHeader = formatHeader(firstValue.length, { key: firstKey, fields: header, delimiter: options.delimiter })
|
|
||||||
yield indentedListItem(depth, formattedHeader, options.indent)
|
yield indentedListItem(depth, formattedHeader, options.indent)
|
||||||
yield* writeTabularRowsLines(firstValue, header, depth + 1, options)
|
yield* writeTabularRowsLines(value, header, depth + 1, options)
|
||||||
|
return
|
||||||
}
|
}
|
||||||
else {
|
|
||||||
// Fall back to list format for non-uniform arrays of objects
|
|
||||||
yield indentedListItem(depth, `${encodedKey}[${firstValue.length}]:`, options.indent)
|
|
||||||
for (const item of firstValue) {
|
|
||||||
yield* encodeObjectAsListItemLines(item, depth + 1, options)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
// Complex arrays on separate lines (array of arrays, etc.)
|
|
||||||
yield indentedListItem(depth, `${encodedKey}[${firstValue.length}]:`, options.indent)
|
|
||||||
|
|
||||||
// Encode array contents at depth + 1
|
|
||||||
for (const item of firstValue) {
|
|
||||||
yield* encodeListItemValueLines(item, depth + 1, options)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
else if (isJsonObject(firstValue)) {
|
|
||||||
yield indentedListItem(depth, `${encodedKey}:`, options.indent)
|
|
||||||
if (!isEmptyObject(firstValue)) {
|
|
||||||
yield* encodeObjectLines(firstValue, depth + 2, options)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Remaining entries on indented lines
|
// All other cases: emit a bare list item marker and all fields at depth + 1
|
||||||
for (let i = 1; i < entries.length; i++) {
|
yield indentedLine(depth, LIST_ITEM_MARKER, options.indent)
|
||||||
const [key, value] = entries[i]!
|
yield* encodeObjectLines(obj, depth + 1, options)
|
||||||
yield* encodeKeyValuePairLines(key, value, depth + 1, options)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// #endregion
|
// #endregion
|
||||||
|
|||||||
1237
pnpm-lock.yaml
generated
1237
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user