From ba50364ae79bb7cd7e808a50907fe92d59a96392 Mon Sep 17 00:00:00 2001 From: Lilleman auf Larv Date: Mon, 14 Sep 2026 17:03:00 +0200 Subject: [PATCH] 4.1: editor-normal and the node accessors --- src/adf/document.ts | 30 +++++--- src/adf/editor-normal.test.ts | 70 +++++++++++++++++++ src/adf/editor-normal.ts | 63 +++++++++++++++-- src/corpus.test.ts | 7 +- src/markdown/block-directive-marks.ts | 4 +- src/markdown/emit/adf-to-markdown.test.ts | 5 +- src/markdown/emit/adf-to-markdown.ts | 38 +++++----- src/markdown/emit/block-directive-spelling.ts | 7 +- src/markdown/emit/image.ts | 10 +-- .../emit/inline-directive-spelling.ts | 3 +- src/markdown/emit/inline-line.ts | 23 +++--- src/markdown/emit/pipe-table.ts | 12 ++-- src/markdown/mark-spellings.ts | 3 +- src/markdown/parse/directive-nodes.ts | 4 +- src/markdown/parse/inline-content.ts | 5 +- src/markdown/parse/markdown-to-adf.ts | 3 +- 16 files changed, 217 insertions(+), 70 deletions(-) create mode 100644 src/adf/editor-normal.test.ts diff --git a/src/adf/document.ts b/src/adf/document.ts index c4c72cf..dfd6b21 100644 --- a/src/adf/document.ts +++ b/src/adf/document.ts @@ -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 diff --git a/src/adf/editor-normal.test.ts b/src/adf/editor-normal.test.ts new file mode 100644 index 0000000..eaefc83 --- /dev/null +++ b/src/adf/editor-normal.test.ts @@ -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' }) +}) diff --git a/src/adf/editor-normal.ts b/src/adf/editor-normal.ts index cd5b738..b6514f1 100644 --- a/src/adf/editor-normal.ts +++ b/src/adf/editor-normal.ts @@ -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')}` } diff --git a/src/corpus.test.ts b/src/corpus.test.ts index 180eb16..ae36632 100644 --- a/src/corpus.test.ts +++ b/src/corpus.test.ts @@ -9,6 +9,7 @@ import { isAdfDocument } from './adf/document.ts' import { isJsonValue } from './json-value.ts' import { markdownToAdf } from './markdown/parse/markdown-to-adf.ts' import { serializeCanonicalJson } from './canonical-json.ts' +import { toEditorNormal } from './adf/editor-normal.ts' const corpusRoot = join(dirname(fileURLToPath(import.meta.url)), '..', 'corpus') const errorsRoot = join(corpusRoot, 'errors') @@ -95,7 +96,7 @@ for (const directory of roundTripDirectories) { assert.ok(isAdfDocument(expected), `${name}.json is not an ADF document`) const result = markdownToAdf(readFileSync(join(roundTripRoot, directory, `${name}.md`), 'utf8')) assert.ok(result.ok, result.ok ? '' : `${result.error.code}: ${result.error.message}`) - assert.deepEqual(result.value, expected) + assert.deepEqual(toEditorNormal(result.value), toEditorNormal(expected)) }) } } @@ -184,12 +185,12 @@ for (const name of pairedNames(normalizationRoot, '.md', '.json')) { assert.ok(isAdfDocument(expected), `${name}.json is not an ADF document`) const result = markdownToAdf(readFileSync(join(normalizationRoot, `${name}.md`), 'utf8')) assert.ok(result.ok, result.ok ? '' : `${result.error.code}: ${result.error.message}`) - assert.deepEqual(result.value, expected) + assert.deepEqual(toEditorNormal(result.value), toEditorNormal(expected)) const emitted = adfToMarkdown(result.value) assert.ok(emitted.ok, emitted.ok ? '' : `${emitted.error.code}: ${emitted.error.message}`) const again = markdownToAdf(emitted.value) assert.ok(again.ok, again.ok ? '' : `${again.error.code}: ${again.error.message}`) - assert.deepEqual(again.value, expected) + assert.deepEqual(toEditorNormal(again.value), toEditorNormal(expected)) }) } diff --git a/src/markdown/block-directive-marks.ts b/src/markdown/block-directive-marks.ts index dffda09..311ba2e 100644 --- a/src/markdown/block-directive-marks.ts +++ b/src/markdown/block-directive-marks.ts @@ -1,13 +1,13 @@ import type { AdfMark } from '../adf/document.ts' import type { JsonValue } from '../json-value.ts' -import { isAdfMark } from '../adf/document.ts' +import { isAdfMark, nodeAttrs } from '../adf/document.ts' import { serializeCanonicalJson } from '../canonical-json.ts' export const marksAttribute = 'marks' export function markValues(marks: readonly AdfMark[]): JsonValue { return marks.map((mark) => { - const attrs = mark.attrs ?? {} + const attrs = nodeAttrs(mark) return Object.keys(attrs).length === 0 ? { type: mark.type } : { attrs, type: mark.type } }) } diff --git a/src/markdown/emit/adf-to-markdown.test.ts b/src/markdown/emit/adf-to-markdown.test.ts index 3d19348..4f53351 100644 --- a/src/markdown/emit/adf-to-markdown.test.ts +++ b/src/markdown/emit/adf-to-markdown.test.ts @@ -6,6 +6,7 @@ import type { JsonValue } from '../../json-value.ts' import type { Result } from '../../result.ts' import { adfToMarkdown, markdownToAdf } from '../../index.ts' import { largestNesting } from '../../nesting.ts' +import { toEditorNormal } from '../../adf/editor-normal.ts' function document(...content: AdfNode[]): AdfDocument { return { content, type: 'doc', version: 1 } @@ -353,7 +354,9 @@ test('refuses marks and attributes nested deeper than the emitter carries', () = const roundTrips = (node: AdfNode): void => { const spelled = adfToMarkdown(document(node)) assert.ok(spelled.ok, spelled.ok ? '' : spelled.error.message) - assert.deepEqual(markdownToAdf(spelled.value), { ok: true, value: document(node) }) + const read = markdownToAdf(spelled.value) + assert.ok(read.ok, read.ok ? '' : read.error.message) + assert.deepEqual(toEditorNormal(read.value), toEditorNormal(document(node))) } assert.equal(markdown(adfToMarkdown(document(paragraph({ marks: [{ attrs, type: 'em' }], text: 'x', type: 'text' })))), deeper('depth', 'em', largestNesting - 3)) diff --git a/src/markdown/emit/adf-to-markdown.ts b/src/markdown/emit/adf-to-markdown.ts index 05c7c78..7f861b8 100644 --- a/src/markdown/emit/adf-to-markdown.ts +++ b/src/markdown/emit/adf-to-markdown.ts @@ -1,6 +1,6 @@ import type { AdfDocument, AdfNode } from '../../adf/document.ts' import type { BlockDirective } from '../../adf/block-directives.ts' -import { adfDocumentFault, carriesOnly } from '../../adf/document.ts' +import { adfDocumentFault, carriesOnly, nodeAttrs, nodeContent, nodeMarks } from '../../adf/document.ts' import { blockDirective } from '../../adf/block-directives.ts' import { carriedBlock } from '../opaque-carry.ts' import { emitInlineLine } from './inline-line.ts' @@ -26,7 +26,7 @@ export function adfToMarkdown(document: AdfDocument): Result { const fault = adfDocumentFault(document) if (fault !== undefined) return faulted(fault, []) if (document.version !== 1) return failure('unsupported-document-version', `no markdown spelling carries ADF version ${document.version}`, []) - const blocks = emitBlocks(document.content ?? [], 'document', [], 0) + const blocks = emitBlocks(nodeContent(document), 'document', [], 0) if (!blocks.ok) return blocks return success(blocks.value.text === '' ? '' : `${blocks.value.text}\n`) } @@ -63,8 +63,8 @@ function separationBetween(previous: PlacedBlock, next: PlacedBlock, container: } function interruptsParagraph(node: AdfNode): boolean { - const items = node.content ?? [] - const empty = (items[0]?.content ?? []).length === 0 + const items = nodeContent(node) + const empty = items[0] === undefined || nodeContent(items[0]).length === 0 if (node.type !== 'orderedList') return markerInterruptsParagraph(undefined, empty) return markerInterruptsParagraph(listStart(node, items.length) ?? 0, empty) } @@ -110,7 +110,7 @@ function commonMarkText(text: string): EmittedBlock { function emitDirectiveBlock(node: AdfNode, directive: BlockDirective, path: ConvertErrorPath, depth: number): Result { if (node.text !== undefined) return failure('unsupported-node-shape', `a ${node.type} carries no text: this one holds text`, path) - const content = node.content ?? [] + const content = nodeContent(node) if (directive.contentModel === 'none' && content.length > 0) return failure('unsupported-node-shape', `a ${node.type} holds no content: this one holds some`, path) if (directive.contentModel === 'code') return emitCodeDirective(node, directive, path, depth) const header = spellDirectiveHeader(node, directive) @@ -134,7 +134,7 @@ function emitInlineBody(content: readonly AdfNode[], path: ConvertErrorPath): Re function emitBlockquote(node: AdfNode, path: ConvertErrorPath, depth: number): Result | undefined { if (!carriesOnly(node, [])) return undefined - const inner = emitBlocks(node.content ?? [], 'document', path, depth + 1) + const inner = emitBlocks(nodeContent(node), 'document', path, depth + 1) if (!inner.ok) return inner const text = inner.value.text .split('\n') @@ -145,7 +145,7 @@ function emitBlockquote(node: AdfNode, path: ConvertErrorPath, depth: number): R function emitCodeBlock(node: AdfNode, path: ConvertErrorPath): Result | undefined { if (!carriesOnly(node, ['language'])) return undefined - const slot = languageSlot(node.attrs?.['language']) + const slot = languageSlot(nodeAttrs(node)['language']) if (slot.kind === 'attribute') return undefined const text = codeBlockText(node, path) if (!text.ok) return text @@ -153,7 +153,7 @@ function emitCodeBlock(node: AdfNode, path: ConvertErrorPath): Result { - const slot = languageSlot(node.attrs?.['language']) + const slot = languageSlot(nodeAttrs(node)['language']) const header = spellDirectiveHeader(node, directive, slot.kind === 'attribute' ? [] : ['language']) if (header === undefined) return commonMarkLine(carriedBlock(node, path, depth)) const text = codeBlockText(node, path) @@ -164,15 +164,15 @@ function emitCodeDirective(node: AdfNode, directive: BlockDirective, path: Conve function codeBlockText(node: AdfNode, path: ConvertErrorPath): Result { let text = '' - for (const [index, child] of (node.content ?? []).entries()) { + for (const [index, child] of nodeContent(node).entries()) { const childPath = [...path, 'content', index] if ( child.type !== 'text' || typeof child.text !== 'string' || child.text === '' || - (child.content ?? []).length > 0 || - (child.marks ?? []).length > 0 || - Object.keys(child.attrs ?? {}).length > 0 + nodeContent(child).length > 0 || + nodeMarks(child).length > 0 || + Object.keys(nodeAttrs(child)).length > 0 ) { return failure('unsupported-node-shape', `a codeBlock holds plain text nodes only: this ${child.type} node is not one`, childPath) } @@ -185,10 +185,10 @@ function codeBlockText(node: AdfNode, path: ConvertErrorPath): Result { function emitHeading(node: AdfNode, path: ConvertErrorPath): Result | undefined { if (!carriesOnly(node, ['level'])) return undefined - const level = node.attrs?.['level'] + const level = nodeAttrs(node)['level'] if (typeof level !== 'number' || !Number.isInteger(level) || level < 1 || level > 6) return undefined const hashes = '#'.repeat(level) - const content = node.content ?? [] + const content = nodeContent(node) if (content.length === 0) return success(commonMarkText(hashes)) const line = emitInlineLine(content, 'heading', path) if (!line.ok) return line @@ -198,7 +198,7 @@ function emitHeading(node: AdfNode, path: ConvertErrorPath): Result | undefined { const ordered = node.type === 'orderedList' if (!carriesOnly(node, ordered ? ['order'] : [])) return undefined - const items = node.content ?? [] + const items = nodeContent(node) const start = listStart(node, items.length) if (start === undefined || items.length === 0) return undefined if (items.some((item) => item.type !== 'listItem' || !carriesOnly(item, []))) return undefined @@ -216,13 +216,13 @@ function emitList(node: AdfNode, path: ConvertErrorPath, depth: number): Result< function listStart(node: AdfNode, items: number): number | undefined { if (node.type !== 'orderedList') return 0 - const start = node.attrs?.['order'] + const start = nodeAttrs(node)['order'] if (typeof start !== 'number' || !Number.isInteger(start) || start < 0 || start > largestListMarker) return undefined return start + items - 1 > largestListMarker ? undefined : start } function emitListItem(item: AdfNode, marker: string, path: ConvertErrorPath, depth: number): Result | undefined { - const inner = emitBlocks(item.content ?? [], 'list-item', path, depth + 1) + const inner = emitBlocks(nodeContent(item), 'list-item', path, depth + 1) if (!inner.ok) return inner if (inner.value.text === '') return success({ fenceColons: 0, text: marker.trimEnd() }) const indent = ' '.repeat(marker.length) @@ -232,7 +232,7 @@ function emitListItem(item: AdfNode, marker: string, path: ConvertErrorPath, dep } function emitParagraph(node: AdfNode, path: ConvertErrorPath): Result | undefined { - const content = node.content ?? [] + const content = nodeContent(node) if (content.length === 0 || !carriesOnly(node, [])) return undefined const line = emitInlineLine(content, 'paragraph', path) if (!line.ok) return line @@ -240,6 +240,6 @@ function emitParagraph(node: AdfNode, path: ConvertErrorPath): Result | undefined { - if (!carriesOnly(node, []) || (node.content ?? []).length > 0) return undefined + if (!carriesOnly(node, []) || nodeContent(node).length > 0) return undefined return success(commonMarkText('---')) } diff --git a/src/markdown/emit/block-directive-spelling.ts b/src/markdown/emit/block-directive-spelling.ts index 05b5a0a..9de3071 100644 --- a/src/markdown/emit/block-directive-spelling.ts +++ b/src/markdown/emit/block-directive-spelling.ts @@ -3,6 +3,7 @@ import type { BlockDirective } from '../../adf/block-directives.ts' import { blockArgument } from '../block-directive-arguments.ts' import { isBareToken, spellAttributes, spellJsonAttribute, spellVocabulary } from '../directive-syntax.ts' import { markValues, marksAttribute } from '../block-directive-marks.ts' +import { nodeAttrs, nodeMarks } from '../../adf/document.ts' import { vocabularyPairs } from '../../adf/attribute-vocabulary.ts' export function spellDirectiveHeader(node: AdfNode, directive: BlockDirective, spelledByBody: readonly string[] = []): string | undefined { @@ -10,17 +11,17 @@ export function spellDirectiveHeader(node: AdfNode, directive: BlockDirective, s const argument = spellArgument(node, argumentAttribute) if (argument === undefined) return undefined const spelled = argumentAttribute === undefined ? spelledByBody : [argumentAttribute, ...spelledByBody] - const pairs = vocabularyPairs(node.attrs ?? {}, directive.attributes, spelled) + const pairs = vocabularyPairs(nodeAttrs(node), directive.attributes, spelled) if (pairs === undefined) return undefined const spelledPairs = spellVocabulary(pairs) - const marks = node.marks ?? [] + const marks = nodeMarks(node) if (marks.length > 0) spelledPairs.push([marksAttribute, spellJsonAttribute(markValues(marks))]) const attributes = spellAttributes(spelledPairs) return `${node.type}${argument}${attributes === '' ? '' : ` ${attributes}`}` } function spellArgument(node: AdfNode, argumentAttribute: string | undefined): string | undefined { - const value = argumentAttribute === undefined ? undefined : node.attrs?.[argumentAttribute] + const value = argumentAttribute === undefined ? undefined : nodeAttrs(node)[argumentAttribute] if (value === undefined) return '' if (typeof value !== 'string' || !isBareToken(value)) return undefined return ` ${value}` diff --git a/src/markdown/emit/image.ts b/src/markdown/emit/image.ts index f44a880..3b318d9 100644 --- a/src/markdown/emit/image.ts +++ b/src/markdown/emit/image.ts @@ -1,5 +1,5 @@ import type { AdfNode } from '../../adf/document.ts' -import { carriesOnly } from '../../adf/document.ts' +import { carriesOnly, nodeAttrs, nodeContent } from '../../adf/document.ts' import type { ConvertErrorPath } from '../../result.ts' import { serializeCanonicalJson } from '../../canonical-json.ts' import { tryImageLine } from './inline-line.ts' @@ -14,11 +14,11 @@ export function tryImage(node: AdfNode, path: ConvertErrorPath): string | undefi } function imageShape(node: AdfNode): { alt: string | undefined; url: string } | undefined { - const content = node.content ?? [] + const content = nodeContent(node) const media = content[0] - if (!carriesOnly(node, ['layout']) || serializeCanonicalJson(node.attrs ?? {}, 'compact') !== centeredMediaSingle) return undefined - if (media === undefined || content.length !== 1 || media.type !== 'media' || !carriesOnly(media, imageAttributes) || (media.content ?? []).length > 0) return undefined - const attrs = media.attrs ?? {} + if (!carriesOnly(node, ['layout']) || serializeCanonicalJson(nodeAttrs(node), 'compact') !== centeredMediaSingle) return undefined + if (media === undefined || content.length !== 1 || media.type !== 'media' || !carriesOnly(media, imageAttributes) || nodeContent(media).length > 0) return undefined + const attrs = nodeAttrs(media) const alt = attrs['alt'] const url = attrs['url'] if (attrs['type'] !== 'external' || typeof url !== 'string') return undefined diff --git a/src/markdown/emit/inline-directive-spelling.ts b/src/markdown/emit/inline-directive-spelling.ts index 2e7273f..5d867a4 100644 --- a/src/markdown/emit/inline-directive-spelling.ts +++ b/src/markdown/emit/inline-directive-spelling.ts @@ -1,9 +1,10 @@ import type { AdfNode } from '../../adf/document.ts' import type { InlineDirective } from '../../adf/inline-directives.ts' +import { nodeAttrs } from '../../adf/document.ts' import { spellAttributes, spellVocabulary } from '../directive-syntax.ts' import { vocabularyPairs } from '../../adf/attribute-vocabulary.ts' export function spellInlineNodeAttributes(node: AdfNode, directive: InlineDirective): string | undefined { - const pairs = vocabularyPairs(node.attrs ?? {}, directive.attributes, directive.textAttribute === undefined ? [] : [directive.textAttribute]) + const pairs = vocabularyPairs(nodeAttrs(node), directive.attributes, directive.textAttribute === undefined ? [] : [directive.textAttribute]) return pairs === undefined ? undefined : spellAttributes(spellVocabulary(pairs)) } diff --git a/src/markdown/emit/inline-line.ts b/src/markdown/emit/inline-line.ts index 6588593..71aaaa1 100644 --- a/src/markdown/emit/inline-line.ts +++ b/src/markdown/emit/inline-line.ts @@ -9,6 +9,7 @@ import { inlineDirective } from '../../adf/inline-directives.ts' import { largestNesting } from '../../nesting.ts' import { longestBacktickRun } from '../backtick-runs.ts' import { markSpelling, spellMarkAttributes } from '../mark-spellings.ts' +import { nodeAttrs, nodeContent, nodeMarks } from '../../adf/document.ts' import { sameMark } from '../../adf/editor-normal.ts' import { slotLineEndingFault, spellLeafDirective } from '../directive-syntax.ts' import { spellDestination, spellTitle } from '../link-syntax.ts' @@ -127,7 +128,7 @@ function syntax(text: string): InlineSegment { } function refuseContentAndText(node: AdfNode, path: ConvertErrorPath): Result { - const holdsContent = (node.content ?? []).length > 0 + const holdsContent = nodeContent(node).length > 0 if (holdsContent || node.text !== undefined) { const held = holdsContent ? 'content' : 'text' return failure('unsupported-node-shape', `a ${node.type} node holds neither content nor text: this one holds ${held}`, path) @@ -156,7 +157,7 @@ function inlineRuns(nodes: readonly AdfNode[], depth: number, firstIndex: number for (const [offset, node] of nodes.entries()) { const index = firstIndex + offset // spec/flavour.md, Marks. - const mark = carries(node, carried, index) ? undefined : (node.marks ?? [])[depth] + const mark = carries(node, carried, index) ? undefined : nodeMarks(node)[depth] if (mark === undefined) { runs.push({ index, kind: 'plain', node }) continue @@ -184,7 +185,7 @@ function emitLeaf(node: AdfNode, context: InlineContext, index: number): Result< if (!carried.ok) return carried return success({ segments: [syntax(carried.value)] }) } - const types = (node.marks ?? []).map((mark) => mark.type) + const types = nodeMarks(node).map((mark) => mark.type) if (new Set(types).size !== types.length) return failure('unsupported-node-shape', `a ${node.type} node carries one mark type twice`, path) const directive = inlineDirective(node.type) if (directive === undefined) return emitText(node, context, index, path) @@ -206,7 +207,7 @@ function emitInlineDirective(node: AdfNode, directive: InlineDirective, index: n if (!empty.ok) return empty const attributes = spellInlineNodeAttributes(node, directive) if (attributes === undefined) return success({ carry: { first: index, last: index } }) - const slot = directive.textAttribute === undefined ? undefined : node.attrs?.[directive.textAttribute] + const slot = directive.textAttribute === undefined ? undefined : nodeAttrs(node)[directive.textAttribute] if (slot === undefined) return success({ segments: [syntax(spellLeafDirective(node.type, attributes))] }) if (typeof slot !== 'string') return success({ carry: { first: index, last: index } }) const spans = slotLineEndingFault(node.type, slot) @@ -217,9 +218,9 @@ function emitInlineDirective(node: AdfNode, directive: InlineDirective, index: n } function emitText(node: AdfNode, context: InlineContext, index: number, path: ConvertErrorPath): Result { - if (Object.keys(node.attrs ?? {}).length > 0) return success({ carry: { first: index, last: index } }) + if (Object.keys(nodeAttrs(node)).length > 0) return success({ carry: { first: index, last: index } }) if (typeof node.text !== 'string' || node.text === '') return failure('unsupported-node-shape', 'a text node holds text: this one has none', path) - if ((node.content ?? []).length > 0) return failure('unsupported-node-shape', 'a text node holds no content: this one holds some', path) + if (nodeContent(node).length > 0) return failure('unsupported-node-shape', 'a text node holds no content: this one holds some', path) if (/\r/.test(node.text)) return failure('unspellable-character', 'a text node holds a carriage return CommonMark rewrites', path) if (holdsNullCharacter(node.text)) return failure('unspellable-character', 'a text node holds a null character CommonMark replaces', path) const escaping: InlineEscaping = context.bracketed ? 'bracketed' : 'backslash' @@ -260,9 +261,9 @@ function emitEmphasis(nodes: readonly AdfNode[], spelling: string, depth: number function emitCodeSpan(nodes: readonly AdfNode[], depth: number, range: NodeRange, path: ConvertErrorPath): Result { let text = '' for (const node of nodes) { - if (node.type !== 'text' || (node.marks ?? []).length !== depth + 1) return success({ carry: range }) + if (node.type !== 'text' || nodeMarks(node).length !== depth + 1) return success({ carry: range }) if (typeof node.text !== 'string' || node.text === '') return failure('unsupported-node-shape', 'a text node holds text: this one has none', path) - if ((node.content ?? []).length > 0) return failure('unsupported-node-shape', 'a text node holds no content: this one holds some', path) + if (nodeContent(node).length > 0) return failure('unsupported-node-shape', 'a text node holds no content: this one holds some', path) text += node.text } if (/[\n\r]/.test(text)) return success({ carry: range }) @@ -278,11 +279,11 @@ function needsPadding(text: string): boolean { } function emitLink(nodes: readonly AdfNode[], mark: AdfMark, depth: number, range: NodeRange, context: InlineContext, path: ConvertErrorPath): Result { - const href = mark.attrs?.['href'] - const title = mark.attrs?.['title'] + const href = nodeAttrs(mark)['href'] + const title = nodeAttrs(mark)['title'] if (typeof href !== 'string') return success({ carry: range }) const node = nodes[0] - const bare = nodes.length === 1 && node !== undefined && node.type === 'text' && node.text === href && (node.marks ?? []).length === depth + 1 + const bare = nodes.length === 1 && node !== undefined && node.type === 'text' && node.text === href && nodeMarks(node).length === depth + 1 if (bare && title === undefined && isAutolink(href) && !holdsEntityReference(href)) return success({ segments: [syntax(`<${href}>`)] }) const destination = spellDestination(href, path) if (!destination.ok) return destination diff --git a/src/markdown/emit/pipe-table.ts b/src/markdown/emit/pipe-table.ts index c47ca57..c57e5fc 100644 --- a/src/markdown/emit/pipe-table.ts +++ b/src/markdown/emit/pipe-table.ts @@ -1,5 +1,5 @@ import type { AdfNode } from '../../adf/document.ts' -import { carriesOnly } from '../../adf/document.ts' +import { carriesOnly, nodeContent } from '../../adf/document.ts' import { spellPipeDelimiter, spellPipeRow } from '../pipe-table-syntax.ts' import { tryPipeCell } from './inline-line.ts' import type { ConvertErrorPath } from '../../result.ts' @@ -11,7 +11,7 @@ export function tryPipeTable(node: AdfNode, path: ConvertErrorPath): string | un for (const [rowIndex, row] of rows.entries()) { const cells: string[] = [] for (const [cellIndex, paragraph] of row.entries()) { - const content = paragraph.content ?? [] + const content = nodeContent(paragraph) const line = content.length === 0 ? '' : tryPipeCell(content, [...path, 'content', rowIndex, 'content', cellIndex, 'content', 0]) if (line === undefined) return undefined cells.push(line) @@ -23,12 +23,12 @@ export function tryPipeTable(node: AdfNode, path: ConvertErrorPath): string | un } function pipeRows(node: AdfNode): AdfNode[][] | undefined { - const rows = node.content ?? [] - const columns = (rows[0]?.content ?? []).length + const rows = nodeContent(node) + const columns = rows[0] === undefined ? 0 : nodeContent(rows[0]).length if (!carriesOnly(node, []) || columns === 0) return undefined const grid: AdfNode[][] = [] for (const [index, row] of rows.entries()) { - const cells = row.content ?? [] + const cells = nodeContent(row) if (row.type !== 'tableRow' || !carriesOnly(row, []) || cells.length !== columns) return undefined const wanted = index === 0 ? 'tableHeader' : 'tableCell' const paragraphs: AdfNode[] = [] @@ -43,7 +43,7 @@ function pipeRows(node: AdfNode): AdfNode[][] | undefined { } function plainParagraph(cell: AdfNode): AdfNode | undefined { - const content = cell.content ?? [] + const content = nodeContent(cell) const paragraph = content[0] if (paragraph === undefined || content.length !== 1 || paragraph.type !== 'paragraph' || !carriesOnly(paragraph, [])) return undefined return paragraph diff --git a/src/markdown/mark-spellings.ts b/src/markdown/mark-spellings.ts index e19a37c..7b4ce42 100644 --- a/src/markdown/mark-spellings.ts +++ b/src/markdown/mark-spellings.ts @@ -2,6 +2,7 @@ import type { AdfMark } from '../adf/document.ts' import type { AttributeVocabulary } from '../adf/attribute-vocabulary.ts' import type { MarkType } from '../adf/mark-attributes.ts' import { isMarkType, markAttributes } from '../adf/mark-attributes.ts' +import { nodeAttrs } from '../adf/document.ts' import { spellAttributes, spellVocabulary } from './directive-syntax.ts' import { vocabularyPairs } from '../adf/attribute-vocabulary.ts' @@ -30,6 +31,6 @@ export function markSpelling(type: string): MarkSpelling | undefined { } export function spellMarkAttributes(mark: AdfMark, vocabulary: AttributeVocabulary): string | undefined { - const pairs = vocabularyPairs(mark.attrs ?? {}, vocabulary, []) + const pairs = vocabularyPairs(nodeAttrs(mark), vocabulary, []) return pairs === undefined ? undefined : spellAttributes(spellVocabulary(pairs)) } diff --git a/src/markdown/parse/directive-nodes.ts b/src/markdown/parse/directive-nodes.ts index 7b4e54d..e5e6e83 100644 --- a/src/markdown/parse/directive-nodes.ts +++ b/src/markdown/parse/directive-nodes.ts @@ -3,7 +3,7 @@ import type { BlockDirective } from '../../adf/block-directives.ts' import type { ConvertFault } from '../../result.ts' import type { DirectiveAttributes, DirectiveValue } from '../directive-syntax.ts' import type { Elsewhere } from './directive-attributes.ts' -import { attributeNestingMessage } from '../../adf/document.ts' +import { attributeNestingMessage, nodeMarks } from '../../adf/document.ts' import { attributeValue, directiveLineEscape, inlineDirectiveEscape, spellAttributeValue, unknownDirectiveFault } from '../directive-syntax.ts' import { blockArgument } from '../block-directive-arguments.ts' import { blockDirective } from '../../adf/block-directives.ts' @@ -89,7 +89,7 @@ function blockSpellingFault(name: string): ConvertFault | undefined { function slotText(content: readonly AdfNode[]): string | undefined { if (content.length === 0) return '' const only = content.length === 1 ? content[0] : undefined - if (only?.type !== 'text' || (only.marks ?? []).length > 0 || typeof only.text !== 'string') return undefined + if (only?.type !== 'text' || nodeMarks(only).length > 0 || typeof only.text !== 'string') return undefined return only.text } diff --git a/src/markdown/parse/inline-content.ts b/src/markdown/parse/inline-content.ts index 286b6ec..2cafcf1 100644 --- a/src/markdown/parse/inline-content.ts +++ b/src/markdown/parse/inline-content.ts @@ -8,6 +8,7 @@ import { delimiterFlags, matchEmphasis, runLength } from '../emphasis-matching.t import { failure, faulted, success, type ConvertErrorPath, type Result } from '../../result.ts' import { inlineDirective } from '../../adf/inline-directives.ts' import { mergeAdjacentText } from '../../adf/editor-normal.ts' +import { nodeAttrs, nodeMarks } from '../../adf/document.ts' import { normalizeLabel, readInlineTarget, readLabel } from '../link-syntax.ts' import { readCarriedInline } from '../opaque-carry.ts' import { readDirectiveMark } from './directive-marks.ts' @@ -337,7 +338,7 @@ function imageAlt(inner: readonly Piece[], path: ConvertErrorPath): Result { - const marks = node.marks ?? [] + const marks = nodeMarks(node) return marks.some((carried) => carried.type === mark.type) ? node : { ...node, marks: [mark, ...marks] } }) } diff --git a/src/markdown/parse/markdown-to-adf.ts b/src/markdown/parse/markdown-to-adf.ts index 8f2461a..146d600 100644 --- a/src/markdown/parse/markdown-to-adf.ts +++ b/src/markdown/parse/markdown-to-adf.ts @@ -9,6 +9,7 @@ import { failure, faulted, positioned, success, type ConvertErrorPath, type Pars import { languageSlot } from '../code-language.ts' import { largestNesting } from '../../nesting.ts' import { listBreakName, listBreakSpelling } from '../list-break.ts' +import { nodeAttrs } from '../../adf/document.ts' import { parseBlocks } from './blocks.ts' import { parseInlineContent } from './inline-content.ts' import { readBlockDirectiveNode } from './directive-nodes.ts' @@ -106,7 +107,7 @@ function directiveBody(read: BlockDirectiveNode, blocks: Block[] | undefined, de function codeDirectiveNode(node: AdfNode, blocks: readonly Block[], path: ConvertErrorPath): Result { const only = blocks.length === 1 ? blocks[0] : undefined if (only?.kind !== 'code') return failure('unsupported-node-shape', `${node.type} takes one code block as its body: this body is not one`, path) - const attribute = node.attrs?.['language'] + const attribute = nodeAttrs(node)['language'] const fromFence = only.language !== '' const slot = languageSlot(fromFence ? only.language : attribute) if ((slot.kind === 'fence') !== fromFence || (fromFence && attribute !== undefined)) {