forked from mengyxu/noob-components
You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
452 lines
12 KiB
452 lines
12 KiB
import type { BuildVisibleJsonRowsOptions, JsonViewNode, JsonViewNodeType } from "./types"; |
|
|
|
type PlainObject = Record<string, unknown>; |
|
type JsonContainerKind = "object" | "array" | "map" | "set"; |
|
type JsonContainerValue = PlainObject | unknown[] | Map<unknown, unknown> | Set<unknown>; |
|
|
|
interface RowContext { |
|
path: string; |
|
level: number; |
|
key?: string; |
|
displayKey?: string; |
|
keyValue?: unknown; |
|
index?: number; |
|
showComma: boolean; |
|
posInSet: number; |
|
setSize: number; |
|
} |
|
|
|
interface LineCounter { |
|
nextLineNumber: number; |
|
} |
|
|
|
function isRecordLike(value: unknown): value is Record<string, unknown> { |
|
return value !== null && typeof value === "object" && !Array.isArray(value) && !(value instanceof Map) && !(value instanceof Set); |
|
} |
|
|
|
function buildNodeId(path: string, type: JsonViewNodeType) { |
|
return `${path}:${type}`; |
|
} |
|
|
|
function buildObjectPath(parentPath: string, key: string) { |
|
if (/^[A-Za-z_$][\w$]*$/.test(key)) { |
|
return `${parentPath}.${key}`; |
|
} |
|
return `${parentPath}[${JSON.stringify(key)}]`; |
|
} |
|
|
|
function buildArrayPath(parentPath: string, index: number) { |
|
return `${parentPath}[${index}]`; |
|
} |
|
|
|
function buildMapPath(parentPath: string, key: unknown, index: number) { |
|
if (typeof key === "string") { |
|
return `${parentPath}.get(${JSON.stringify(key)})`; |
|
} |
|
|
|
if (typeof key === "number" || typeof key === "boolean" || typeof key === "bigint") { |
|
return `${parentPath}.get(${String(key)})`; |
|
} |
|
|
|
if (key === null) { |
|
return `${parentPath}.get(null)`; |
|
} |
|
|
|
if (key === undefined) { |
|
return `${parentPath}.get(undefined)`; |
|
} |
|
|
|
if (typeof key === "symbol") { |
|
return `${parentPath}.get(${String(key)})`; |
|
} |
|
|
|
return `${parentPath}.entries[${index}]`; |
|
} |
|
|
|
function isCollapsedByDefault(level: number, length: number, options: BuildVisibleJsonRowsOptions) { |
|
const deepCollapsed = typeof options.deep === "number" ? level + 1 > options.deep : false; |
|
const lengthCollapsed = |
|
typeof options.collapsedNodeLength === "number" ? length > options.collapsedNodeLength : false; |
|
return deepCollapsed || lengthCollapsed; |
|
} |
|
|
|
function isExpanded(path: string, level: number, length: number, options: BuildVisibleJsonRowsOptions) { |
|
const explicit = options.expandedState.get(path); |
|
if (explicit !== undefined) { |
|
return explicit; |
|
} |
|
return !isCollapsedByDefault(level, length, options); |
|
} |
|
|
|
function formatKeyDisplay(value: unknown) { |
|
if (typeof value === "string") { |
|
return JSON.stringify(value); |
|
} |
|
if (typeof value === "number" || typeof value === "boolean") { |
|
return String(value); |
|
} |
|
if (typeof value === "bigint") { |
|
return `${String(value)}n`; |
|
} |
|
if (value === null) { |
|
return "null"; |
|
} |
|
if (value === undefined) { |
|
return "undefined"; |
|
} |
|
if (typeof value === "symbol") { |
|
return value.toString(); |
|
} |
|
if (typeof value === "function") { |
|
return value.name ? `[Function ${value.name}]` : "[Function]"; |
|
} |
|
if (Array.isArray(value)) { |
|
return `Array(${value.length})`; |
|
} |
|
if (value instanceof Map) { |
|
return `Map(${value.size})`; |
|
} |
|
if (value instanceof Set) { |
|
return `Set(${value.size})`; |
|
} |
|
return "Object"; |
|
} |
|
|
|
function formatPrimitiveDisplay(value: unknown) { |
|
if (typeof value === "string") { |
|
return JSON.stringify(value); |
|
} |
|
if (typeof value === "number" || typeof value === "boolean") { |
|
return String(value); |
|
} |
|
if (typeof value === "bigint") { |
|
return `${String(value)}n`; |
|
} |
|
if (value === null) { |
|
return "null"; |
|
} |
|
if (value === undefined) { |
|
return "undefined"; |
|
} |
|
if (typeof value === "symbol") { |
|
return value.toString(); |
|
} |
|
if (typeof value === "function") { |
|
return value.name ? `[Function ${value.name}]` : "[Function]"; |
|
} |
|
return String(value); |
|
} |
|
|
|
function getContainerKind(value: unknown): JsonContainerKind | null { |
|
if (Array.isArray(value)) { |
|
return "array"; |
|
} |
|
if (value instanceof Map) { |
|
return "map"; |
|
} |
|
if (value instanceof Set) { |
|
return "set"; |
|
} |
|
if (isRecordLike(value)) { |
|
return "object"; |
|
} |
|
return null; |
|
} |
|
|
|
function getContainerLength(value: JsonContainerValue, kind: JsonContainerKind) { |
|
switch (kind) { |
|
case "array": |
|
return (value as unknown[]).length; |
|
case "map": |
|
return (value as Map<unknown, unknown>).size; |
|
case "set": |
|
return (value as Set<unknown>).size; |
|
case "object": |
|
default: |
|
return Object.keys(value as PlainObject).length; |
|
} |
|
} |
|
|
|
function getContainerContent(kind: JsonContainerKind, state: "start" | "end" | "collapsed") { |
|
switch (kind) { |
|
case "map": |
|
if (state === "start") return "Map {"; |
|
if (state === "end") return "}"; |
|
return "Map {...}"; |
|
case "set": |
|
if (state === "start") return "Set ["; |
|
if (state === "end") return "]"; |
|
return "Set [...]"; |
|
case "object": |
|
if (state === "start") return "{"; |
|
if (state === "end") return "}"; |
|
return "{...}"; |
|
case "array": |
|
default: |
|
if (state === "start") return "["; |
|
if (state === "end") return "]"; |
|
return "[...]"; |
|
} |
|
} |
|
|
|
function getContainerNodeType(kind: JsonContainerKind, state: "start" | "end" | "collapsed"): JsonViewNodeType { |
|
if (kind === "object" || kind === "map") { |
|
if (state === "start") return "objectStart"; |
|
if (state === "end") return "objectEnd"; |
|
return "objectCollapsed"; |
|
} |
|
|
|
if (state === "start") return "arrayStart"; |
|
if (state === "end") return "arrayEnd"; |
|
return "arrayCollapsed"; |
|
} |
|
|
|
function countValueLines(value: unknown, ancestors = new WeakSet<object>()): number { |
|
const kind = getContainerKind(value); |
|
if (!kind) { |
|
return 1; |
|
} |
|
|
|
const reference = value as object; |
|
if (ancestors.has(reference)) { |
|
return 1; |
|
} |
|
|
|
ancestors.add(reference); |
|
|
|
const total = |
|
kind === "array" |
|
? 2 + (value as unknown[]).reduce<number>((sum, entry) => sum + countValueLines(entry, ancestors), 0) |
|
: kind === "set" |
|
? 2 + Array.from(value as Set<unknown>).reduce<number>((sum, entry) => sum + countValueLines(entry, ancestors), 0) |
|
: kind === "map" |
|
? 2 + |
|
Array.from((value as Map<unknown, unknown>).values()).reduce<number>( |
|
(sum, entry) => sum + countValueLines(entry, ancestors), |
|
0 |
|
) |
|
: 2 + |
|
Object.values(value as PlainObject).reduce<number>((sum, entry) => sum + countValueLines(entry, ancestors), 0); |
|
|
|
ancestors.delete(reference); |
|
return total; |
|
} |
|
|
|
function countContainerChildLines(kind: JsonContainerKind, value: JsonContainerValue, ancestors: WeakSet<object>) { |
|
if (kind === "array") { |
|
return (value as unknown[]).reduce<number>((sum, entry) => sum + countValueLines(entry, ancestors), 0); |
|
} |
|
|
|
if (kind === "set") { |
|
return Array.from(value as Set<unknown>).reduce<number>((sum, entry) => sum + countValueLines(entry, ancestors), 0); |
|
} |
|
|
|
if (kind === "map") { |
|
return Array.from((value as Map<unknown, unknown>).values()).reduce<number>( |
|
(sum, entry) => sum + countValueLines(entry, ancestors), |
|
0 |
|
); |
|
} |
|
|
|
return Object.values(value as PlainObject).reduce<number>((sum, entry) => sum + countValueLines(entry, ancestors), 0); |
|
} |
|
|
|
function createNode( |
|
counter: LineCounter, |
|
context: RowContext, |
|
type: JsonViewNodeType, |
|
content: string, |
|
value: unknown, |
|
extras?: Partial<JsonViewNode> |
|
): JsonViewNode { |
|
return { |
|
id: buildNodeId(context.path, type), |
|
path: context.path, |
|
lineNumber: counter.nextLineNumber++, |
|
level: context.level, |
|
key: context.key, |
|
displayKey: context.displayKey, |
|
keyValue: context.keyValue, |
|
index: context.index, |
|
content, |
|
showComma: context.showComma, |
|
type, |
|
value, |
|
isLeaf: type === "content", |
|
hasToggle: false, |
|
isExpanded: false, |
|
posInSet: context.posInSet, |
|
setSize: context.setSize, |
|
...extras, |
|
}; |
|
} |
|
|
|
function appendValueRows( |
|
value: unknown, |
|
context: RowContext, |
|
rows: JsonViewNode[], |
|
options: BuildVisibleJsonRowsOptions, |
|
counter: LineCounter, |
|
ancestors: WeakSet<object> |
|
) { |
|
const kind = getContainerKind(value); |
|
if (!kind) { |
|
rows.push(createNode(counter, context, "content", formatPrimitiveDisplay(value), value, { isLeaf: true })); |
|
return; |
|
} |
|
|
|
const reference = value as object; |
|
if (ancestors.has(reference)) { |
|
rows.push(createNode(counter, context, "content", "[Circular]", value, { isLeaf: true })); |
|
return; |
|
} |
|
|
|
ancestors.add(reference); |
|
appendContainerRows(kind, value as JsonContainerValue, context, rows, options, counter, ancestors); |
|
ancestors.delete(reference); |
|
} |
|
|
|
function appendContainerRows( |
|
kind: JsonContainerKind, |
|
value: JsonContainerValue, |
|
context: RowContext, |
|
rows: JsonViewNode[], |
|
options: BuildVisibleJsonRowsOptions, |
|
counter: LineCounter, |
|
ancestors: WeakSet<object> |
|
) { |
|
const length = getContainerLength(value, kind); |
|
const expanded = isExpanded(context.path, context.level, length, options); |
|
const totalLineCount = 2 + countContainerChildLines(kind, value, ancestors); |
|
|
|
if (!expanded) { |
|
rows.push( |
|
createNode(counter, context, getContainerNodeType(kind, "collapsed"), getContainerContent(kind, "collapsed"), value, { |
|
length, |
|
hasToggle: true, |
|
isExpanded: false, |
|
isLeaf: false, |
|
}) |
|
); |
|
counter.nextLineNumber += totalLineCount - 1; |
|
return; |
|
} |
|
|
|
rows.push( |
|
createNode(counter, context, getContainerNodeType(kind, "start"), getContainerContent(kind, "start"), value, { |
|
length, |
|
hasToggle: true, |
|
isExpanded: true, |
|
isLeaf: false, |
|
showComma: false, |
|
}) |
|
); |
|
|
|
if (kind === "object") { |
|
Object.entries(value as PlainObject).forEach(([key, childValue], index) => { |
|
appendValueRows( |
|
childValue, |
|
{ |
|
path: buildObjectPath(context.path, key), |
|
level: context.level + 1, |
|
key, |
|
showComma: index < length - 1, |
|
posInSet: index + 1, |
|
setSize: length, |
|
}, |
|
rows, |
|
options, |
|
counter, |
|
ancestors |
|
); |
|
}); |
|
} else if (kind === "array") { |
|
(value as unknown[]).forEach((childValue, index) => { |
|
appendValueRows( |
|
childValue, |
|
{ |
|
path: buildArrayPath(context.path, index), |
|
level: context.level + 1, |
|
index, |
|
showComma: index < length - 1, |
|
posInSet: index + 1, |
|
setSize: length, |
|
}, |
|
rows, |
|
options, |
|
counter, |
|
ancestors |
|
); |
|
}); |
|
} else if (kind === "set") { |
|
Array.from(value as Set<unknown>).forEach((childValue, index) => { |
|
appendValueRows( |
|
childValue, |
|
{ |
|
path: buildArrayPath(context.path, index), |
|
level: context.level + 1, |
|
index, |
|
showComma: index < length - 1, |
|
posInSet: index + 1, |
|
setSize: length, |
|
}, |
|
rows, |
|
options, |
|
counter, |
|
ancestors |
|
); |
|
}); |
|
} else { |
|
Array.from(value as Map<unknown, unknown>).forEach(([keyValue, childValue], index) => { |
|
appendValueRows( |
|
childValue, |
|
{ |
|
path: buildMapPath(context.path, keyValue, index), |
|
level: context.level + 1, |
|
displayKey: formatKeyDisplay(keyValue), |
|
keyValue, |
|
showComma: index < length - 1, |
|
posInSet: index + 1, |
|
setSize: length, |
|
}, |
|
rows, |
|
options, |
|
counter, |
|
ancestors |
|
); |
|
}); |
|
} |
|
|
|
rows.push( |
|
createNode(counter, context, getContainerNodeType(kind, "end"), getContainerContent(kind, "end"), value, { |
|
length, |
|
hasToggle: false, |
|
isExpanded: expanded, |
|
isLeaf: false, |
|
key: undefined, |
|
displayKey: undefined, |
|
keyValue: undefined, |
|
index: undefined, |
|
}) |
|
); |
|
} |
|
|
|
export function buildVisibleJsonRows(value: unknown, options: BuildVisibleJsonRowsOptions) { |
|
const rows: JsonViewNode[] = []; |
|
const counter: LineCounter = { nextLineNumber: 1 }; |
|
appendValueRows( |
|
value, |
|
{ |
|
path: options.rootPath, |
|
level: 0, |
|
showComma: false, |
|
posInSet: 1, |
|
setSize: 1, |
|
}, |
|
rows, |
|
options, |
|
counter, |
|
new WeakSet<object>() |
|
); |
|
return rows; |
|
}
|
|
|