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

@@ -38,6 +38,6 @@
"test": "vitest"
},
"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
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

View File

@@ -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