4.1: editor-normal and the node accessors

This commit is contained in:
2026-09-14 17:03:00 +02:00
parent ec5f80e47f
commit 7672531cdc
16 changed files with 217 additions and 70 deletions
+21 -9
View File
@@ -52,8 +52,8 @@ export function attributeNestingMessage(key: string, type: string, levels: numbe
}
export function carriesOnly(node: AdfNode, attributes: readonly string[]): boolean {
if ((node.marks ?? []).length > 0 || node.text !== undefined) return false
return holdsOnly(node.attrs ?? {}, attributes)
if (nodeMarks(node).length > 0 || node.text !== undefined) return false
return holdsOnly(nodeAttrs(node), attributes)
}
// Depth is the walks' business, not the shape's: the guard waves a deep document through as blocks and marks do.
@@ -72,6 +72,18 @@ export function isAdfMark(value: unknown): value is AdfMark {
return !('attrs' in value) || isAttributes(value['attrs'])
}
export function nodeAttrs(node: { attrs?: AdfAttributes }): AdfAttributes {
return node.attrs ?? {}
}
export function nodeContent(node: { content?: AdfNode[] }): readonly AdfNode[] {
return node.content ?? []
}
export function nodeMarks(node: { marks?: AdfMark[] }): readonly AdfMark[] {
return node.marks ?? []
}
function isNodeArray(value: readonly unknown[]): value is readonly AdfNode[] {
const pending: unknown[] = [...value]
while (pending.length > 0) {
@@ -95,23 +107,23 @@ function nestingFault(nodes: readonly AdfNode[]): ConvertFault | undefined {
while (pending.length > 0) {
const node = pending.pop()
if (node === undefined) continue
const fault = attributesFault(node.attrs, node.type) ?? marksFault(node.marks)
const fault = attributesFault(nodeAttrs(node), node.type) ?? marksFault(nodeMarks(node))
if (fault !== undefined) return fault
pending.push(...(node.content ?? []))
pending.push(...nodeContent(node))
}
return undefined
}
function marksFault(marks: readonly AdfMark[] | undefined): ConvertFault | undefined {
for (const mark of marks ?? []) {
const fault = attributesFault(mark.attrs, mark.type, markAttributeNesting)
function marksFault(marks: readonly AdfMark[]): ConvertFault | undefined {
for (const mark of marks) {
const fault = attributesFault(nodeAttrs(mark), mark.type, markAttributeNesting)
if (fault !== undefined) return fault
}
return undefined
}
function attributesFault(attrs: AdfAttributes | undefined, type: string, levels: number = largestNesting): ConvertFault | undefined {
for (const [key, value] of Object.entries(attrs ?? {})) {
function attributesFault(attrs: AdfAttributes, type: string, levels: number = largestNesting): ConvertFault | undefined {
for (const [key, value] of Object.entries(attrs)) {
if (overNested(value, levels)) return { code: 'unsupported-nesting-depth', message: attributeNestingMessage(key, type, levels) }
}
return undefined
+70
View File
@@ -0,0 +1,70 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import type { AdfNode } from './document.ts'
import { toEditorNormal } from './editor-normal.ts'
test('merges adjacent text nodes carrying identical marks, at every level', () => {
const content: AdfNode[] = [
{ text: 'a', type: 'text' },
{ text: 'b', type: 'text' },
{ marks: [{ type: 'strong' }], text: 'c', type: 'text' },
{ marks: [{ attrs: {}, type: 'strong' }], text: 'd', type: 'text' },
{ type: 'hardBreak' },
{ text: 'e', type: 'text' },
{ attrs: { localId: '01a0a06b-5281-7f27-9022-8d3a74b0ab0d' }, text: 'f', type: 'text' },
{ text: 'g', type: 'text' },
]
assert.deepEqual(toEditorNormal({ content: [{ attrs: { panelType: 'info' }, content: [{ content, type: 'paragraph' }], type: 'panel' }], type: 'doc', version: 1 }), {
content: [
{
attrs: { panelType: 'info' },
content: [
{
content: [
{ text: 'ab', type: 'text' },
{ marks: [{ type: 'strong' }], text: 'cd', type: 'text' },
{ type: 'hardBreak' },
{ text: 'e', type: 'text' },
{ attrs: { localId: '01a0a06b-5281-7f27-9022-8d3a74b0ab0d' }, text: 'f', type: 'text' },
{ text: 'g', type: 'text' },
],
type: 'paragraph',
},
],
type: 'panel',
},
],
type: 'doc',
version: 1,
})
})
test('reads negative zero as zero, as JSON does', () => {
const marks = [{ attrs: { size: -0 }, type: 'border' }]
assert.deepEqual(
toEditorNormal({ content: [{ attrs: { a: -0, b: [{ c: -0 }, null, 'd'] }, content: [{ marks, text: 'x', type: 'text' }], type: 'paragraph' }], type: 'doc', version: -0 }),
{ content: [{ attrs: { a: 0, b: [{ c: 0 }, null, 'd'] }, content: [{ marks: [{ attrs: { size: 0 }, type: 'border' }], text: 'x', type: 'text' }], type: 'paragraph' }], type: 'doc', version: 0 },
)
})
test('reads an empty attrs object, marks array or content array as the absent key', () => {
const paragraph: AdfNode = { attrs: {}, content: [{ attrs: {}, marks: [], text: 'a', type: 'text' }, { marks: [{ attrs: {}, type: 'em' }], text: 'b', type: 'text' }], marks: [], type: 'paragraph' }
assert.deepEqual(toEditorNormal({ content: [paragraph, { content: [], type: 'rule' }], type: 'doc', version: 1 }), {
content: [{ content: [{ text: 'a', type: 'text' }, { marks: [{ type: 'em' }], text: 'b', type: 'text' }], type: 'paragraph' }, { type: 'rule' }],
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', () => {
const levels = 100000
let node: AdfNode = { content: [], type: 'paragraph' }
for (let level = 0; level < levels; level += 1) node = { content: [node], type: 'blockquote' }
let normal = toEditorNormal({ content: [node], type: 'doc', version: 1 }).content?.[0]
let depth = 0
for (; normal?.content !== undefined; depth += 1) normal = normal.content[0]
assert.equal(depth, levels)
assert.deepEqual(normal, { type: 'paragraph' })
})
+59 -4
View File
@@ -1,6 +1,12 @@
import type { AdfMark, AdfNode } from './document.ts'
import type { AdfAttributes, AdfDocument, AdfMark, AdfNode } from './document.ts'
import type { JsonValue } from '../json-value.ts'
import { nodeAttrs, nodeContent, nodeMarks } from './document.ts'
import { serializeCanonicalJson } from '../canonical-json.ts'
type JsonContainer = JsonValue[] | { [key: string]: JsonValue }
type NodeHolder = { content?: AdfNode[] }
export function sameMark(candidate: AdfMark, mark: AdfMark): boolean {
return markKey(candidate) === markKey(mark)
}
@@ -19,12 +25,61 @@ export function mergeAdjacentText(nodes: readonly AdfNode[]): AdfNode[] {
return merged
}
export function toEditorNormal(document: AdfDocument): AdfDocument {
const normal: AdfDocument = { type: document.type, version: Object.is(document.version, -0) ? 0 : document.version }
const pending: { holder: NodeHolder; source: NodeHolder }[] = [{ holder: normal, source: document }]
for (let entry = pending.pop(); entry !== undefined; entry = pending.pop()) {
const content = mergeAdjacentText(nodeContent(entry.source))
if (content.length === 0) continue
entry.holder.content = content.map((source) => {
const holder = normalNode(source)
pending.push({ holder, source })
return holder
})
}
return normal
}
function normalNode(node: AdfNode): AdfNode {
const normal: AdfNode = { type: node.type }
const attrs = normalAttributes(nodeAttrs(node))
if (attrs !== undefined) normal.attrs = attrs
const marks = nodeMarks(node).map(normalMark)
if (marks.length > 0) normal.marks = marks
if (node.text !== undefined) normal.text = node.text
return normal
}
function normalMark(mark: AdfMark): AdfMark {
const attrs = normalAttributes(nodeAttrs(mark))
return attrs === undefined ? { type: mark.type } : { attrs, type: mark.type }
}
function normalAttributes(attrs: AdfAttributes): AdfAttributes | undefined {
if (Object.keys(attrs).length === 0) return undefined
const normal = { ...attrs }
const pending: JsonContainer[] = [normal]
for (let held = pending.pop(); held !== undefined; held = pending.pop()) {
if (Array.isArray(held)) for (const [index, value] of held.entries()) held[index] = normalValue(value, pending)
else for (const [key, value] of Object.entries(held)) held[key] = normalValue(value, pending)
}
return normal
}
function normalValue(value: JsonValue, pending: JsonContainer[]): JsonValue {
if (Object.is(value, -0)) return 0
if (value === null || typeof value !== 'object') return value
const copy = Array.isArray(value) ? [...value] : { ...value }
pending.push(copy)
return copy
}
function mergesText(node: AdfNode): boolean {
return node.type === 'text' && Object.keys(node.attrs ?? {}).length === 0
return node.type === 'text' && Object.keys(nodeAttrs(node)).length === 0
}
function sameMarks(previous: AdfNode, node: AdfNode): boolean {
return marksKey(previous.marks ?? []) === marksKey(node.marks ?? [])
return marksKey(nodeMarks(previous)) === marksKey(nodeMarks(node))
}
function marksKey(marks: readonly AdfMark[]): string {
@@ -32,5 +87,5 @@ function marksKey(marks: readonly AdfMark[]): string {
}
function markKey(mark: AdfMark): string {
return `${mark.type} ${serializeCanonicalJson(mark.attrs ?? {}, 'compact')}`
return `${mark.type} ${serializeCanonicalJson(nodeAttrs(mark), 'compact')}`
}