diff --git a/src/conformance/property-harness.ts b/src/conformance/property-harness.ts index 0743e5a..6951572 100644 --- a/src/conformance/property-harness.ts +++ b/src/conformance/property-harness.ts @@ -11,7 +11,7 @@ import { blockNodes } from '../adf/block-nodes.ts' import { directivePrefix } from '../markdown/directive-syntax.ts' import { emptyKeys, mergeAdjacentText } from '../adf/document.ts' import { inlineNodes } from '../adf/inline-nodes.ts' -import { joinsNormally } from '../markdown/plain/editor-normal.ts' +import { joinsWhenEditorNormal } from '../markdown/adjacent-text.ts' import { markAttributes } from '../adf/mark-attributes.ts' type Positions = { block: AdfNode; inline: AdfNode } @@ -91,7 +91,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, joinsNormally))) + return fc.tuple(arbitrary, fc.nat({ max: 5 })).map(([nodes, roll]) => (roll === 0 ? nodes : mergeAdjacentText(nodes, joinsWhenEditorNormal))) } function pipeTable({ body, header }: { body: AdfNode[][]; header: AdfNode[] }): AdfNode { diff --git a/src/markdown/adjacent-text.ts b/src/markdown/adjacent-text.ts new file mode 100644 index 0000000..b063caa --- /dev/null +++ b/src/markdown/adjacent-text.ts @@ -0,0 +1,53 @@ +import type { AdfAttributes, AdfMark, AdfNode } from '../adf/document.ts' +import type { JsonValue } from '../json-value.ts' +import { identicalMark, identicalMarks, isPlainText, nodeAttrs, nodeMarks } from '../adf/document.ts' +import { spellInlineLeafDirective } from './directive-syntax.ts' + +type JsonContainer = JsonValue[] | { [key: string]: JsonValue } + +export const textBreakName = 'textBreak' + +export const textBreakSpelling = spellInlineLeafDirective(textBreakName, '') + +// The lossless reader's rule: CommonMark reads the pair back as one text run (spec/flavour.md, Inline nodes). +export function joinsWhenRead(previous: AdfNode, node: AdfNode): boolean { + return isPlainText(previous) && isPlainText(node) && identicalMarks(nodeMarks(previous), nodeMarks(node)) +} + +// The editor's rule, parting from the reader's only on shapes editor-normal ADF erases: an empty attrs, content or marks, and -0 in a mark. +export function joinsWhenEditorNormal(previous: AdfNode, node: AdfNode): boolean { + if (!joinsAsEditorText(previous) || !joinsAsEditorText(node)) return false + return identicalMarks(nodeMarks(previous).map(normalMark), nodeMarks(node).map(normalMark)) +} + +export function sameMarkWhenEditorNormal(left: AdfMark, right: AdfMark): boolean { + return identicalMark(normalMark(left), normalMark(right)) +} + +export function normalMark(mark: AdfMark): AdfMark { + const attrs = normalAttributes(nodeAttrs(mark)) + return attrs === undefined ? { type: mark.type } : { attrs, type: mark.type } +} + +export 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 joinsAsEditorText(node: AdfNode): boolean { + return node.type === 'text' && Object.keys(nodeAttrs(node)).length === 0 +} diff --git a/src/markdown/emit/inline-line.ts b/src/markdown/emit/inline-line.ts index 6513e8d..c4ce116 100644 --- a/src/markdown/emit/inline-line.ts +++ b/src/markdown/emit/inline-line.ts @@ -11,9 +11,9 @@ import { escapeUnbalanced, spellDestination } from '../commonmark/link-syntax.ts import { failure, faulted, success, type ConvertErrorPath, type Result } from '../../result.ts' import { highlightDelimiter } from '../plain/conventions.ts' import { inlineNodeModel } from '../../adf/inline-nodes.ts' +import { joinsWhenRead, textBreakSpelling } from '../adjacent-text.ts' import { largestNesting } from '../../nesting.ts' import { longestBacktickRun } from '../commonmark/backtick-runs.ts' -import { readsAsOne, textBreakSpelling } from '../text-break.ts' import { slotLineEndingFault, spellInlineDirectiveOpener, spellInlineLeafDirective } from '../directive-syntax.ts' import { spellInlineNodeAttributes } from './inline-directive-spelling.ts' import { spellTextDirective } from '../text-directive.ts' @@ -186,7 +186,7 @@ function emitRun(nodes: readonly AdfNode[], depth: number, firstIndex: number, c const runs = inlineRuns(nodes, depth, firstIndex, context.carried) const segments: InlineSegment[] = [] for (const [offset, run] of runs.entries()) { - if (partsText(runs[offset - 1], run, context.carried)) segments.push(syntax(textBreakSpelling)) + if (takesTextBreak(runs[offset - 1], run, context.carried)) segments.push(syntax(textBreakSpelling)) const runContext = { ...context, atBlockEnd: context.atBlockEnd && offset === runs.length - 1 } const emitted = run.kind === 'plain' ? emitLeaf(run.node, runContext, run.index) : emitMarkedRun(run.nodes, run.mark, depth, run.index, runContext) if (!emitted.ok) return emitted @@ -213,10 +213,10 @@ function inlineRuns(nodes: readonly AdfNode[], depth: number, firstIndex: number return runs } -function partsText(previous: InlineRun | undefined, run: InlineRun, carried: ReadonlySet): boolean { +function takesTextBreak(previous: InlineRun | undefined, run: InlineRun, carried: ReadonlySet): boolean { if (previous?.kind !== 'plain' || run.kind !== 'plain') return false if (carries(previous.node, carried, previous.index) || carries(run.node, carried, run.index)) return false - return readsAsOne(previous.node, run.node) + return joinsWhenRead(previous.node, run.node) } function nodePath(context: InlineContext, index: number): ConvertErrorPath { diff --git a/src/markdown/parse/directive-nodes.ts b/src/markdown/parse/directive-nodes.ts index 63e1e56..61c1471 100644 --- a/src/markdown/parse/directive-nodes.ts +++ b/src/markdown/parse/directive-nodes.ts @@ -14,7 +14,7 @@ import { inlineNodeModel } from '../../adf/inline-nodes.ts' import { readEmptyKeys, spellsEmpty } from '../empty-keys.ts' import { readVocabulary } from './directive-attributes.ts' import { slotLineEndingFault } from '../directive-syntax.ts' -import { textBreakName } from '../text-break.ts' +import { textBreakName } from '../adjacent-text.ts' import { textDirectiveName } from '../text-directive.ts' export type BlockDirectiveNode = { contentModel: BlockNodeModel['contentModel']; node: AdfNode } diff --git a/src/markdown/parse/inline-content.ts b/src/markdown/parse/inline-content.ts index 22d7114..1d7c0db 100644 --- a/src/markdown/parse/inline-content.ts +++ b/src/markdown/parse/inline-content.ts @@ -12,6 +12,7 @@ import { failure, faulted, success, type ConvertErrorPath, type Result } from '. import { highlightDelimiter, highlightFlanking } from '../plain/conventions.ts' import { identicalMarks, mergeAdjacentText, nodeAttrs, nodeMarks } from '../../adf/document.ts' import { inlineNodeModel } from '../../adf/inline-nodes.ts' +import { joinsWhenRead, textBreakName, textBreakSpelling } from '../adjacent-text.ts' import { noSpans, readInlineDirective } from '../directive-syntax.ts' import { normalizeLabel, readInlineTarget, readLabel } from '../commonmark/link-syntax.ts' import { openingLinkTakesDirective } from '../emit/inline-line.ts' @@ -19,7 +20,6 @@ import { readCarriedInline } from '../opaque-carry.ts' import { readDirectiveMark } from './directive-marks.ts' import { readInlineDirectiveNode } from './directive-nodes.ts' import { readTextDirective } from '../text-directive.ts' -import { readsAsOne, textBreakName, textBreakSpelling } from '../text-break.ts' export type InlineContent = { carry?: undefined; image: AdfNode; nodes?: undefined } | { carry: boolean; image?: undefined; nodes: AdfNode[] } @@ -312,7 +312,7 @@ function partText(items: readonly Inline[], scan: Scan): Result { } const previous = items[index - 1] const next = items[index + 1] - if (previous === undefined || next === undefined || !isNode(previous) || !isNode(next) || !readsAsOne(previous, next)) { + if (previous === undefined || next === undefined || !isNode(previous) || !isNode(next) || !joinsWhenRead(previous, next)) { return failure('unsupported-node-shape', `delete ${textBreakSpelling} here: it stands only between two runs of text with the same formatting, which would otherwise read as one`, scan.path) } } @@ -508,11 +508,11 @@ 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())) + return success(mergeReadText(nodes.flat())) } // A text break and a carried node are walls: the text on either side joins only its own side. -function mergeText(items: readonly Inline[]): Inline[] { +function mergeReadText(items: readonly Inline[]): Inline[] { const merged: Inline[] = [] let run: AdfNode[] = [] for (const item of items) { @@ -520,11 +520,11 @@ function mergeText(items: readonly Inline[]): Inline[] { run.push(item) continue } - for (const node of mergeAdjacentText(run, readsAsOne)) merged.push(node) + for (const node of mergeAdjacentText(run, joinsWhenRead)) merged.push(node) merged.push(item) run = [] } - for (const node of mergeAdjacentText(run, readsAsOne)) merged.push(node) + for (const node of mergeAdjacentText(run, joinsWhenRead)) merged.push(node) return merged } diff --git a/src/markdown/plain/editor-normal.ts b/src/markdown/plain/editor-normal.ts index a5ffb7e..e43a0a5 100644 --- a/src/markdown/plain/editor-normal.ts +++ b/src/markdown/plain/editor-normal.ts @@ -1,25 +1,14 @@ -import type { AdfAttributes, AdfDocument, AdfMark, AdfNode } from '../../adf/document.ts' -import type { JsonValue } from '../../json-value.ts' -import { identicalMark, identicalMarks, mergeAdjacentText, nodeAttrs, nodeContent, nodeMarks } from '../../adf/document.ts' - -type JsonContainer = JsonValue[] | { [key: string]: JsonValue } +import type { AdfDocument, AdfNode } from '../../adf/document.ts' +import { joinsWhenEditorNormal, normalAttributes, normalMark } from '../adjacent-text.ts' +import { mergeAdjacentText, nodeAttrs, nodeContent, nodeMarks } from '../../adf/document.ts' type NodeHolder = { content?: AdfNode[] } -export function sameMark(left: AdfMark, right: AdfMark): boolean { - return identicalMark(normalMark(left), normalMark(right)) -} - -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), joinsNormally) + const content = mergeAdjacentText(nodeContent(entry.source), joinsWhenEditorNormal) if (content.length === 0) continue entry.holder.content = content.map((source) => { const holder = normalNode(source) @@ -39,31 +28,3 @@ function normalNode(node: AdfNode): AdfNode { 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(nodeAttrs(node)).length === 0 -} diff --git a/src/markdown/plain/inline-reduction.ts b/src/markdown/plain/inline-reduction.ts index b2d3501..0df6095 100644 --- a/src/markdown/plain/inline-reduction.ts +++ b/src/markdown/plain/inline-reduction.ts @@ -3,7 +3,7 @@ import type { LineContainer } from '../line-container.ts' import type { MarkRun } from '../emit/line-escaping.ts' import { blockNodeModel } from '../../adf/block-nodes.ts' import { failure, success, type ConvertErrorPath, type Result } from '../../result.ts' -import { joinsNormally, sameMark } from './editor-normal.ts' +import { joinsWhenEditorNormal, sameMarkWhenEditorNormal } from '../adjacent-text.ts' import { largestNesting } from '../../nesting.ts' import { mergeAdjacentText, nodeAttrs, nodeContent, nodeMarks } from '../../adf/document.ts' import { plainLineFallback, type PlainLineFallback } from '../emit/inline-line.ts' @@ -175,11 +175,11 @@ function highlighted(leaves: readonly AdfNode[]): AdfNode[] { const marks = nodeMarks(leaf) if (marks[0]?.type === highlight) { const held = marks.slice(1) - shared = run.length === 0 ? held : shared.filter((mark) => held.some((other) => sameMark(other, mark))) + shared = run.length === 0 ? held : shared.filter((mark) => held.some((other) => sameMarkWhenEditorNormal(other, mark))) run.push(leaf) continue } - for (const held of run) spelled.push(withMarks(held, [...shared, highlightMark, ...nodeMarks(held).slice(1).filter((mark) => !shared.some((other) => sameMark(other, mark)))])) + for (const held of run) spelled.push(withMarks(held, [...shared, highlightMark, ...nodeMarks(held).slice(1).filter((mark) => !shared.some((other) => sameMarkWhenEditorNormal(other, mark)))])) run = [] spelled.push(leaf) } @@ -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, joinsNormally)) + const merged = withoutEdgeBreaks(mergeAdjacentText(current, joinsWhenEditorNormal)) let changed = false const trimmed: AdfNode[] = [] for (const [index, leaf] of merged.entries()) { @@ -251,7 +251,7 @@ function edgeDepth(marks: readonly AdfMark[], neighbour: AdfNode | undefined, wh function sameMarkAt(marks: readonly AdfMark[], others: readonly AdfMark[], index: number): boolean { const mark = marks[index] const other = others[index] - return mark !== undefined && other !== undefined && sameMark(mark, other) + return mark !== undefined && other !== undefined && sameMarkWhenEditorNormal(mark, other) } function spellableLine(leaves: AdfNode[], container: LineContainer, path: ConvertErrorPath): Result { diff --git a/src/markdown/text-break.ts b/src/markdown/text-break.ts deleted file mode 100644 index 2055539..0000000 --- a/src/markdown/text-break.ts +++ /dev/null @@ -1,12 +0,0 @@ -import type { AdfNode } from '../adf/document.ts' -import { identicalMarks, isPlainText, nodeMarks } from '../adf/document.ts' -import { spellInlineLeafDirective } from './directive-syntax.ts' - -export const textBreakName = 'textBreak' - -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 isPlainText(previous) && isPlainText(node) && identicalMarks(nodeMarks(previous), nodeMarks(node)) -}