4.1: editor-normal and the node accessors #69

Merged
lilleman merged 5 commits from editor-normal into main 2026-09-14 20:22:59 +02:00
3 changed files with 53 additions and 20 deletions
Showing only changes of commit 758a5724e9 - Show all commits
+7 -1
View File
@@ -2,6 +2,7 @@ import assert from 'node:assert/strict'
import test from 'node:test' import test from 'node:test'
import type { AdfNode } from './document.ts' import type { AdfNode } from './document.ts'
import type { JsonValue } from '../json-value.ts'
import { toEditorNormal } from './editor-normal.ts' import { toEditorNormal } from './editor-normal.ts'
test('merges adjacent text nodes carrying identical marks, at every level', () => { test('merges adjacent text nodes carrying identical marks, at every level', () => {
@@ -59,7 +60,7 @@ test('reads an empty attrs object, marks array or content array as the absent ke
assert.deepEqual(toEditorNormal({ content: [], type: 'doc', version: 1 }), { type: 'doc', version: 1 }) assert.deepEqual(toEditorNormal({ content: [], type: 'doc', version: 1 }), { type: 'doc', version: 1 })
}) })
test('normalizes a document nesting far past the levels a recursive walk survives', () => { test('normalizes blocks and mark attributes nesting far past the levels a recursive walk survives', () => {
const levels = 100000 const levels = 100000
let node: AdfNode = { content: [], type: 'paragraph' } let node: AdfNode = { content: [], type: 'paragraph' }
for (let level = 0; level < levels; level += 1) node = { content: [node], type: 'blockquote' } for (let level = 0; level < levels; level += 1) node = { content: [node], type: 'blockquote' }
@@ -68,4 +69,9 @@ test('normalizes a document nesting far past the levels a recursive walk survive
for (; normal?.content !== undefined; depth += 1) normal = normal.content[0] for (; normal?.content !== undefined; depth += 1) normal = normal.content[0]
assert.equal(depth, levels) assert.equal(depth, levels)
assert.deepEqual(normal, { type: 'paragraph' }) assert.deepEqual(normal, { type: 'paragraph' })
let deep: JsonValue = 1
for (let level = 0; level < 2 * levels; level += 1) deep = [deep]
const marks = [{ attrs: { deep }, type: 'textColor' }]
const merged = toEditorNormal({ content: [{ content: [{ marks, text: 'a', type: 'text' }, { marks, text: 'b', type: 'text' }], type: 'paragraph' }], type: 'doc', version: 1 })
assert.deepEqual(merged.content?.[0]?.content?.map((text) => text.text), ['ab'])
}) })
+13
View File
@@ -1,6 +1,7 @@
import assert from 'node:assert/strict' import assert from 'node:assert/strict'
import test from 'node:test' import test from 'node:test'
import type { JsonValue } from './json-value.ts'
import { serializeCanonicalJson } from './canonical-json.ts' import { serializeCanonicalJson } from './canonical-json.ts'
test('sorts object keys recursively', () => { test('sorts object keys recursively', () => {
@@ -39,3 +40,15 @@ test('leaves non-ASCII raw', () => {
test('spells scalars in canonical JSON', () => { test('spells scalars in canonical JSON', () => {
assert.equal(serializeCanonicalJson([null, true, false, 0, -1.5, 'a"b'], 'compact'), '[null,true,false,0,-1.5,"a\\"b"]') assert.equal(serializeCanonicalJson([null, true, false, 0, -1.5, 'a"b'], 'compact'), '[null,true,false,0,-1.5,"a\\"b"]')
}) })
test('spells a value nesting far past the levels a recursive walk survives', () => {
const levels = 200000
let array: JsonValue = 1
let object: JsonValue = 1
for (let level = 0; level < levels; level += 1) {
array = [array]
object = { a: object }
}
assert.equal(serializeCanonicalJson(array, 'compact'), `${'['.repeat(levels)}1${']'.repeat(levels)}`)
assert.equal(serializeCanonicalJson(object, 'compact'), `${'{"a":'.repeat(levels)}1${'}'.repeat(levels)}`)
})
+33 -19
View File
@@ -2,28 +2,42 @@ import type { JsonValue } from './json-value.ts'
export type JsonSpelling = 'compact' | 'two-space' export type JsonSpelling = 'compact' | 'two-space'
type Member = { label: string; value: JsonValue }
type Pending = string | { depth: number; value: JsonValue }
export function serializeCanonicalJson(value: JsonValue, spelling: JsonSpelling): string { export function serializeCanonicalJson(value: JsonValue, spelling: JsonSpelling): string {
return serialize(value, spelling === 'compact' ? '' : ' ', 0) const indent = spelling === 'compact' ? '' : ' '
const text: string[] = []
const pending: Pending[] = [{ depth: 0, value }]
for (let next = pending.pop(); next !== undefined; next = pending.pop()) {
if (typeof next === 'string') {
text.push(next)
continue
}
const { depth, value: held } = next
if (Array.isArray(held)) schedule(pending, '[', held.map((item) => ({ label: '', value: item })), ']', indent, depth)
else if (held !== null && typeof held === 'object') schedule(pending, '{', objectMembers(held, indent), '}', indent, depth)
else text.push(JSON.stringify(held))
}
return text.join('')
} }
function serialize(value: JsonValue, indent: string, depth: number): string { function objectMembers(value: { [key: string]: JsonValue }, indent: string): Member[] {
if (Array.isArray(value)) { const separator = indent === '' ? ':' : ': '
if (value.length === 0) return '[]' return Object.keys(value)
const items = value.map((item) => serialize(item, indent, depth + 1)) .sort()
return `[${join(items, indent, depth)}]` .map((key) => ({ label: `${JSON.stringify(key)}${separator}`, value: value[key] ?? null }))
}
if (value !== null && typeof value === 'object') {
const keys = Object.keys(value).sort()
if (keys.length === 0) return '{}'
const separator = indent === '' ? ':' : ': '
const entries = keys.map((key) => `${JSON.stringify(key)}${separator}${serialize(value[key] ?? null, indent, depth + 1)}`)
return `{${join(entries, indent, depth)}}`
}
return JSON.stringify(value)
} }
function join(parts: readonly string[], indent: string, depth: number): string { function schedule(pending: Pending[], open: string, members: readonly Member[], close: string, indent: string, depth: number): void {
if (indent === '') return parts.join(',') if (members.length === 0) {
const inner = `\n${indent.repeat(depth + 1)}` pending.push(`${open}${close}`)
return `${inner}${parts.join(`,${inner}`)}\n${indent.repeat(depth)}` return
}
const inner = indent === '' ? '' : `\n${indent.repeat(depth + 1)}`
const scheduled: Pending[] = []
for (const [index, member] of members.entries()) scheduled.push(`${index === 0 ? open : ','}${inner}${member.label}`, { depth: depth + 1, value: member.value })
scheduled.push(indent === '' ? close : `\n${indent.repeat(depth)}${close}`)
for (const item of scheduled.reverse()) pending.push(item)
} }