From fdbf43398c2243ea74c24532a28ac02e73d4f3d7 Mon Sep 17 00:00:00 2001 From: Lilleman auf Larv Date: Sat, 3 Oct 2026 14:17:22 +0200 Subject: [PATCH] Keep one merge loop and one mark key: editor-normal compares normalized marks with identicalMark --- src/adf/document.ts | 18 ++++++++++++-- src/adf/editor-normal.ts | 35 ++++++---------------------- src/conformance/property-harness.ts | 5 ++-- src/markdown/emit/plain-inline.ts | 6 ++--- src/markdown/parse/inline-content.ts | 19 +++------------ src/markdown/text-break.ts | 4 ++-- 6 files changed, 34 insertions(+), 53 deletions(-) diff --git a/src/adf/document.ts b/src/adf/document.ts index 370876c..752e517 100644 --- a/src/adf/document.ts +++ b/src/adf/document.ts @@ -68,8 +68,22 @@ export function identicalMark(left: AdfMark, right: AdfMark): boolean { return marksKey([left]) === marksKey([right]) } -export function identicalMarks(left: AdfNode, right: AdfNode): boolean { - return marksKey(nodeMarks(left)) === marksKey(nodeMarks(right)) +export function identicalMarks(left: readonly AdfMark[], right: readonly AdfMark[]): boolean { + return marksKey(left) === marksKey(right) +} + +// `joins` is the caller's: a reader joins what CommonMark reads as one, editor-normal ADF what the editor would. +export function mergeAdjacentText(nodes: readonly AdfNode[], joins: (previous: AdfNode, node: AdfNode) => boolean): AdfNode[] { + const merged: AdfNode[] = [] + for (const node of nodes) { + const previous = merged[merged.length - 1] + if (previous !== undefined && joins(previous, node)) { + merged[merged.length - 1] = { ...previous, text: `${previous.text ?? ''}${node.text ?? ''}` } + continue + } + merged.push(node) + } + return merged } // Depth is the walks' business, not the shape's: the guard waves a deep document through as blocks and marks do. diff --git a/src/adf/editor-normal.ts b/src/adf/editor-normal.ts index cd37b1f..a89c0df 100644 --- a/src/adf/editor-normal.ts +++ b/src/adf/editor-normal.ts @@ -1,34 +1,25 @@ 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' +import { identicalMark, identicalMarks, mergeAdjacentText, nodeAttrs, nodeContent, nodeMarks } from './document.ts' type JsonContainer = JsonValue[] | { [key: string]: JsonValue } type NodeHolder = { content?: AdfNode[] } -export function sameMark(candidate: AdfMark, mark: AdfMark): boolean { - return markKey(candidate) === markKey(mark) +export function sameMark(left: AdfMark, right: AdfMark): boolean { + return identicalMark(normalMark(left), normalMark(right)) } -export function mergeAdjacentText(nodes: readonly AdfNode[]): AdfNode[] { - const merged: AdfNode[] = [] - for (const node of nodes) { - const previous = merged[merged.length - 1] - if (previous !== undefined && mergesText(previous) && mergesText(node) && sameMarks(previous, node)) { - merged[merged.length - 1] = { ...previous, text: `${previous.text ?? ''}${node.text ?? ''}` } - continue - } - merged.push(node) - } - return merged +export function joinsNormally(previous: AdfNode, node: AdfNode): boolean { + if (!mergesText(previous) || !mergesText(node)) return false + return identicalMarks(nodeMarks(previous).map(normalMark), nodeMarks(node).map(normalMark)) } 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)) + const content = mergeAdjacentText(nodeContent(entry.source), joinsNormally) if (content.length === 0) continue entry.holder.content = content.map((source) => { const holder = normalNode(source) @@ -76,15 +67,3 @@ function normalValue(value: JsonValue, pending: JsonContainer[]): JsonValue { function mergesText(node: AdfNode): boolean { return node.type === 'text' && Object.keys(nodeAttrs(node)).length === 0 } - -function sameMarks(previous: AdfNode, node: AdfNode): boolean { - return marksKey(nodeMarks(previous)) === marksKey(nodeMarks(node)) -} - -function marksKey(marks: readonly AdfMark[]): string { - return marks.map(markKey).join('\n') -} - -function markKey(mark: AdfMark): string { - return `${mark.type} ${serializeCanonicalJson(normalAttributes(nodeAttrs(mark)) ?? {}, 'compact')}` -} diff --git a/src/conformance/property-harness.ts b/src/conformance/property-harness.ts index cd935f8..6afc116 100644 --- a/src/conformance/property-harness.ts +++ b/src/conformance/property-harness.ts @@ -10,8 +10,9 @@ import { blockArgument } from '../markdown/block-directive.ts' import { blockNodes } from '../adf/block-nodes.ts' import { directivePrefix } from '../markdown/directive-syntax.ts' import { inlineNodes } from '../adf/inline-nodes.ts' +import { joinsNormally } from '../adf/editor-normal.ts' import { markAttributes } from '../adf/mark-attributes.ts' -import { mergeAdjacentText } from '../adf/editor-normal.ts' +import { mergeAdjacentText } from '../adf/document.ts' type Positions = { block: AdfNode; inline: AdfNode } @@ -92,7 +93,7 @@ function withoutEmptyKeys(held: T): T { } function occasionallyApart(arbitrary: Arbitrary): Arbitrary { - return fc.tuple(arbitrary, fc.nat({ max: 5 })).map(([nodes, roll]) => (roll === 0 ? nodes : mergeAdjacentText(nodes))) + return fc.tuple(arbitrary, fc.nat({ max: 5 })).map(([nodes, roll]) => (roll === 0 ? nodes : mergeAdjacentText(nodes, joinsNormally))) } function pipeTable({ body, header }: { body: AdfNode[][]; header: AdfNode[] }): AdfNode { diff --git a/src/markdown/emit/plain-inline.ts b/src/markdown/emit/plain-inline.ts index 7da6adc..f8e89b5 100644 --- a/src/markdown/emit/plain-inline.ts +++ b/src/markdown/emit/plain-inline.ts @@ -3,9 +3,9 @@ import type { LineContainer } from '../line-container.ts' import type { MarkRun } from './line-escaping.ts' import { blockNodeModel } from '../../adf/block-nodes.ts' import { failure, success, type ConvertErrorPath, type Result } from '../../result.ts' +import { joinsNormally, sameMark } from '../../adf/editor-normal.ts' import { largestNesting } from '../../nesting.ts' -import { mergeAdjacentText, sameMark } from '../../adf/editor-normal.ts' -import { nodeAttrs, nodeContent, nodeMarks } from '../../adf/document.ts' +import { mergeAdjacentText, nodeAttrs, nodeContent, nodeMarks } from '../../adf/document.ts' import { plainLineFallback, type PlainLineFallback } from './inline-line.ts' import { spellDestination, spellLinkTarget } from '../commonmark/link-syntax.ts' @@ -193,7 +193,7 @@ function withMarks(leaf: AdfNode, marks: readonly AdfMark[]): AdfNode { function trimmedEdges(leaves: readonly AdfNode[]): AdfNode[] { for (let current = leaves; ; ) { - const merged = withoutEdgeBreaks(mergeAdjacentText(current)) + const merged = withoutEdgeBreaks(mergeAdjacentText(current, joinsNormally)) let changed = false const trimmed: AdfNode[] = [] for (const [index, leaf] of merged.entries()) { diff --git a/src/markdown/parse/inline-content.ts b/src/markdown/parse/inline-content.ts index 7aeabe8..132d1ee 100644 --- a/src/markdown/parse/inline-content.ts +++ b/src/markdown/parse/inline-content.ts @@ -10,7 +10,7 @@ import { commonMarkLink, linkHref } from '../mark-spellings.ts' import { delimiterFlags, matchEmphasis, runLength } from '../commonmark/emphasis-matching.ts' import { failure, faulted, success, type ConvertErrorPath, type Result } from '../../result.ts' import { highlightDelimiter, highlightFlanking } from '../plain-conventions.ts' -import { identicalMarks, nodeAttrs, nodeMarks } from '../../adf/document.ts' +import { identicalMarks, mergeAdjacentText, nodeAttrs, nodeMarks } from '../../adf/document.ts' import { inlineNodeModel } from '../../adf/inline-nodes.ts' import { noSpans, readInlineDirective } from '../directive-syntax.ts' import { normalizeLabel, readInlineTarget, readLabel } from '../commonmark/link-syntax.ts' @@ -475,20 +475,7 @@ function resolveNodes(pieces: readonly Piece[], scan: Scan, highlights: boolean) writeUnpaired(nodes, runs, pairings) if (!markPairings(pieces, nodes, pairings)) return failure('unsupported-node-shape', carriedInMark, scan.path) markHighlights(pieces, nodes, highlights ? pairedHighlights(pieces, nodes) : []) - return success(mergeText(nodes.flat(), scan.carried)) -} - -function mergeText(nodes: readonly AdfNode[], carried: ReadonlySet): AdfNode[] { - const merged: AdfNode[] = [] - for (const node of nodes) { - const previous = merged[merged.length - 1] - if (previous !== undefined && !carried.has(previous) && !carried.has(node) && readsAsOne(previous, node)) { - merged[merged.length - 1] = { ...previous, text: `${previous.text ?? ''}${node.text ?? ''}` } - continue - } - merged.push(node) - } - return merged + return success(mergeAdjacentText(nodes.flat(), (previous, node) => !scan.carried.has(previous) && !scan.carried.has(node) && readsAsOne(previous, node))) } // Only `imageAlt` reaches the image arm: everywhere else an image amid other content is refused first. @@ -579,7 +566,7 @@ function pairedHighlights(pieces: readonly Piece[], nodes: readonly AdfNode[][]) let candidate = found[closer] while (candidate !== undefined && (!candidate.closes || candidate.position < earliest)) candidate = found[(closer += 1)] if (candidate === undefined) break - if (candidate.line !== opener.line || !identicalMarks(opener.holder, candidate.holder)) continue + if (candidate.line !== opener.line || !identicalMarks(nodeMarks(opener.holder), nodeMarks(candidate.holder))) continue paired.push({ closer: candidate.index, opener: opener.index }) resume = candidate.position + highlightDelimiter.length } diff --git a/src/markdown/text-break.ts b/src/markdown/text-break.ts index de1174c..994ae19 100644 --- a/src/markdown/text-break.ts +++ b/src/markdown/text-break.ts @@ -1,5 +1,5 @@ import type { AdfNode } from '../adf/document.ts' -import { identicalMarks } from '../adf/document.ts' +import { identicalMarks, nodeMarks } from '../adf/document.ts' import { spellInlineLeafDirective } from './directive-syntax.ts' export const textBreakName = 'textBreak' @@ -8,7 +8,7 @@ export const textBreakSpelling = spellInlineLeafDirective(textBreakName, '') // Whether CommonMark reads the pair back as one text node, where neither rides the carry (spec/flavour.md, Inline nodes). export function readsAsOne(previous: AdfNode, node: AdfNode): boolean { - return spelledAsText(previous) && spelledAsText(node) && identicalMarks(previous, node) + return spelledAsText(previous) && spelledAsText(node) && identicalMarks(nodeMarks(previous), nodeMarks(node)) } function spelledAsText(node: AdfNode): boolean {