diff --git a/README.md b/README.md index 2a9f202..bd86a94 100644 --- a/README.md +++ b/README.md @@ -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)](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) [![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](./LICENSE) diff --git a/SPEC.md b/SPEC.md index cbbe98f..1eb04eb 100644 --- a/SPEC.md +++ b/SPEC.md @@ -4,7 +4,7 @@ The TOON specification has moved to a dedicated repository: [github.com/toon-for ## Current Version -**Version 2.1** (2025-11-23) +**Version 3.0** (2025-11-24) ## Quick Links diff --git a/docs/.vitepress/theme/index.ts b/docs/.vitepress/theme/index.ts index e221ff9..e7524b7 100644 --- a/docs/.vitepress/theme/index.ts +++ b/docs/.vitepress/theme/index.ts @@ -10,7 +10,7 @@ const config: Theme = { extends: DefaultTheme, enhanceApp({ app }) { app.config.globalProperties.$spec = { - version: '2.1', + version: '3.0', } app.component('CopyOrDownloadAsMarkdownButtons', CopyOrDownloadAsMarkdownButtons) }, diff --git a/docs/guide/format-overview.md b/docs/guide/format-overview.md index cdcd0f0..1c8227c 100644 --- a/docs/guide/format-overview.md +++ b/docs/guide/format-overview.md @@ -107,6 +107,40 @@ items[3]: 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 When you have arrays containing primitive inner arrays: diff --git a/docs/index.md b/docs/index.md index 06a4170..f616147 100644 --- a/docs/index.md +++ b/docs/index.md @@ -20,7 +20,7 @@ hero: text: CLI link: /cli/ - theme: alt - text: Spec v2.1 + text: Spec v3.0 link: /reference/spec features: diff --git a/docs/reference/spec.md b/docs/reference/spec.md index 66aeb94..427aca4 100644 --- a/docs/reference/spec.md +++ b/docs/reference/spec.md @@ -9,7 +9,7 @@ You don't need this page to *use* TOON. It's mainly for implementers and contrib ## 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: diff --git a/docs/reference/syntax-cheatsheet.md b/docs/reference/syntax-cheatsheet.md index c3eb878..edf4668 100644 --- a/docs/reference/syntax-cheatsheet.md +++ b/docs/reference/syntax-cheatsheet.md @@ -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 ::: code-group diff --git a/packages/toon/package.json b/packages/toon/package.json index 29e08ba..efb8ce4 100644 --- a/packages/toon/package.json +++ b/packages/toon/package.json @@ -38,6 +38,6 @@ "test": "vitest" }, "devDependencies": { - "@toon-format/spec": "^2.1.0" + "@toon-format/spec": "^3.0.0" } } diff --git a/packages/toon/src/decode/decoders.ts b/packages/toon/src/decode/decoders.ts index 9819c1a..0c1aa83 100644 --- a/packages/toon/src/decode/decoders.ts +++ b/packages/toon/src/decode/decoders.ts @@ -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 if (isKeyValueContent(afterHyphen)) { yield { type: 'startObject' } - yield* decodeKeyValueSync(afterHyphen, cursor, baseDepth, options) + yield* decodeKeyValueSync(afterHyphen, cursor, baseDepth + 1, options) // Read subsequent fields 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 if (isKeyValueContent(afterHyphen)) { yield { type: 'startObject' } - yield* decodeKeyValueAsync(afterHyphen, cursor, baseDepth, options) + yield* decodeKeyValueAsync(afterHyphen, cursor, baseDepth + 1, options) // Read subsequent fields const followDepth = baseDepth + 1 diff --git a/packages/toon/src/encode/encoders.ts b/packages/toon/src/encode/encoders.ts index c4dcff6..78a5d06 100644 --- a/packages/toon/src/encode/encoders.ts +++ b/packages/toon/src/encode/encoders.ts @@ -295,25 +295,66 @@ export function* encodeObjectAsListItemLines( } 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 - if (entries.length === 1) { - const [key, value] = entries[0]! + // Check if first field is a tabular array + if (isJsonArray(firstValue) && isArrayOfObjects(firstValue)) { + const header = extractTabularHeader(firstValue) + if (header) { + // Tabular array as first field + const formattedHeader = formatHeader(firstValue.length, { key: firstKey, fields: header, delimiter: options.delimiter }) + yield indentedListItem(depth, formattedHeader, options.indent) + yield* writeTabularRowsLines(firstValue, header, depth + 2, options) - if (isJsonArray(value) && isArrayOfObjects(value)) { - const header = extractTabularHeader(value) - if (header) { - const formattedHeader = formatHeader(value.length, { key, fields: header, delimiter: options.delimiter }) - yield indentedListItem(depth, formattedHeader, options.indent) - yield* writeTabularRowsLines(value, header, depth + 1, options) - return + if (restEntries.length > 0) { + const restObj: JsonObject = Object.fromEntries(restEntries) + yield* encodeObjectLines(restObj, depth + 1, options) } + return } } - // All other cases: emit a bare list item marker and all fields at depth + 1 - yield indentedLine(depth, LIST_ITEM_MARKER, options.indent) - yield* encodeObjectLines(obj, depth + 1, options) + 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) + } + } + + if (restEntries.length > 0) { + const restObj: JsonObject = Object.fromEntries(restEntries) + yield* encodeObjectLines(restObj, depth + 1, options) + } } // #endregion