export type JsonLine = { path: string; depth: number; key?: string; kind: "open" | "close" | "value"; text: string; tone?: "string" | "number" | "boolean" | "null"; foldable?: boolean; expanded?: boolean; }; /** Pretty JSON lines retain separators and paths, including after collapsing branches. */ export function jsonLines(value: unknown, collapsed: ReadonlySet = new Set()): JsonLine[] { const lines: JsonLine[] = []; function visit(current: unknown, path: string, depth: number, key: string | undefined, last: boolean) { const comma = last ? "" : ","; if (current !== null && typeof current === "object") { const array = Array.isArray(current); const entries: [string, unknown][] = array ? current.map((item, index) => [String(index), item]) : Object.entries(current); const open = array ? "[" : "{"; const close = array ? "]" : "}"; if (entries.length === 0) { lines.push({ path, depth, key, kind: "value", text: `${open}${close}${comma}` }); } else if (collapsed.has(path)) { lines.push({ path, depth, key, kind: "open", text: `${open}…${close}${comma}`, foldable: true, expanded: false }); } else { lines.push({ path, depth, key, kind: "open", text: open, foldable: true, expanded: true }); entries.forEach(([childKey, childValue], index) => { visit(childValue, `${path}/${childKey.replace(/~/g, "~0").replace(/\//g, "~1")}`, depth + 1, array ? undefined : childKey, index === entries.length - 1); }); lines.push({ path, depth, kind: "close", text: `${close}${comma}` }); } return; } const tone = current === null ? "null" : typeof current as "string" | "number" | "boolean"; lines.push({ path, depth, key, kind: "value", tone, text: `${JSON.stringify(current)}${comma}` }); } visit(value, "$", 0, undefined, true); return lines; }