mirror of
https://github.com/voson-wang/toon.git
synced 2026-01-29 15:24:10 +08:00
feat!: standardized encoding for list-item objects (spec v3)
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)
|
||||||
|
|
||||||
|
|||||||
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.1** (2025-11-23)
|
**Version 3.0** (2025-11-24)
|
||||||
|
|
||||||
## Quick Links
|
## Quick Links
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -38,6 +38,6 @@
|
|||||||
"test": "vitest"
|
"test": "vitest"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@toon-format/spec": "^2.1.0"
|
"@toon-format/spec": "^3.0.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user