Skip to content

Commit 05abb99

Browse files
feat!: standardized encoding for list-item objects (spec v3)
1 parent 7e9fbcf commit 05abb99

File tree

10 files changed

+182
-21
lines changed

10 files changed

+182
-21
lines changed

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44

55
[![CI](https://github.com/toon-format/toon/actions/workflows/ci.yml/badge.svg)](https://github.com/toon-format/toon/actions)
66
[![npm version](https://img.shields.io/npm/v/@toon-format/toon.svg)](https://www.npmjs.com/package/@toon-format/toon)
7-
[![SPEC v2.1](https://img.shields.io/badge/spec-v2.1-lightgray)](https://github.com/toon-format/spec)
7+
[![SPEC v3.0](https://img.shields.io/badge/spec-v3.0-lightgray)](https://github.com/toon-format/spec)
88
[![npm downloads (total)](https://img.shields.io/npm/dt/@toon-format/toon.svg)](https://www.npmjs.com/package/@toon-format/toon)
99
[![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](./LICENSE)
1010

SPEC.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ The TOON specification has moved to a dedicated repository: [github.com/toon-for
44

55
## Current Version
66

7-
**Version 2.1** (2025-11-23)
7+
**Version 3.0** (2025-11-24)
88

99
## Quick Links
1010

docs/.vitepress/theme/index.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ const config: Theme = {
1010
extends: DefaultTheme,
1111
enhanceApp({ app }) {
1212
app.config.globalProperties.$spec = {
13-
version: '2.1',
13+
version: '3.0',
1414
}
1515
app.component('CopyOrDownloadAsMarkdownButtons', CopyOrDownloadAsMarkdownButtons)
1616
},

docs/guide/format-overview.md

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,40 @@ items[3]:
107107

108108
Each element starts with `- ` at one indentation level deeper than the parent array header.
109109

110+
### Objects as List Items
111+
112+
When an array element is an object, it appears as a list item:
113+
114+
```yaml
115+
items[2]:
116+
- id: 1
117+
name: First
118+
- id: 2
119+
name: Second
120+
extra: true
121+
```
122+
123+
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:
124+
125+
```yaml
126+
items[1]:
127+
- users[2]{id,name}:
128+
1,Ada
129+
2,Bob
130+
status: active
131+
```
132+
133+
When the object has only a single tabular field, the same pattern applies:
134+
135+
```yaml
136+
items[1]:
137+
- users[2]{id,name}:
138+
1,Ada
139+
2,Bob
140+
```
141+
142+
This is the canonical encoding for list-item objects whose first field is a tabular array.
143+
110144
### Arrays of Arrays
111145

112146
When you have arrays containing primitive inner arrays:

docs/index.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ hero:
2020
text: CLI
2121
link: /cli/
2222
- theme: alt
23-
text: Spec v2.1
23+
text: Spec v3.0
2424
link: /reference/spec
2525

2626
features:

docs/reference/spec.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ You don't need this page to *use* TOON. It's mainly for implementers and contrib
99
1010
## Current Version
1111

12-
**Spec v{{ $spec.version }}** (2025-11-23) is the current stable version.
12+
**Spec v{{ $spec.version }}** (2025-11-24) is the current stable version.
1313

1414
The spec defines a provisional media type and file extension in §18.2:
1515

docs/reference/syntax-cheatsheet.md

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,28 @@ items[3]:
9797
9898
:::
9999
100+
> [!NOTE]
101+
> 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.
102+
103+
::: code-group
104+
105+
```yaml [Multi-field object]
106+
items[1]:
107+
- users[2]{id,name}:
108+
1,Ada
109+
2,Bob
110+
status: active
111+
```
112+
113+
```yaml [Single-field object]
114+
items[1]:
115+
- users[2]{id,name}:
116+
1,Ada
117+
2,Bob
118+
```
119+
120+
:::
121+
100122
## Arrays of Arrays
101123
102124
::: code-group

packages/toon/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,6 @@
3838
"test": "vitest"
3939
},
4040
"devDependencies": {
41-
"@toon-format/spec": "^2.1.0"
41+
"@toon-format/spec": "^3.0.0"
4242
}
4343
}

packages/toon/src/decode/decoders.ts

Lines changed: 66 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -473,10 +473,42 @@ function* decodeListItemSync(
473473
}
474474
}
475475

476+
// Check for tabular-first list-item object: `- key[N]{fields}:`
477+
const headerInfo = parseArrayHeaderLine(afterHyphen, DEFAULT_DELIMITER)
478+
if (headerInfo && headerInfo.header.key && headerInfo.header.fields) {
479+
// Object with tabular array as first field
480+
const header = headerInfo.header
481+
yield { type: 'startObject' }
482+
yield { type: 'key', key: header.key! }
483+
484+
// Use baseDepth + 1 for the array so rows are at baseDepth + 2
485+
yield* decodeArrayFromHeaderSync(header, headerInfo.inlineValues, cursor, baseDepth + 1, options)
486+
487+
// Read sibling fields at depth = baseDepth + 1
488+
const followDepth = baseDepth + 1
489+
while (!cursor.atEndSync()) {
490+
const nextLine = cursor.peekSync()
491+
if (!nextLine || nextLine.depth < followDepth) {
492+
break
493+
}
494+
495+
if (nextLine.depth === followDepth && !nextLine.content.startsWith(LIST_ITEM_PREFIX)) {
496+
cursor.advanceSync()
497+
yield* decodeKeyValueSync(nextLine.content, cursor, followDepth, options)
498+
}
499+
else {
500+
break
501+
}
502+
}
503+
504+
yield { type: 'endObject' }
505+
return
506+
}
507+
476508
// Check for object first field after hyphen
477509
if (isKeyValueContent(afterHyphen)) {
478510
yield { type: 'startObject' }
479-
yield* decodeKeyValueSync(afterHyphen, cursor, baseDepth, options)
511+
yield* decodeKeyValueSync(afterHyphen, cursor, baseDepth + 1, options)
480512

481513
// Read subsequent fields
482514
const followDepth = baseDepth + 1
@@ -868,10 +900,42 @@ async function* decodeListItemAsync(
868900
}
869901
}
870902

903+
// Check for tabular-first list-item object: `- key[N]{fields}:`
904+
const headerInfo = parseArrayHeaderLine(afterHyphen, DEFAULT_DELIMITER)
905+
if (headerInfo && headerInfo.header.key && headerInfo.header.fields) {
906+
// Object with tabular array as first field
907+
const header = headerInfo.header
908+
yield { type: 'startObject' }
909+
yield { type: 'key', key: header.key! }
910+
911+
// Use baseDepth + 1 for the array so rows are at baseDepth + 2
912+
yield* decodeArrayFromHeaderAsync(header, headerInfo.inlineValues, cursor, baseDepth + 1, options)
913+
914+
// Read sibling fields at depth = baseDepth + 1
915+
const followDepth = baseDepth + 1
916+
while (!(await cursor.atEnd())) {
917+
const nextLine = await cursor.peek()
918+
if (!nextLine || nextLine.depth < followDepth) {
919+
break
920+
}
921+
922+
if (nextLine.depth === followDepth && !nextLine.content.startsWith(LIST_ITEM_PREFIX)) {
923+
await cursor.advance()
924+
yield* decodeKeyValueAsync(nextLine.content, cursor, followDepth, options)
925+
}
926+
else {
927+
break
928+
}
929+
}
930+
931+
yield { type: 'endObject' }
932+
return
933+
}
934+
871935
// Check for object first field after hyphen
872936
if (isKeyValueContent(afterHyphen)) {
873937
yield { type: 'startObject' }
874-
yield* decodeKeyValueAsync(afterHyphen, cursor, baseDepth, options)
938+
yield* decodeKeyValueAsync(afterHyphen, cursor, baseDepth + 1, options)
875939

876940
// Read subsequent fields
877941
const followDepth = baseDepth + 1

packages/toon/src/encode/encoders.ts

Lines changed: 54 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -295,25 +295,66 @@ export function* encodeObjectAsListItemLines(
295295
}
296296

297297
const entries = Object.entries(obj)
298+
const [firstKey, firstValue] = entries[0]!
299+
const restEntries = entries.slice(1)
298300

299-
// Compact form only when the list-item object has a single tabular array field
300-
if (entries.length === 1) {
301-
const [key, value] = entries[0]!
301+
// Check if first field is a tabular array
302+
if (isJsonArray(firstValue) && isArrayOfObjects(firstValue)) {
303+
const header = extractTabularHeader(firstValue)
304+
if (header) {
305+
// Tabular array as first field
306+
const formattedHeader = formatHeader(firstValue.length, { key: firstKey, fields: header, delimiter: options.delimiter })
307+
yield indentedListItem(depth, formattedHeader, options.indent)
308+
yield* writeTabularRowsLines(firstValue, header, depth + 2, options)
309+
310+
if (restEntries.length > 0) {
311+
const restObj: JsonObject = Object.fromEntries(restEntries)
312+
yield* encodeObjectLines(restObj, depth + 1, options)
313+
}
314+
return
315+
}
316+
}
302317

303-
if (isJsonArray(value) && isArrayOfObjects(value)) {
304-
const header = extractTabularHeader(value)
305-
if (header) {
306-
const formattedHeader = formatHeader(value.length, { key, fields: header, delimiter: options.delimiter })
307-
yield indentedListItem(depth, formattedHeader, options.indent)
308-
yield* writeTabularRowsLines(value, header, depth + 1, options)
309-
return
318+
const encodedKey = encodeKey(firstKey)
319+
320+
if (isJsonPrimitive(firstValue)) {
321+
// Primitive value: `- key: value`
322+
const encodedValue = encodePrimitive(firstValue, options.delimiter)
323+
yield indentedListItem(depth, `${encodedKey}: ${encodedValue}`, options.indent)
324+
}
325+
else if (isJsonArray(firstValue)) {
326+
if (firstValue.length === 0) {
327+
// Empty array: `- key[0]:`
328+
const header = formatHeader(0, { delimiter: options.delimiter })
329+
yield indentedListItem(depth, `${encodedKey}${header}`, options.indent)
330+
}
331+
else if (isArrayOfPrimitives(firstValue)) {
332+
// Inline primitive array: `- key[N]: values`
333+
const arrayLine = encodeInlineArrayLine(firstValue, options.delimiter)
334+
yield indentedListItem(depth, `${encodedKey}${arrayLine}`, options.indent)
335+
}
336+
else {
337+
// Non-inline array: `- key[N]:` with items at depth + 2
338+
const header = formatHeader(firstValue.length, { delimiter: options.delimiter })
339+
yield indentedListItem(depth, `${encodedKey}${header}`, options.indent)
340+
341+
for (const item of firstValue) {
342+
yield* encodeListItemValueLines(item, depth + 2, options)
310343
}
311344
}
312345
}
346+
else if (isJsonObject(firstValue)) {
347+
// Object value: `- key:` with fields at depth + 2
348+
yield indentedListItem(depth, `${encodedKey}:`, options.indent)
349+
if (!isEmptyObject(firstValue)) {
350+
yield* encodeObjectLines(firstValue, depth + 2, options)
351+
}
352+
}
313353

314-
// All other cases: emit a bare list item marker and all fields at depth + 1
315-
yield indentedLine(depth, LIST_ITEM_MARKER, options.indent)
316-
yield* encodeObjectLines(obj, depth + 1, options)
354+
if (restEntries.length > 0) {
355+
const restObj: JsonObject = Object.fromEntries(restEntries)
356+
yield* encodeObjectLines(restObj, depth + 1, options)
357+
}
317358
}
318359

319360
// #endregion

0 commit comments

Comments
 (0)