feat!: standardized encoding for list-item objects (spec v3)

This commit is contained in:
Johann Schopplich
2025-11-24 14:40:36 +01:00
parent 7e9fbcfd40
commit 05abb99a7e
10 changed files with 182 additions and 21 deletions

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) [![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)](https://www.npmjs.com/package/@toon-format/toon) [![npm version](https://img.shields.io/npm/v/@toon-format/toon.svg)](https://www.npmjs.com/package/@toon-format/toon)
[![SPEC v2.1](https://img.shields.io/badge/spec-v2.1-lightgray)](https://github.com/toon-format/spec) [![SPEC v3.0](https://img.shields.io/badge/spec-v3.0-lightgray)](https://github.com/toon-format/spec)
[![npm downloads (total)](https://img.shields.io/npm/dt/@toon-format/toon.svg)](https://www.npmjs.com/package/@toon-format/toon) [![npm downloads (total)](https://img.shields.io/npm/dt/@toon-format/toon.svg)](https://www.npmjs.com/package/@toon-format/toon)
[![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](./LICENSE) [![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](./LICENSE)

View File

@@ -4,7 +4,7 @@ The TOON specification has moved to a dedicated repository: [github.com/toon-for
## Current Version ## Current Version
**Version 2.1** (2025-11-23) **Version 3.0** (2025-11-24)
## Quick Links ## Quick Links

View File

@@ -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.1', version: '3.0',
} }
app.component('CopyOrDownloadAsMarkdownButtons', CopyOrDownloadAsMarkdownButtons) app.component('CopyOrDownloadAsMarkdownButtons', CopyOrDownloadAsMarkdownButtons)
}, },

View File

@@ -107,6 +107,40 @@ items[3]:
Each element starts with `- ` at one indentation level deeper than the parent array header. Each element starts with `- ` at one indentation level deeper than the parent array header.
### Objects as List Items
When an array element is an object, it appears as a list item:
```yaml
items[2]:
- id: 1
name: First
- id: 2
name: Second
extra: true
```
When a tabular array is the first field of a list-item object, the tabular header appears on the hyphen line, with rows indented two levels deeper and other fields indented one level deeper:
```yaml
items[1]:
- users[2]{id,name}:
1,Ada
2,Bob
status: active
```
When the object has only a single tabular field, the same pattern applies:
```yaml
items[1]:
- users[2]{id,name}:
1,Ada
2,Bob
```
This is the canonical encoding for list-item objects whose first field is a tabular array.
### Arrays of Arrays ### Arrays of Arrays
When you have arrays containing primitive inner arrays: When you have arrays containing primitive inner arrays:

View File

@@ -20,7 +20,7 @@ hero:
text: CLI text: CLI
link: /cli/ link: /cli/
- theme: alt - theme: alt
text: Spec v2.1 text: Spec v3.0
link: /reference/spec link: /reference/spec
features: features:

View File

@@ -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-23) is the current stable version. **Spec v{{ $spec.version }}** (2025-11-24) 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:

View File

@@ -97,6 +97,28 @@ items[3]:
::: :::
> [!NOTE]
> When a list-item object has a tabular array as its first field, the tabular header appears on the hyphen line. Rows are indented two levels deeper than the hyphen, and other fields are indented one level deeper. This is the canonical encoding for this pattern.
::: code-group
```yaml [Multi-field object]
items[1]:
- users[2]{id,name}:
1,Ada
2,Bob
status: active
```
```yaml [Single-field object]
items[1]:
- users[2]{id,name}:
1,Ada
2,Bob
```
:::
## Arrays of Arrays ## Arrays of Arrays
::: code-group ::: code-group

View File

@@ -38,6 +38,6 @@
"test": "vitest" "test": "vitest"
}, },
"devDependencies": { "devDependencies": {
"@toon-format/spec": "^2.1.0" "@toon-format/spec": "^3.0.0"
} }
} }

View File

@@ -473,10 +473,42 @@ function* decodeListItemSync(
} }
} }
// Check for tabular-first list-item object: `- key[N]{fields}:`
const headerInfo = parseArrayHeaderLine(afterHyphen, DEFAULT_DELIMITER)
if (headerInfo && headerInfo.header.key && headerInfo.header.fields) {
// Object with tabular array as first field
const header = headerInfo.header
yield { type: 'startObject' }
yield { type: 'key', key: header.key! }
// Use baseDepth + 1 for the array so rows are at baseDepth + 2
yield* decodeArrayFromHeaderSync(header, headerInfo.inlineValues, cursor, baseDepth + 1, options)
// Read sibling fields at depth = baseDepth + 1
const followDepth = baseDepth + 1
while (!cursor.atEndSync()) {
const nextLine = cursor.peekSync()
if (!nextLine || nextLine.depth < followDepth) {
break
}
if (nextLine.depth === followDepth && !nextLine.content.startsWith(LIST_ITEM_PREFIX)) {
cursor.advanceSync()
yield* decodeKeyValueSync(nextLine.content, cursor, followDepth, options)
}
else {
break
}
}
yield { type: 'endObject' }
return
}
// Check for object first field after hyphen // Check for object first field after hyphen
if (isKeyValueContent(afterHyphen)) { if (isKeyValueContent(afterHyphen)) {
yield { type: 'startObject' } yield { type: 'startObject' }
yield* decodeKeyValueSync(afterHyphen, cursor, baseDepth, options) yield* decodeKeyValueSync(afterHyphen, cursor, baseDepth + 1, options)
// Read subsequent fields // Read subsequent fields
const followDepth = baseDepth + 1 const followDepth = baseDepth + 1
@@ -868,10 +900,42 @@ async function* decodeListItemAsync(
} }
} }
// Check for tabular-first list-item object: `- key[N]{fields}:`
const headerInfo = parseArrayHeaderLine(afterHyphen, DEFAULT_DELIMITER)
if (headerInfo && headerInfo.header.key && headerInfo.header.fields) {
// Object with tabular array as first field
const header = headerInfo.header
yield { type: 'startObject' }
yield { type: 'key', key: header.key! }
// Use baseDepth + 1 for the array so rows are at baseDepth + 2
yield* decodeArrayFromHeaderAsync(header, headerInfo.inlineValues, cursor, baseDepth + 1, options)
// Read sibling fields at depth = baseDepth + 1
const followDepth = baseDepth + 1
while (!(await cursor.atEnd())) {
const nextLine = await cursor.peek()
if (!nextLine || nextLine.depth < followDepth) {
break
}
if (nextLine.depth === followDepth && !nextLine.content.startsWith(LIST_ITEM_PREFIX)) {
await cursor.advance()
yield* decodeKeyValueAsync(nextLine.content, cursor, followDepth, options)
}
else {
break
}
}
yield { type: 'endObject' }
return
}
// Check for object first field after hyphen // Check for object first field after hyphen
if (isKeyValueContent(afterHyphen)) { if (isKeyValueContent(afterHyphen)) {
yield { type: 'startObject' } yield { type: 'startObject' }
yield* decodeKeyValueAsync(afterHyphen, cursor, baseDepth, options) yield* decodeKeyValueAsync(afterHyphen, cursor, baseDepth + 1, options)
// Read subsequent fields // Read subsequent fields
const followDepth = baseDepth + 1 const followDepth = baseDepth + 1

View File

@@ -295,25 +295,66 @@ export function* encodeObjectAsListItemLines(
} }
const entries = Object.entries(obj) const entries = Object.entries(obj)
const [firstKey, firstValue] = entries[0]!
const restEntries = entries.slice(1)
// Compact form only when the list-item object has a single tabular array field // Check if first field is a tabular array
if (entries.length === 1) { if (isJsonArray(firstValue) && isArrayOfObjects(firstValue)) {
const [key, value] = entries[0]! const header = extractTabularHeader(firstValue)
if (isJsonArray(value) && isArrayOfObjects(value)) {
const header = extractTabularHeader(value)
if (header) { if (header) {
const formattedHeader = formatHeader(value.length, { key, fields: header, delimiter: options.delimiter }) // Tabular array as first field
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(value, header, depth + 1, options) yield* writeTabularRowsLines(firstValue, header, depth + 2, options)
if (restEntries.length > 0) {
const restObj: JsonObject = Object.fromEntries(restEntries)
yield* encodeObjectLines(restObj, depth + 1, options)
}
return return
} }
} }
const encodedKey = encodeKey(firstKey)
if (isJsonPrimitive(firstValue)) {
// Primitive value: `- key: value`
const encodedValue = encodePrimitive(firstValue, options.delimiter)
yield indentedListItem(depth, `${encodedKey}: ${encodedValue}`, options.indent)
}
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)
}
else if (isArrayOfPrimitives(firstValue)) {
// Inline primitive array: `- key[N]: values`
const arrayLine = encodeInlineArrayLine(firstValue, options.delimiter)
yield indentedListItem(depth, `${encodedKey}${arrayLine}`, options.indent)
}
else {
// Non-inline array: `- key[N]:` with items at depth + 2
const header = formatHeader(firstValue.length, { delimiter: options.delimiter })
yield indentedListItem(depth, `${encodedKey}${header}`, options.indent)
for (const item of firstValue) {
yield* encodeListItemValueLines(item, depth + 2, options)
}
}
}
else if (isJsonObject(firstValue)) {
// Object value: `- key:` with fields at depth + 2
yield indentedListItem(depth, `${encodedKey}:`, options.indent)
if (!isEmptyObject(firstValue)) {
yield* encodeObjectLines(firstValue, depth + 2, options)
}
} }
// All other cases: emit a bare list item marker and all fields at depth + 1 if (restEntries.length > 0) {
yield indentedLine(depth, LIST_ITEM_MARKER, options.indent) const restObj: JsonObject = Object.fromEntries(restEntries)
yield* encodeObjectLines(obj, depth + 1, options) yield* encodeObjectLines(restObj, depth + 1, options)
}
} }
// #endregion // #endregion