🎩 You're Invited:Meet the Socket team at Black Hat in Las Vegas, August 3-6.RSVP
Sign In

@toon-format/toon

Package Overview
Dependencies
Maintainers
1
Versions
20
Alerts
File Explorer

Advanced tools

Socket logo

Install Socket

Detect and block malicious and high-risk dependencies

Install

@toon-format/toon - npm Package Compare versions

Comparing version
1.2.0
to
1.3.0
+131
-25
dist/index.d.mts

@@ -65,5 +65,76 @@ //#region src/constants.d.ts

type ResolvedDecodeOptions = Readonly<Required<DecodeOptions>>;
/**
* Options for streaming decode operations.
*
* @remarks
* Path expansion is not supported in streaming mode.
*/
interface DecodeStreamOptions extends Omit<DecodeOptions, "expandPaths"> {
/**
* Path expansion is not supported in streaming decode.
* This option is explicitly omitted.
*/
expandPaths?: never;
}
type JsonStreamEvent = {
type: "startObject";
} | {
type: "endObject";
} | {
type: "startArray";
length: number;
} | {
type: "endArray";
} | {
type: "key";
key: string;
wasQuoted?: boolean;
} | {
type: "primitive";
value: JsonPrimitive;
};
//#endregion
//#region src/index.d.ts
/**
* Encodes a JavaScript value into TOON format string.
*
* @param input - Any JavaScript value (objects, arrays, primitives)
* @param options - Optional encoding configuration
* @returns TOON formatted string
*
* @example
* ```ts
* encode({ name: 'Alice', age: 30 })
* // name: Alice
* // age: 30
*
* encode({ users: [{ id: 1 }, { id: 2 }] })
* // users[]:
* // - id: 1
* // - id: 2
*
* encode(data, { indent: 4, keyFolding: 'safe' })
* ```
*/
declare function encode(input: unknown, options?: EncodeOptions): string;
/**
* Decodes a TOON format string into a JavaScript value.
*
* @param input - TOON formatted string
* @param options - Optional decoding configuration
* @returns Parsed JavaScript value (object, array, or primitive)
*
* @example
* ```ts
* decode('name: Alice\nage: 30')
* // { name: 'Alice', age: 30 }
*
* decode('users[]:\n - id: 1\n - id: 2')
* // { users: [{ id: 1 }, { id: 2 }] }
*
* decode(toonString, { strict: false, expandPaths: 'safe' })
* ```
*/
declare function decode(input: string, options?: DecodeOptions): JsonValue;
/**
* Encodes a JavaScript value into TOON format as a sequence of lines.

@@ -94,43 +165,78 @@ *

/**
* Encodes a JavaScript value into TOON format string.
* Decodes TOON format from pre-split lines into a JavaScript value.
*
* @param input - Any JavaScript value (objects, arrays, primitives)
* @param options - Optional encoding configuration
* @returns TOON formatted string
* This is a convenience wrapper around the streaming decoder that builds
* the full value in memory. Useful when you already have lines as an array
* or iterable and want the standard decode behavior with path expansion support.
*
* @param lines - Iterable of TOON lines (without newlines)
* @param options - Optional decoding configuration (supports expandPaths)
* @returns Parsed JavaScript value (object, array, or primitive)
*
* @example
* ```ts
* encode({ name: 'Alice', age: 30 })
* // name: Alice
* // age: 30
* const lines = ['name: Alice', 'age: 30']
* decodeFromLines(lines)
* // { name: 'Alice', age: 30 }
* ```
*/
declare function decodeFromLines(lines: Iterable<string>, options?: DecodeOptions): JsonValue;
/**
* Synchronously decodes TOON lines into a stream of JSON events.
*
* encode({ users: [{ id: 1 }, { id: 2 }] })
* // users[]:
* // - id: 1
* // - id: 2
* This function yields structured events (startObject, endObject, startArray, endArray,
* key, primitive) that represent the JSON data model without building the full value tree.
* Useful for streaming processing, custom transformations, or memory-efficient parsing.
*
* encode(data, { indent: 4, keyFolding: 'safe' })
* @remarks
* Path expansion (`expandPaths: 'safe'`) is not supported in streaming mode.
*
* @param lines - Iterable of TOON lines (without newlines)
* @param options - Optional decoding configuration (expandPaths not supported)
* @returns Iterable of JSON stream events
*
* @example
* ```ts
* const lines = ['name: Alice', 'age: 30']
* for (const event of decodeStreamSync(lines)) {
* console.log(event)
* // { type: 'startObject' }
* // { type: 'key', key: 'name' }
* // { type: 'primitive', value: 'Alice' }
* // ...
* }
* ```
*/
declare function encode(input: unknown, options?: EncodeOptions): string;
declare function decodeStreamSync(lines: Iterable<string>, options?: DecodeStreamOptions): Iterable<JsonStreamEvent>;
/**
* Decodes a TOON format string into a JavaScript value.
* Asynchronously decodes TOON lines into a stream of JSON events.
*
* @param input - TOON formatted string
* @param options - Optional decoding configuration
* @returns Parsed JavaScript value (object, array, or primitive)
* This function yields structured events (startObject, endObject, startArray, endArray,
* key, primitive) that represent the JSON data model without building the full value tree.
* Supports both sync and async iterables for maximum flexibility with file streams,
* network responses, or other async sources.
*
* @remarks
* Path expansion (`expandPaths: 'safe'`) is not supported in streaming mode.
*
* @param source - Async or sync iterable of TOON lines (without newlines)
* @param options - Optional decoding configuration (expandPaths not supported)
* @returns Async iterable of JSON stream events
*
* @example
* ```ts
* decode('name: Alice\nage: 30')
* // { name: 'Alice', age: 30 }
* const fileStream = createReadStream('data.toon', 'utf-8')
* const lines = splitLines(fileStream) // Async iterable of lines
*
* decode('users[]:\n - id: 1\n - id: 2')
* // { users: [{ id: 1 }, { id: 2 }] }
*
* decode(toonString, { strict: false, expandPaths: 'safe' })
* for await (const event of decodeStream(lines)) {
* console.log(event)
* // { type: 'startObject' }
* // { type: 'key', key: 'name' }
* // { type: 'primitive', value: 'Alice' }
* // ...
* }
* ```
*/
declare function decode(input: string, options?: DecodeOptions): JsonValue;
declare function decodeStream(source: AsyncIterable<string> | Iterable<string>, options?: DecodeStreamOptions): AsyncIterable<JsonStreamEvent>;
//#endregion
export { DEFAULT_DELIMITER, DELIMITERS, type DecodeOptions, type Delimiter, type DelimiterKey, type EncodeOptions, type JsonArray, type JsonObject, type JsonPrimitive, type JsonValue, type ResolvedDecodeOptions, type ResolvedEncodeOptions, decode, encode, encodeLines };
export { DEFAULT_DELIMITER, DELIMITERS, type DecodeOptions, type DecodeStreamOptions, type Delimiter, type DelimiterKey, type EncodeOptions, type JsonArray, type JsonObject, type JsonPrimitive, type JsonStreamEvent, type JsonValue, type ResolvedDecodeOptions, type ResolvedEncodeOptions, decode, decodeFromLines, decodeStream, decodeStreamSync, encode, encodeLines };
+938
-461

@@ -122,54 +122,2 @@ //#region src/constants.ts

//#endregion
//#region src/encode/normalize.ts
function normalizeValue(value) {
if (value === null) return null;
if (typeof value === "string" || typeof value === "boolean") return value;
if (typeof value === "number") {
if (Object.is(value, -0)) return 0;
if (!Number.isFinite(value)) return null;
return value;
}
if (typeof value === "bigint") {
if (value >= Number.MIN_SAFE_INTEGER && value <= Number.MAX_SAFE_INTEGER) return Number(value);
return value.toString();
}
if (value instanceof Date) return value.toISOString();
if (Array.isArray(value)) return value.map(normalizeValue);
if (value instanceof Set) return Array.from(value).map(normalizeValue);
if (value instanceof Map) return Object.fromEntries(Array.from(value, ([k, v]) => [String(k), normalizeValue(v)]));
if (isPlainObject(value)) {
const normalized = {};
for (const key in value) if (Object.prototype.hasOwnProperty.call(value, key)) normalized[key] = normalizeValue(value[key]);
return normalized;
}
return null;
}
function isJsonPrimitive(value) {
return value === null || typeof value === "string" || typeof value === "number" || typeof value === "boolean";
}
function isJsonArray(value) {
return Array.isArray(value);
}
function isJsonObject(value) {
return value !== null && typeof value === "object" && !Array.isArray(value);
}
function isEmptyObject(value) {
return Object.keys(value).length === 0;
}
function isPlainObject(value) {
if (value === null || typeof value !== "object") return false;
const prototype = Object.getPrototypeOf(value);
return prototype === null || prototype === Object.prototype;
}
function isArrayOfPrimitives(value) {
return value.length === 0 || value.every((item) => isJsonPrimitive(item));
}
function isArrayOfArrays(value) {
return value.length === 0 || value.every((item) => isJsonArray(item));
}
function isArrayOfObjects(value) {
return value.length === 0 || value.every((item) => isJsonObject(item));
}
//#endregion
//#region src/shared/literal-utils.ts

@@ -193,191 +141,2 @@ function isBooleanOrNullLiteral(token) {

//#endregion
//#region src/shared/validation.ts
/**
* Checks if a key can be used without quotes.
*
* @remarks
* Valid unquoted keys must start with a letter or underscore,
* followed by letters, digits, underscores, or dots.
*/
function isValidUnquotedKey(key) {
return /^[A-Z_][\w.]*$/i.test(key);
}
/**
* Checks if a key segment is a valid identifier for safe folding/expansion.
*
* @remarks
* Identifier segments are more restrictive than unquoted keys:
* - Must start with a letter or underscore
* - Followed only by letters, digits, or underscores (no dots)
* - Used for safe key folding and path expansion
*/
function isIdentifierSegment(key) {
return /^[A-Z_]\w*$/i.test(key);
}
/**
* Determines if a string value can be safely encoded without quotes.
*
* @remarks
* A string needs quoting if it:
* - Is empty
* - Has leading or trailing whitespace
* - Could be confused with a literal (boolean, null, number)
* - Contains structural characters (colons, brackets, braces)
* - Contains quotes or backslashes (need escaping)
* - Contains control characters (newlines, tabs, etc.)
* - Contains the active delimiter
* - Starts with a list marker (hyphen)
*/
function isSafeUnquoted(value, delimiter = DEFAULT_DELIMITER) {
if (!value) return false;
if (value !== value.trim()) return false;
if (isBooleanOrNullLiteral(value) || isNumericLike(value)) return false;
if (value.includes(":")) return false;
if (value.includes("\"") || value.includes("\\")) return false;
if (/[[\]{}]/.test(value)) return false;
if (/[\n\r\t]/.test(value)) return false;
if (value.includes(delimiter)) return false;
if (value.startsWith(LIST_ITEM_MARKER)) return false;
return true;
}
/**
* Checks if a string looks like a number.
*
* @remarks
* Match numbers like `42`, `-3.14`, `1e-6`, `05`, etc.
*/
function isNumericLike(value) {
return /^-?\d+(?:\.\d+)?(?:e[+-]?\d+)?$/i.test(value) || /^0\d+$/.test(value);
}
//#endregion
//#region src/decode/expand.ts
/**
* Symbol used to mark object keys that were originally quoted in the TOON source.
* Quoted dotted keys should not be expanded, even if they meet expansion criteria.
*/
const QUOTED_KEY_MARKER = Symbol("quotedKey");
/**
* Expands dotted keys into nested objects in safe mode.
*
* @remarks
* This function recursively traverses a decoded TOON value and expands any keys
* containing dots (`.`) into nested object structures, provided all segments
* are valid identifiers.
*
* Expansion rules:
* - Keys containing dots are split into segments
* - All segments must pass `isIdentifierSegment` validation
* - Non-eligible keys (with special characters) are left as literal dotted keys
* - Deep merge: When multiple dotted keys expand to the same path, their values are merged if both are objects
* - Conflict handling:
* - `strict=true`: Throws TypeError on conflicts (non-object collision)
* - `strict=false`: LWW (silent overwrite)
*
* @param value - The decoded value to expand
* @param strict - Whether to throw errors on conflicts
* @returns The expanded value with dotted keys reconstructed as nested objects
* @throws TypeError if conflicts occur in strict mode
*/
function expandPathsSafe(value, strict) {
if (Array.isArray(value)) return value.map((item) => expandPathsSafe(item, strict));
if (isJsonObject(value)) {
const expandedObject = {};
const quotedKeys = value[QUOTED_KEY_MARKER];
for (const [key, keyValue] of Object.entries(value)) {
const isQuoted = quotedKeys?.has(key);
if (key.includes(DOT) && !isQuoted) {
const segments = key.split(DOT);
if (segments.every((seg) => isIdentifierSegment(seg))) {
insertPathSafe(expandedObject, segments, expandPathsSafe(keyValue, strict), strict);
continue;
}
}
const expandedValue = expandPathsSafe(keyValue, strict);
if (key in expandedObject) {
const conflictingValue = expandedObject[key];
if (canMerge(conflictingValue, expandedValue)) mergeObjects(conflictingValue, expandedValue, strict);
else {
if (strict) throw new TypeError(`Path expansion conflict at key "${key}": cannot merge ${typeof conflictingValue} with ${typeof expandedValue}`);
expandedObject[key] = expandedValue;
}
} else expandedObject[key] = expandedValue;
}
return expandedObject;
}
return value;
}
/**
* Inserts a value at a nested path, creating intermediate objects as needed.
*
* @remarks
* This function walks the segment path, creating nested objects as needed.
* When an existing value is encountered:
* - If both are objects: deep merge (continue insertion)
* - If values differ: conflict
* - strict=true: throw TypeError
* - strict=false: overwrite with new value (LWW)
*
* @param target - The object to insert into
* @param segments - Array of path segments (e.g., ['data', 'metadata', 'items'])
* @param value - The value to insert at the end of the path
* @param strict - Whether to throw on conflicts
* @throws TypeError if a conflict occurs in strict mode
*/
function insertPathSafe(target, segments, value, strict) {
let currentNode = target;
for (let i = 0; i < segments.length - 1; i++) {
const currentSegment = segments[i];
const segmentValue = currentNode[currentSegment];
if (segmentValue === void 0) {
const newObj = {};
currentNode[currentSegment] = newObj;
currentNode = newObj;
} else if (isJsonObject(segmentValue)) currentNode = segmentValue;
else {
if (strict) throw new TypeError(`Path expansion conflict at segment "${currentSegment}": expected object but found ${typeof segmentValue}`);
const newObj = {};
currentNode[currentSegment] = newObj;
currentNode = newObj;
}
}
const lastSeg = segments[segments.length - 1];
const destinationValue = currentNode[lastSeg];
if (destinationValue === void 0) currentNode[lastSeg] = value;
else if (canMerge(destinationValue, value)) mergeObjects(destinationValue, value, strict);
else {
if (strict) throw new TypeError(`Path expansion conflict at key "${lastSeg}": cannot merge ${typeof destinationValue} with ${typeof value}`);
currentNode[lastSeg] = value;
}
}
/**
* Deep merges properties from source into target.
*
* @remarks
* For each key in source:
* - If key doesn't exist in target: copy it
* - If both values are objects: recursively merge
* - Otherwise: conflict (strict throws, non-strict overwrites)
*
* @param target - The target object to merge into
* @param source - The source object to merge from
* @param strict - Whether to throw on conflicts
* @throws TypeError if a conflict occurs in strict mode
*/
function mergeObjects(target, source, strict) {
for (const [key, sourceValue] of Object.entries(source)) {
const targetValue = target[key];
if (targetValue === void 0) target[key] = sourceValue;
else if (canMerge(targetValue, sourceValue)) mergeObjects(targetValue, sourceValue, strict);
else {
if (strict) throw new TypeError(`Path expansion conflict at key "${key}": cannot merge ${typeof targetValue} with ${typeof sourceValue}`);
target[key] = sourceValue;
}
}
}
function canMerge(a, b) {
return isJsonObject(a) && isJsonObject(b);
}
//#endregion
//#region src/decode/parser.ts

@@ -549,6 +308,6 @@ function parseArrayHeaderLine(content, defaultDelimiter) {

}
function isArrayHeaderAfterHyphen(content) {
function isArrayHeaderContent(content) {
return content.trim().startsWith(OPEN_BRACKET) && findUnquotedChar(content, COLON) !== -1;
}
function isObjectFirstFieldAfterHyphen(content) {
function isKeyValueContent(content) {
return findUnquotedChar(content, COLON) !== -1;

@@ -558,2 +317,56 @@ }

//#endregion
//#region src/decode/scanner.ts
function createScanState() {
return {
lineNumber: 0,
blankLines: []
};
}
function parseLineIncremental(raw, state, indentSize, strict) {
state.lineNumber++;
const lineNumber = state.lineNumber;
let indent = 0;
while (indent < raw.length && raw[indent] === SPACE) indent++;
const content = raw.slice(indent);
if (!content.trim()) {
const depth$1 = computeDepthFromIndent(indent, indentSize);
state.blankLines.push({
lineNumber,
indent,
depth: depth$1
});
return;
}
const depth = computeDepthFromIndent(indent, indentSize);
if (strict) {
let whitespaceEndIndex = 0;
while (whitespaceEndIndex < raw.length && (raw[whitespaceEndIndex] === SPACE || raw[whitespaceEndIndex] === TAB)) whitespaceEndIndex++;
if (raw.slice(0, whitespaceEndIndex).includes(TAB)) throw new SyntaxError(`Line ${lineNumber}: Tabs are not allowed in indentation in strict mode`);
if (indent > 0 && indent % indentSize !== 0) throw new SyntaxError(`Line ${lineNumber}: Indentation must be exact multiple of ${indentSize}, but found ${indent} spaces`);
}
return {
raw,
indent,
content,
depth,
lineNumber
};
}
function* parseLinesSync(source, indentSize, strict, state) {
for (const raw of source) {
const parsedLine = parseLineIncremental(raw, state, indentSize, strict);
if (parsedLine !== void 0) yield parsedLine;
}
}
async function* parseLinesAsync(source, indentSize, strict, state) {
for await (const raw of source) {
const parsedLine = parseLineIncremental(raw, state, indentSize, strict);
if (parsedLine !== void 0) yield parsedLine;
}
}
function computeDepthFromIndent(indentSpaces, indentSize) {
return Math.floor(indentSpaces / indentSize);
}
//#endregion
//#region src/decode/validation.ts

@@ -569,4 +382,3 @@ /**

*/
function validateNoExtraListItems(cursor, itemDepth, expectedCount) {
const nextLine = cursor.peek();
function validateNoExtraListItems(nextLine, itemDepth, expectedCount) {
if (nextLine?.depth === itemDepth && nextLine.content.startsWith(LIST_ITEM_PREFIX)) throw new RangeError(`Expected ${expectedCount} list array items, but found more`);

@@ -577,4 +389,3 @@ }

*/
function validateNoExtraTabularRows(cursor, rowDepth, header) {
const nextLine = cursor.peek();
function validateNoExtraTabularRows(nextLine, rowDepth, header) {
if (nextLine?.depth === rowDepth && !nextLine.content.startsWith(LIST_ITEM_PREFIX) && isDataRow(nextLine.content, header.delimiter)) throw new RangeError(`Expected ${header.length} tabular rows, but found more`);

@@ -603,120 +414,412 @@ }

//#region src/decode/decoders.ts
function decodeValueFromLines(cursor, options) {
const first = cursor.peek();
if (!first) throw new ReferenceError("No content to decode");
if (isArrayHeaderAfterHyphen(first.content)) {
var StreamingLineCursor = class {
buffer = [];
generator;
done = false;
lastLine;
scanState;
constructor(generator, scanState) {
this.generator = generator;
this.scanState = scanState;
}
getBlankLines() {
return this.scanState.blankLines;
}
async peek() {
if (this.buffer.length > 0) return this.buffer[0];
if (this.done) return;
const result = await this.generator.next();
if (result.done) {
this.done = true;
return;
}
this.buffer.push(result.value);
return result.value;
}
async next() {
const line = await this.peek();
if (line !== void 0) {
this.buffer.shift();
this.lastLine = line;
}
return line;
}
async advance() {
await this.next();
}
current() {
return this.lastLine;
}
async atEnd() {
return await this.peek() === void 0;
}
peekSync() {
if (this.buffer.length > 0) return this.buffer[0];
if (this.done) return;
const result = this.generator.next();
if (result.done) {
this.done = true;
return;
}
this.buffer.push(result.value);
return result.value;
}
nextSync() {
const line = this.peekSync();
if (line !== void 0) {
this.buffer.shift();
this.lastLine = line;
}
return line;
}
advanceSync() {
this.nextSync();
}
atEndSync() {
return this.peekSync() === void 0;
}
};
function* decodeStreamSync$1(source, options) {
if (options?.expandPaths !== void 0) throw new Error("expandPaths is not supported in streaming decode");
const resolvedOptions = {
indent: options?.indent ?? 2,
strict: options?.strict ?? true
};
const scanState = createScanState();
const cursor = new StreamingLineCursor(parseLinesSync(source, resolvedOptions.indent, resolvedOptions.strict, scanState), scanState);
const first = cursor.peekSync();
if (!first) {
yield { type: "startObject" };
yield { type: "endObject" };
return;
}
if (isArrayHeaderContent(first.content)) {
const headerInfo = parseArrayHeaderLine(first.content, DEFAULT_DELIMITER);
if (headerInfo) {
cursor.advance();
return decodeArrayFromHeader(headerInfo.header, headerInfo.inlineValues, cursor, 0, options);
cursor.advanceSync();
yield* decodeArrayFromHeaderSync(headerInfo.header, headerInfo.inlineValues, cursor, 0, resolvedOptions);
return;
}
}
if (cursor.length === 1 && !isKeyValueLine(first)) return parsePrimitiveToken(first.content.trim());
return decodeObject(cursor, 0, options);
cursor.advanceSync();
if (!!cursor.atEndSync() && !isKeyValueLineSync(first)) {
yield {
type: "primitive",
value: parsePrimitiveToken(first.content.trim())
};
return;
}
yield { type: "startObject" };
yield* decodeKeyValueSync(first.content, cursor, 0, resolvedOptions);
while (!cursor.atEndSync()) {
const line = cursor.peekSync();
if (!line || line.depth !== 0) break;
cursor.advanceSync();
yield* decodeKeyValueSync(line.content, cursor, 0, resolvedOptions);
}
yield { type: "endObject" };
}
function isKeyValueLine(line) {
const content = line.content;
if (content.startsWith("\"")) {
const closingQuoteIndex = findClosingQuote(content, 0);
if (closingQuoteIndex === -1) return false;
return content.slice(closingQuoteIndex + 1).includes(COLON);
} else return content.includes(COLON);
function* decodeKeyValueSync(content, cursor, baseDepth, options) {
const arrayHeader = parseArrayHeaderLine(content, DEFAULT_DELIMITER);
if (arrayHeader && arrayHeader.header.key) {
yield {
type: "key",
key: arrayHeader.header.key
};
yield* decodeArrayFromHeaderSync(arrayHeader.header, arrayHeader.inlineValues, cursor, baseDepth, options);
return;
}
const { key, isQuoted } = parseKeyToken(content, 0);
const colonIndex = content.indexOf(COLON, key.length);
const rest = colonIndex >= 0 ? content.slice(colonIndex + 1).trim() : "";
yield isQuoted ? {
type: "key",
key,
wasQuoted: true
} : {
type: "key",
key
};
if (!rest) {
const nextLine = cursor.peekSync();
if (nextLine && nextLine.depth > baseDepth) {
yield { type: "startObject" };
yield* decodeObjectFieldsSync(cursor, baseDepth + 1, options);
yield { type: "endObject" };
return;
}
yield { type: "startObject" };
yield { type: "endObject" };
return;
}
yield {
type: "primitive",
value: parsePrimitiveToken(rest)
};
}
function decodeObject(cursor, baseDepth, options) {
const obj = {};
const quotedKeys = /* @__PURE__ */ new Set();
function* decodeObjectFieldsSync(cursor, baseDepth, options) {
let computedDepth;
while (!cursor.atEnd()) {
const line = cursor.peek();
while (!cursor.atEndSync()) {
const line = cursor.peekSync();
if (!line || line.depth < baseDepth) break;
if (computedDepth === void 0 && line.depth >= baseDepth) computedDepth = line.depth;
if (line.depth === computedDepth) {
cursor.advance();
const { key, value, isQuoted } = decodeKeyValue(line.content, cursor, computedDepth, options);
obj[key] = value;
if (isQuoted && key.includes(DOT)) quotedKeys.add(key);
cursor.advanceSync();
yield* decodeKeyValueSync(line.content, cursor, computedDepth, options);
} else break;
}
if (quotedKeys.size > 0) obj[QUOTED_KEY_MARKER] = quotedKeys;
return obj;
}
function decodeKeyValue(content, cursor, baseDepth, options) {
const arrayHeader = parseArrayHeaderLine(content, DEFAULT_DELIMITER);
if (arrayHeader && arrayHeader.header.key) {
const decodedValue = decodeArrayFromHeader(arrayHeader.header, arrayHeader.inlineValues, cursor, baseDepth, options);
return {
key: arrayHeader.header.key,
value: decodedValue,
followDepth: baseDepth + 1,
isQuoted: false
};
function* decodeArrayFromHeaderSync(header, inlineValues, cursor, baseDepth, options) {
yield {
type: "startArray",
length: header.length
};
if (inlineValues) {
yield* decodeInlinePrimitiveArraySync(header, inlineValues, options);
yield { type: "endArray" };
return;
}
const { key, end, isQuoted } = parseKeyToken(content, 0);
const rest = content.slice(end).trim();
if (!rest) {
const nextLine = cursor.peek();
if (nextLine && nextLine.depth > baseDepth) return {
key,
value: decodeObject(cursor, baseDepth + 1, options),
followDepth: baseDepth + 1,
isQuoted
};
return {
key,
value: {},
followDepth: baseDepth + 1,
isQuoted
};
if (header.fields && header.fields.length > 0) {
yield* decodeTabularArraySync(header, cursor, baseDepth, options);
yield { type: "endArray" };
return;
}
return {
key,
value: parsePrimitiveToken(rest),
followDepth: baseDepth + 1,
isQuoted
};
yield* decodeListArraySync(header, cursor, baseDepth, options);
yield { type: "endArray" };
}
function decodeArrayFromHeader(header, inlineValues, cursor, baseDepth, options) {
if (inlineValues) return decodeInlinePrimitiveArray(header, inlineValues, options);
if (header.fields && header.fields.length > 0) return decodeTabularArray(header, cursor, baseDepth, options);
return decodeListArray(header, cursor, baseDepth, options);
}
function decodeInlinePrimitiveArray(header, inlineValues, options) {
function* decodeInlinePrimitiveArraySync(header, inlineValues, options) {
if (!inlineValues.trim()) {
assertExpectedCount(0, header.length, "inline array items", options);
return [];
return;
}
const primitives = mapRowValuesToPrimitives(parseDelimitedValues(inlineValues, header.delimiter));
assertExpectedCount(primitives.length, header.length, "inline array items", options);
return primitives;
for (const primitive of primitives) yield {
type: "primitive",
value: primitive
};
}
function decodeListArray(header, cursor, baseDepth, options) {
const items = [];
function* decodeTabularArraySync(header, cursor, baseDepth, options) {
const rowDepth = baseDepth + 1;
let rowCount = 0;
let startLine;
let endLine;
while (!cursor.atEndSync() && rowCount < header.length) {
const line = cursor.peekSync();
if (!line || line.depth < rowDepth) break;
if (line.depth === rowDepth) {
if (startLine === void 0) startLine = line.lineNumber;
endLine = line.lineNumber;
cursor.advanceSync();
const values = parseDelimitedValues(line.content, header.delimiter);
assertExpectedCount(values.length, header.fields.length, "tabular row values", options);
const primitives = mapRowValuesToPrimitives(values);
yield { type: "startObject" };
for (let i = 0; i < header.fields.length; i++) {
yield {
type: "key",
key: header.fields[i]
};
yield {
type: "primitive",
value: primitives[i]
};
}
yield { type: "endObject" };
rowCount++;
} else break;
}
assertExpectedCount(rowCount, header.length, "tabular rows", options);
if (options.strict && startLine !== void 0 && endLine !== void 0) validateNoBlankLinesInRange(startLine, endLine, cursor.getBlankLines(), options.strict, "tabular array");
if (options.strict) validateNoExtraTabularRows(cursor.peekSync(), rowDepth, header);
}
function* decodeListArraySync(header, cursor, baseDepth, options) {
const itemDepth = baseDepth + 1;
let itemCount = 0;
let startLine;
let endLine;
while (!cursor.atEnd() && items.length < header.length) {
const line = cursor.peek();
while (!cursor.atEndSync() && itemCount < header.length) {
const line = cursor.peekSync();
if (!line || line.depth < itemDepth) break;
const isListItem = line.content.startsWith(LIST_ITEM_PREFIX) || line.content === "-";
const isListItem = line.content.startsWith(LIST_ITEM_PREFIX) || line.content === LIST_ITEM_MARKER;
if (line.depth === itemDepth && isListItem) {
if (startLine === void 0) startLine = line.lineNumber;
endLine = line.lineNumber;
const item = decodeListItem(cursor, itemDepth, options);
items.push(item);
yield* decodeListItemSync(cursor, itemDepth, options);
const currentLine = cursor.current();
if (currentLine) endLine = currentLine.lineNumber;
itemCount++;
} else break;
}
assertExpectedCount(items.length, header.length, "list array items", options);
assertExpectedCount(itemCount, header.length, "list array items", options);
if (options.strict && startLine !== void 0 && endLine !== void 0) validateNoBlankLinesInRange(startLine, endLine, cursor.getBlankLines(), options.strict, "list array");
if (options.strict) validateNoExtraListItems(cursor, itemDepth, header.length);
return items;
if (options.strict) validateNoExtraListItems(cursor.peekSync(), itemDepth, header.length);
}
function decodeTabularArray(header, cursor, baseDepth, options) {
const objects = [];
function* decodeListItemSync(cursor, baseDepth, options) {
const line = cursor.nextSync();
if (!line) throw new ReferenceError("Expected list item");
let afterHyphen;
if (line.content === LIST_ITEM_MARKER) {
yield { type: "startObject" };
yield { type: "endObject" };
return;
} else if (line.content.startsWith(LIST_ITEM_PREFIX)) afterHyphen = line.content.slice(LIST_ITEM_PREFIX.length);
else throw new SyntaxError(`Expected list item to start with "${LIST_ITEM_PREFIX}"`);
if (!afterHyphen.trim()) {
yield { type: "startObject" };
yield { type: "endObject" };
return;
}
if (isArrayHeaderContent(afterHyphen)) {
const arrayHeader = parseArrayHeaderLine(afterHyphen, DEFAULT_DELIMITER);
if (arrayHeader) {
yield* decodeArrayFromHeaderSync(arrayHeader.header, arrayHeader.inlineValues, cursor, baseDepth, options);
return;
}
}
if (isKeyValueContent(afterHyphen)) {
yield { type: "startObject" };
yield* decodeKeyValueSync(afterHyphen, cursor, baseDepth, options);
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;
}
yield {
type: "primitive",
value: parsePrimitiveToken(afterHyphen)
};
}
function isKeyValueLineSync(line) {
const content = line.content;
if (content.startsWith("\"")) {
const closingQuoteIndex = findClosingQuote(content, 0);
if (closingQuoteIndex === -1) return false;
return content.slice(closingQuoteIndex + 1).includes(COLON);
} else return content.includes(COLON);
}
async function* decodeStream$1(source, options) {
if (options?.expandPaths !== void 0) throw new Error("expandPaths is not supported in streaming decode");
const resolvedOptions = {
indent: options?.indent ?? 2,
strict: options?.strict ?? true
};
const scanState = createScanState();
if (Symbol.asyncIterator in source) {
const cursor = new StreamingLineCursor(parseLinesAsync(source, resolvedOptions.indent, resolvedOptions.strict, scanState), scanState);
const first = await cursor.peek();
if (!first) {
yield { type: "startObject" };
yield { type: "endObject" };
return;
}
if (isArrayHeaderContent(first.content)) {
const headerInfo = parseArrayHeaderLine(first.content, DEFAULT_DELIMITER);
if (headerInfo) {
await cursor.advance();
yield* decodeArrayFromHeaderAsync(headerInfo.header, headerInfo.inlineValues, cursor, 0, resolvedOptions);
return;
}
}
await cursor.advance();
if (!!await cursor.atEnd() && !isKeyValueLineSync(first)) {
yield {
type: "primitive",
value: parsePrimitiveToken(first.content.trim())
};
return;
}
yield { type: "startObject" };
yield* decodeKeyValueAsync(first.content, cursor, 0, resolvedOptions);
while (!await cursor.atEnd()) {
const line = await cursor.peek();
if (!line || line.depth !== 0) break;
await cursor.advance();
yield* decodeKeyValueAsync(line.content, cursor, 0, resolvedOptions);
}
yield { type: "endObject" };
} else yield* decodeStreamSync$1(source, options);
}
async function* decodeKeyValueAsync(content, cursor, baseDepth, options) {
const arrayHeader = parseArrayHeaderLine(content, DEFAULT_DELIMITER);
if (arrayHeader && arrayHeader.header.key) {
yield {
type: "key",
key: arrayHeader.header.key
};
yield* decodeArrayFromHeaderAsync(arrayHeader.header, arrayHeader.inlineValues, cursor, baseDepth, options);
return;
}
const { key, isQuoted } = parseKeyToken(content, 0);
const colonIndex = content.indexOf(COLON, key.length);
const rest = colonIndex >= 0 ? content.slice(colonIndex + 1).trim() : "";
yield isQuoted ? {
type: "key",
key,
wasQuoted: true
} : {
type: "key",
key
};
if (!rest) {
const nextLine = await cursor.peek();
if (nextLine && nextLine.depth > baseDepth) {
yield { type: "startObject" };
yield* decodeObjectFieldsAsync(cursor, baseDepth + 1, options);
yield { type: "endObject" };
return;
}
yield { type: "startObject" };
yield { type: "endObject" };
return;
}
yield {
type: "primitive",
value: parsePrimitiveToken(rest)
};
}
async function* decodeObjectFieldsAsync(cursor, baseDepth, options) {
let computedDepth;
while (!await cursor.atEnd()) {
const line = await cursor.peek();
if (!line || line.depth < baseDepth) break;
if (computedDepth === void 0 && line.depth >= baseDepth) computedDepth = line.depth;
if (line.depth === computedDepth) {
await cursor.advance();
yield* decodeKeyValueAsync(line.content, cursor, computedDepth, options);
} else break;
}
}
async function* decodeArrayFromHeaderAsync(header, inlineValues, cursor, baseDepth, options) {
yield {
type: "startArray",
length: header.length
};
if (inlineValues) {
yield* decodeInlinePrimitiveArraySync(header, inlineValues, options);
yield { type: "endArray" };
return;
}
if (header.fields && header.fields.length > 0) {
yield* decodeTabularArrayAsync(header, cursor, baseDepth, options);
yield { type: "endArray" };
return;
}
yield* decodeListArrayAsync(header, cursor, baseDepth, options);
yield { type: "endArray" };
}
async function* decodeTabularArrayAsync(header, cursor, baseDepth, options) {
const rowDepth = baseDepth + 1;
let rowCount = 0;
let startLine;
let endLine;
while (!cursor.atEnd() && objects.length < header.length) {
const line = cursor.peek();
while (!await cursor.atEnd() && rowCount < header.length) {
const line = await cursor.peek();
if (!line || line.depth < rowDepth) break;

@@ -726,133 +829,424 @@ if (line.depth === rowDepth) {

endLine = line.lineNumber;
cursor.advance();
await cursor.advance();
const values = parseDelimitedValues(line.content, header.delimiter);
assertExpectedCount(values.length, header.fields.length, "tabular row values", options);
const primitives = mapRowValuesToPrimitives(values);
const obj = {};
for (let i = 0; i < header.fields.length; i++) obj[header.fields[i]] = primitives[i];
objects.push(obj);
yield { type: "startObject" };
for (let i = 0; i < header.fields.length; i++) {
yield {
type: "key",
key: header.fields[i]
};
yield {
type: "primitive",
value: primitives[i]
};
}
yield { type: "endObject" };
rowCount++;
} else break;
}
assertExpectedCount(objects.length, header.length, "tabular rows", options);
assertExpectedCount(rowCount, header.length, "tabular rows", options);
if (options.strict && startLine !== void 0 && endLine !== void 0) validateNoBlankLinesInRange(startLine, endLine, cursor.getBlankLines(), options.strict, "tabular array");
if (options.strict) validateNoExtraTabularRows(cursor, rowDepth, header);
return objects;
if (options.strict) validateNoExtraTabularRows(await cursor.peek(), rowDepth, header);
}
function decodeListItem(cursor, baseDepth, options) {
const line = cursor.next();
async function* decodeListArrayAsync(header, cursor, baseDepth, options) {
const itemDepth = baseDepth + 1;
let itemCount = 0;
let startLine;
let endLine;
while (!await cursor.atEnd() && itemCount < header.length) {
const line = await cursor.peek();
if (!line || line.depth < itemDepth) break;
const isListItem = line.content.startsWith(LIST_ITEM_PREFIX) || line.content === LIST_ITEM_MARKER;
if (line.depth === itemDepth && isListItem) {
if (startLine === void 0) startLine = line.lineNumber;
endLine = line.lineNumber;
yield* decodeListItemAsync(cursor, itemDepth, options);
const currentLine = cursor.current();
if (currentLine) endLine = currentLine.lineNumber;
itemCount++;
} else break;
}
assertExpectedCount(itemCount, header.length, "list array items", options);
if (options.strict && startLine !== void 0 && endLine !== void 0) validateNoBlankLinesInRange(startLine, endLine, cursor.getBlankLines(), options.strict, "list array");
if (options.strict) validateNoExtraListItems(await cursor.peek(), itemDepth, header.length);
}
async function* decodeListItemAsync(cursor, baseDepth, options) {
const line = await cursor.next();
if (!line) throw new ReferenceError("Expected list item");
let afterHyphen;
if (line.content === "-") return {};
else if (line.content.startsWith(LIST_ITEM_PREFIX)) afterHyphen = line.content.slice(LIST_ITEM_PREFIX.length);
if (line.content === LIST_ITEM_MARKER) {
yield { type: "startObject" };
yield { type: "endObject" };
return;
} else if (line.content.startsWith(LIST_ITEM_PREFIX)) afterHyphen = line.content.slice(LIST_ITEM_PREFIX.length);
else throw new SyntaxError(`Expected list item to start with "${LIST_ITEM_PREFIX}"`);
if (!afterHyphen.trim()) return {};
if (isArrayHeaderAfterHyphen(afterHyphen)) {
if (!afterHyphen.trim()) {
yield { type: "startObject" };
yield { type: "endObject" };
return;
}
if (isArrayHeaderContent(afterHyphen)) {
const arrayHeader = parseArrayHeaderLine(afterHyphen, DEFAULT_DELIMITER);
if (arrayHeader) return decodeArrayFromHeader(arrayHeader.header, arrayHeader.inlineValues, cursor, baseDepth, options);
if (arrayHeader) {
yield* decodeArrayFromHeaderAsync(arrayHeader.header, arrayHeader.inlineValues, cursor, baseDepth, options);
return;
}
}
if (isObjectFirstFieldAfterHyphen(afterHyphen)) return decodeObjectFromListItem(line, cursor, baseDepth, options);
return parsePrimitiveToken(afterHyphen);
}
function decodeObjectFromListItem(firstLine, cursor, baseDepth, options) {
const { key, value, followDepth, isQuoted } = decodeKeyValue(firstLine.content.slice(LIST_ITEM_PREFIX.length), cursor, baseDepth, options);
const obj = { [key]: value };
const quotedKeys = /* @__PURE__ */ new Set();
if (isQuoted && key.includes(DOT)) quotedKeys.add(key);
while (!cursor.atEnd()) {
const line = cursor.peek();
if (!line || line.depth < followDepth) break;
if (line.depth === followDepth && !line.content.startsWith(LIST_ITEM_PREFIX)) {
cursor.advance();
const { key: k, value: v, isQuoted: kIsQuoted } = decodeKeyValue(line.content, cursor, followDepth, options);
obj[k] = v;
if (kIsQuoted && k.includes(DOT)) quotedKeys.add(k);
} else break;
if (isKeyValueContent(afterHyphen)) {
yield { type: "startObject" };
yield* decodeKeyValueAsync(afterHyphen, cursor, baseDepth, options);
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;
}
if (quotedKeys.size > 0) obj[QUOTED_KEY_MARKER] = quotedKeys;
return obj;
yield {
type: "primitive",
value: parsePrimitiveToken(afterHyphen)
};
}
//#endregion
//#region src/decode/scanner.ts
var LineCursor = class {
lines;
index;
blankLines;
constructor(lines, blankLines = []) {
this.lines = lines;
this.index = 0;
this.blankLines = blankLines;
//#region src/encode/normalize.ts
function normalizeValue(value) {
if (value === null) return null;
if (typeof value === "string" || typeof value === "boolean") return value;
if (typeof value === "number") {
if (Object.is(value, -0)) return 0;
if (!Number.isFinite(value)) return null;
return value;
}
getBlankLines() {
return this.blankLines;
if (typeof value === "bigint") {
if (value >= Number.MIN_SAFE_INTEGER && value <= Number.MAX_SAFE_INTEGER) return Number(value);
return value.toString();
}
peek() {
return this.lines[this.index];
if (value instanceof Date) return value.toISOString();
if (Array.isArray(value)) return value.map(normalizeValue);
if (value instanceof Set) return Array.from(value).map(normalizeValue);
if (value instanceof Map) return Object.fromEntries(Array.from(value, ([k, v]) => [String(k), normalizeValue(v)]));
if (isPlainObject(value)) {
const normalized = {};
for (const key in value) if (Object.prototype.hasOwnProperty.call(value, key)) normalized[key] = normalizeValue(value[key]);
return normalized;
}
next() {
return this.lines[this.index++];
return null;
}
function isJsonPrimitive(value) {
return value === null || typeof value === "string" || typeof value === "number" || typeof value === "boolean";
}
function isJsonArray(value) {
return Array.isArray(value);
}
function isJsonObject(value) {
return value !== null && typeof value === "object" && !Array.isArray(value);
}
function isEmptyObject(value) {
return Object.keys(value).length === 0;
}
function isPlainObject(value) {
if (value === null || typeof value !== "object") return false;
const prototype = Object.getPrototypeOf(value);
return prototype === null || prototype === Object.prototype;
}
function isArrayOfPrimitives(value) {
return value.length === 0 || value.every((item) => isJsonPrimitive(item));
}
function isArrayOfArrays(value) {
return value.length === 0 || value.every((item) => isJsonArray(item));
}
function isArrayOfObjects(value) {
return value.length === 0 || value.every((item) => isJsonObject(item));
}
//#endregion
//#region src/shared/validation.ts
/**
* Checks if a key can be used without quotes.
*
* @remarks
* Valid unquoted keys must start with a letter or underscore,
* followed by letters, digits, underscores, or dots.
*/
function isValidUnquotedKey(key) {
return /^[A-Z_][\w.]*$/i.test(key);
}
/**
* Checks if a key segment is a valid identifier for safe folding/expansion.
*
* @remarks
* Identifier segments are more restrictive than unquoted keys:
* - Must start with a letter or underscore
* - Followed only by letters, digits, or underscores (no dots)
* - Used for safe key folding and path expansion
*/
function isIdentifierSegment(key) {
return /^[A-Z_]\w*$/i.test(key);
}
/**
* Determines if a string value can be safely encoded without quotes.
*
* @remarks
* A string needs quoting if it:
* - Is empty
* - Has leading or trailing whitespace
* - Could be confused with a literal (boolean, null, number)
* - Contains structural characters (colons, brackets, braces)
* - Contains quotes or backslashes (need escaping)
* - Contains control characters (newlines, tabs, etc.)
* - Contains the active delimiter
* - Starts with a list marker (hyphen)
*/
function isSafeUnquoted(value, delimiter = DEFAULT_DELIMITER) {
if (!value) return false;
if (value !== value.trim()) return false;
if (isBooleanOrNullLiteral(value) || isNumericLike(value)) return false;
if (value.includes(":")) return false;
if (value.includes("\"") || value.includes("\\")) return false;
if (/[[\]{}]/.test(value)) return false;
if (/[\n\r\t]/.test(value)) return false;
if (value.includes(delimiter)) return false;
if (value.startsWith(LIST_ITEM_MARKER)) return false;
return true;
}
/**
* Checks if a string looks like a number.
*
* @remarks
* Match numbers like `42`, `-3.14`, `1e-6`, `05`, etc.
*/
function isNumericLike(value) {
return /^-?\d+(?:\.\d+)?(?:e[+-]?\d+)?$/i.test(value) || /^0\d+$/.test(value);
}
//#endregion
//#region src/decode/expand.ts
/**
* Symbol used to mark object keys that were originally quoted in the TOON source.
* Quoted dotted keys should not be expanded, even if they meet expansion criteria.
*/
const QUOTED_KEY_MARKER = Symbol("quotedKey");
/**
* Expands dotted keys into nested objects in safe mode.
*
* @remarks
* This function recursively traverses a decoded TOON value and expands any keys
* containing dots (`.`) into nested object structures, provided all segments
* are valid identifiers.
*
* Expansion rules:
* - Keys containing dots are split into segments
* - All segments must pass `isIdentifierSegment` validation
* - Non-eligible keys (with special characters) are left as literal dotted keys
* - Deep merge: When multiple dotted keys expand to the same path, their values are merged if both are objects
* - Conflict handling:
* - `strict=true`: Throws TypeError on conflicts (non-object collision)
* - `strict=false`: LWW (silent overwrite)
*
* @param value - The decoded value to expand
* @param strict - Whether to throw errors on conflicts
* @returns The expanded value with dotted keys reconstructed as nested objects
* @throws TypeError if conflicts occur in strict mode
*/
function expandPathsSafe(value, strict) {
if (Array.isArray(value)) return value.map((item) => expandPathsSafe(item, strict));
if (isJsonObject(value)) {
const expandedObject = {};
const quotedKeys = value[QUOTED_KEY_MARKER];
for (const [key, keyValue] of Object.entries(value)) {
const isQuoted = quotedKeys?.has(key);
if (key.includes(DOT) && !isQuoted) {
const segments = key.split(DOT);
if (segments.every((seg) => isIdentifierSegment(seg))) {
insertPathSafe(expandedObject, segments, expandPathsSafe(keyValue, strict), strict);
continue;
}
}
const expandedValue = expandPathsSafe(keyValue, strict);
if (key in expandedObject) {
const conflictingValue = expandedObject[key];
if (canMerge(conflictingValue, expandedValue)) mergeObjects(conflictingValue, expandedValue, strict);
else {
if (strict) throw new TypeError(`Path expansion conflict at key "${key}": cannot merge ${typeof conflictingValue} with ${typeof expandedValue}`);
expandedObject[key] = expandedValue;
}
} else expandedObject[key] = expandedValue;
}
return expandedObject;
}
current() {
return this.index > 0 ? this.lines[this.index - 1] : void 0;
return value;
}
/**
* Inserts a value at a nested path, creating intermediate objects as needed.
*
* @remarks
* This function walks the segment path, creating nested objects as needed.
* When an existing value is encountered:
* - If both are objects: deep merge (continue insertion)
* - If values differ: conflict
* - strict=true: throw TypeError
* - strict=false: overwrite with new value (LWW)
*
* @param target - The object to insert into
* @param segments - Array of path segments (e.g., ['data', 'metadata', 'items'])
* @param value - The value to insert at the end of the path
* @param strict - Whether to throw on conflicts
* @throws TypeError if a conflict occurs in strict mode
*/
function insertPathSafe(target, segments, value, strict) {
let currentNode = target;
for (let i = 0; i < segments.length - 1; i++) {
const currentSegment = segments[i];
const segmentValue = currentNode[currentSegment];
if (segmentValue === void 0) {
const newObj = {};
currentNode[currentSegment] = newObj;
currentNode = newObj;
} else if (isJsonObject(segmentValue)) currentNode = segmentValue;
else {
if (strict) throw new TypeError(`Path expansion conflict at segment "${currentSegment}": expected object but found ${typeof segmentValue}`);
const newObj = {};
currentNode[currentSegment] = newObj;
currentNode = newObj;
}
}
advance() {
this.index++;
const lastSeg = segments[segments.length - 1];
const destinationValue = currentNode[lastSeg];
if (destinationValue === void 0) currentNode[lastSeg] = value;
else if (canMerge(destinationValue, value)) mergeObjects(destinationValue, value, strict);
else {
if (strict) throw new TypeError(`Path expansion conflict at key "${lastSeg}": cannot merge ${typeof destinationValue} with ${typeof value}`);
currentNode[lastSeg] = value;
}
atEnd() {
return this.index >= this.lines.length;
}
/**
* Deep merges properties from source into target.
*
* @remarks
* For each key in source:
* - If key doesn't exist in target: copy it
* - If both values are objects: recursively merge
* - Otherwise: conflict (strict throws, non-strict overwrites)
*
* @param target - The target object to merge into
* @param source - The source object to merge from
* @param strict - Whether to throw on conflicts
* @throws TypeError if a conflict occurs in strict mode
*/
function mergeObjects(target, source, strict) {
for (const [key, sourceValue] of Object.entries(source)) {
const targetValue = target[key];
if (targetValue === void 0) target[key] = sourceValue;
else if (canMerge(targetValue, sourceValue)) mergeObjects(targetValue, sourceValue, strict);
else {
if (strict) throw new TypeError(`Path expansion conflict at key "${key}": cannot merge ${typeof targetValue} with ${typeof sourceValue}`);
target[key] = sourceValue;
}
}
get length() {
return this.lines.length;
}
peekAtDepth(targetDepth) {
const line = this.peek();
return line?.depth === targetDepth ? line : void 0;
}
};
function toParsedLines(source, indentSize, strict) {
if (!source.trim()) return {
lines: [],
blankLines: []
};
const lines = source.split("\n");
const parsed = [];
const blankLines = [];
for (let i = 0; i < lines.length; i++) {
const raw = lines[i];
const lineNumber = i + 1;
let indent = 0;
while (indent < raw.length && raw[indent] === SPACE) indent++;
const content = raw.slice(indent);
if (!content.trim()) {
const depth$1 = computeDepthFromIndent(indent, indentSize);
blankLines.push({
lineNumber,
indent,
depth: depth$1
}
function canMerge(a, b) {
return isJsonObject(a) && isJsonObject(b);
}
//#endregion
//#region src/decode/event-builder.ts
function buildValueFromEvents(events) {
const stack = [];
let root;
for (const event of events) switch (event.type) {
case "startObject": {
const obj = {};
const quotedKeys = /* @__PURE__ */ new Set();
if (stack.length === 0) stack.push({
type: "object",
obj,
quotedKeys
});
continue;
else {
const parent = stack[stack.length - 1];
if (parent.type === "object") {
if (parent.currentKey === void 0) throw new Error("Object startObject event without preceding key");
parent.obj[parent.currentKey] = obj;
parent.currentKey = void 0;
} else if (parent.type === "array") parent.arr.push(obj);
stack.push({
type: "object",
obj,
quotedKeys
});
}
break;
}
const depth = computeDepthFromIndent(indent, indentSize);
if (strict) {
let whitespaceEndIndex = 0;
while (whitespaceEndIndex < raw.length && (raw[whitespaceEndIndex] === SPACE || raw[whitespaceEndIndex] === TAB)) whitespaceEndIndex++;
if (raw.slice(0, whitespaceEndIndex).includes(TAB)) throw new SyntaxError(`Line ${lineNumber}: Tabs are not allowed in indentation in strict mode`);
if (indent > 0 && indent % indentSize !== 0) throw new SyntaxError(`Line ${lineNumber}: Indentation must be exact multiple of ${indentSize}, but found ${indent} spaces`);
case "endObject": {
if (stack.length === 0) throw new Error("Unexpected endObject event");
const context = stack.pop();
if (context.type !== "object") throw new Error("Mismatched endObject event");
if (context.quotedKeys.size > 0) Object.defineProperty(context.obj, QUOTED_KEY_MARKER, {
value: context.quotedKeys,
enumerable: false,
writable: false,
configurable: false
});
if (stack.length === 0) root = context.obj;
break;
}
parsed.push({
raw,
indent,
content,
depth,
lineNumber
});
case "startArray": {
const arr = [];
if (stack.length === 0) stack.push({
type: "array",
arr
});
else {
const parent = stack[stack.length - 1];
if (parent.type === "object") {
if (parent.currentKey === void 0) throw new Error("Array startArray event without preceding key");
parent.obj[parent.currentKey] = arr;
parent.currentKey = void 0;
} else if (parent.type === "array") parent.arr.push(arr);
stack.push({
type: "array",
arr
});
}
break;
}
case "endArray": {
if (stack.length === 0) throw new Error("Unexpected endArray event");
const context = stack.pop();
if (context.type !== "array") throw new Error("Mismatched endArray event");
if (stack.length === 0) root = context.arr;
break;
}
case "key": {
if (stack.length === 0) throw new Error("Key event outside of object context");
const parent = stack[stack.length - 1];
if (parent.type !== "object") throw new Error("Key event in non-object context");
parent.currentKey = event.key;
if (event.wasQuoted) parent.quotedKeys.add(event.key);
break;
}
case "primitive":
if (stack.length === 0) root = event.value;
else {
const parent = stack[stack.length - 1];
if (parent.type === "object") {
if (parent.currentKey === void 0) throw new Error("Primitive event without preceding key in object");
parent.obj[parent.currentKey] = event.value;
parent.currentKey = void 0;
} else if (parent.type === "array") parent.arr.push(event.value);
}
break;
}
return {
lines: parsed,
blankLines
};
if (stack.length !== 0) throw new Error("Incomplete event stream: stack not empty at end");
if (root === void 0) throw new Error("No root value built from events");
return root;
}
function computeDepthFromIndent(indentSpaces, indentSize) {
return Math.floor(indentSpaces / indentSize);
}

@@ -1168,29 +1562,2 @@ //#endregion

/**
* Encodes a JavaScript value into TOON format as a sequence of lines.
*
* This function yields TOON lines one at a time without building the full string,
* making it suitable for streaming large outputs to files, HTTP responses, or process stdout.
*
* @param input - Any JavaScript value (objects, arrays, primitives)
* @param options - Optional encoding configuration
* @returns Iterable of TOON lines (without trailing newlines)
*
* @example
* ```ts
* // Stream to stdout
* for (const line of encodeLines({ name: 'Alice', age: 30 })) {
* console.log(line)
* }
*
* // Collect to array
* const lines = Array.from(encodeLines(data))
*
* // Equivalent to encode()
* const toonString = Array.from(encodeLines(data, options)).join('\n')
* ```
*/
function encodeLines(input, options) {
return encodeJsonValue(normalizeValue(input), resolveOptions(options), 0);
}
/**
* Encodes a JavaScript value into TOON format string.

@@ -1238,9 +1605,119 @@ *

function decode(input, options) {
return decodeFromLines(input.split("\n"), options);
}
/**
* Encodes a JavaScript value into TOON format as a sequence of lines.
*
* This function yields TOON lines one at a time without building the full string,
* making it suitable for streaming large outputs to files, HTTP responses, or process stdout.
*
* @param input - Any JavaScript value (objects, arrays, primitives)
* @param options - Optional encoding configuration
* @returns Iterable of TOON lines (without trailing newlines)
*
* @example
* ```ts
* // Stream to stdout
* for (const line of encodeLines({ name: 'Alice', age: 30 })) {
* console.log(line)
* }
*
* // Collect to array
* const lines = Array.from(encodeLines(data))
*
* // Equivalent to encode()
* const toonString = Array.from(encodeLines(data, options)).join('\n')
* ```
*/
function encodeLines(input, options) {
return encodeJsonValue(normalizeValue(input), resolveOptions(options), 0);
}
/**
* Decodes TOON format from pre-split lines into a JavaScript value.
*
* This is a convenience wrapper around the streaming decoder that builds
* the full value in memory. Useful when you already have lines as an array
* or iterable and want the standard decode behavior with path expansion support.
*
* @param lines - Iterable of TOON lines (without newlines)
* @param options - Optional decoding configuration (supports expandPaths)
* @returns Parsed JavaScript value (object, array, or primitive)
*
* @example
* ```ts
* const lines = ['name: Alice', 'age: 30']
* decodeFromLines(lines)
* // { name: 'Alice', age: 30 }
* ```
*/
function decodeFromLines(lines, options) {
const resolvedOptions = resolveDecodeOptions(options);
const scanResult = toParsedLines(input, resolvedOptions.indent, resolvedOptions.strict);
if (scanResult.lines.length === 0) return {};
const decodedValue = decodeValueFromLines(new LineCursor(scanResult.lines, scanResult.blankLines), resolvedOptions);
const decodedValue = buildValueFromEvents(decodeStreamSync$1(lines, {
indent: resolvedOptions.indent,
strict: resolvedOptions.strict
}));
if (resolvedOptions.expandPaths === "safe") return expandPathsSafe(decodedValue, resolvedOptions.strict);
return decodedValue;
}
/**
* Synchronously decodes TOON lines into a stream of JSON events.
*
* This function yields structured events (startObject, endObject, startArray, endArray,
* key, primitive) that represent the JSON data model without building the full value tree.
* Useful for streaming processing, custom transformations, or memory-efficient parsing.
*
* @remarks
* Path expansion (`expandPaths: 'safe'`) is not supported in streaming mode.
*
* @param lines - Iterable of TOON lines (without newlines)
* @param options - Optional decoding configuration (expandPaths not supported)
* @returns Iterable of JSON stream events
*
* @example
* ```ts
* const lines = ['name: Alice', 'age: 30']
* for (const event of decodeStreamSync(lines)) {
* console.log(event)
* // { type: 'startObject' }
* // { type: 'key', key: 'name' }
* // { type: 'primitive', value: 'Alice' }
* // ...
* }
* ```
*/
function decodeStreamSync(lines, options) {
return decodeStreamSync$1(lines, options);
}
/**
* Asynchronously decodes TOON lines into a stream of JSON events.
*
* This function yields structured events (startObject, endObject, startArray, endArray,
* key, primitive) that represent the JSON data model without building the full value tree.
* Supports both sync and async iterables for maximum flexibility with file streams,
* network responses, or other async sources.
*
* @remarks
* Path expansion (`expandPaths: 'safe'`) is not supported in streaming mode.
*
* @param source - Async or sync iterable of TOON lines (without newlines)
* @param options - Optional decoding configuration (expandPaths not supported)
* @returns Async iterable of JSON stream events
*
* @example
* ```ts
* const fileStream = createReadStream('data.toon', 'utf-8')
* const lines = splitLines(fileStream) // Async iterable of lines
*
* for await (const event of decodeStream(lines)) {
* console.log(event)
* // { type: 'startObject' }
* // { type: 'key', key: 'name' }
* // { type: 'primitive', value: 'Alice' }
* // ...
* }
* ```
*/
function decodeStream(source, options) {
return decodeStream$1(source, options);
}
function resolveOptions(options) {

@@ -1263,2 +1740,2 @@ return {

//#endregion
export { DEFAULT_DELIMITER, DELIMITERS, decode, encode, encodeLines };
export { DEFAULT_DELIMITER, DELIMITERS, decode, decodeFromLines, decodeStream, decodeStreamSync, encode, encodeLines };
{
"name": "@toon-format/toon",
"type": "module",
"version": "1.2.0",
"version": "1.3.0",
"description": "Token-Oriented Object Notation (TOON) – Compact, human-readable, schema-aware encoding of JSON for LLM prompts",

@@ -6,0 +6,0 @@ "author": "Johann Schopplich <hello@johannschopplich.com>",

@@ -780,2 +780,42 @@ ![TOON logo with step‑by‑step guide](./.github/og.png)

**Streaming decode:**
```ts
import { decodeFromLines, decodeStreamSync } from '@toon-format/toon'
// 1. Lines → value (build full JSON value)
const value = decodeFromLines([
'users[2]{id,name}:',
' 1,Alice',
' 2,Bob',
])
// { users: [{ id: 1, name: 'Alice' }, { id: 2, name: 'Bob' }] }
// 2. Lines → events (for custom streaming consumers)
const lines = [
'users[2]{id,name}:',
' 1,Alice',
' 2,Bob',
]
for (const event of decodeStreamSync(lines)) {
// { type: 'startObject' }, { type: 'key', key: 'users' }, ...
}
```
**Async streaming decode:**
```ts
// 3. Async streaming from files or network
import { createReadStream } from 'node:fs'
import { createInterface } from 'node:readline'
import { decodeStream } from '@toon-format/toon'
const fileStream = createReadStream('data.toon', 'utf-8')
const rl = createInterface({ input: fileStream })
for await (const event of decodeStream(rl)) {
// Process events as they arrive
}
```
## Playgrounds

@@ -782,0 +822,0 @@