From e473fa92d03328d7f69f22af5215ea0b991994a8 Mon Sep 17 00:00:00 2001 From: Lilleman auf Larv Date: Sat, 31 Oct 2026 04:32:55 +0100 Subject: [PATCH] Span a run inside inlineRuns, so runs group in one place --- src/markdown/emit/inline-line.ts | 86 +++++++++++++++++------ src/markdown/emit/skipped-nodes.ts | 61 ---------------- src/markdown/portable/inline-reduction.ts | 7 +- 3 files changed, 68 insertions(+), 86 deletions(-) delete mode 100644 src/markdown/emit/skipped-nodes.ts diff --git a/src/markdown/emit/inline-line.ts b/src/markdown/emit/inline-line.ts index 9f48ddf..40b10c7 100644 --- a/src/markdown/emit/inline-line.ts +++ b/src/markdown/emit/inline-line.ts @@ -6,19 +6,18 @@ import type { RunFallback } from './unspellable-runs.ts' import type { WrittenFlavour } from '../flavour.ts' import { assembleInlineLine, isSyntax, type InlineEscaping, type InlineSegment, type MarkRun, type NodeRange } from './line-escaping.ts' import { carriedInline } from '../opaque-carry.ts' -import { commonMarkLink, insideRun, isEmphasis, linkHref, markSpelling, spellMarkAttributes, type RunNode } from '../mark-spellings.ts' +import { commonMarkLink, isEmphasis, linkHref, markSpelling, spellMarkAttributes } from '../mark-spellings.ts' +import { directivePrefix, slotFault, spellInlineDirectiveOpener, spellInlineLeafDirective } from '../directive/syntax.ts' import { escapeUnbalanced, spellDestination, spellLinkTarget } from '../commonmark/link-syntax.ts' import { failure, success, type ConvertErrorPath, type Result } from '../../result.ts' import { flavourClaims } from '../flavour.ts' import { highlightDelimiter } from '../inline-tokens.ts' import { holdsNullCharacter, trimTrailingSpace } from '../commonmark/grammar.ts' -import { inlineNodeModel, takenMarkTypes } from '../../adf/inline-nodes.ts' -import { isSpellableText, nodeAttrs, nodeContent, nodeMarks } from '../../adf/document.ts' +import { identicalMark, isSpellableText, nodeAttrs, nodeContent, nodeMarks } from '../../adf/document.ts' +import { inlineNodeModel, takenMarkTypes, takesMark } 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 { slotFault, spellInlineDirectiveOpener, spellInlineLeafDirective } from '../directive/syntax.ts' -import { spanSkippedNodes } from './skipped-nodes.ts' import { spellInlineNodeAttributes } from '../directive/inline-spelling.ts' import { spellTextDirective } from '../directive/text.ts' @@ -118,12 +117,12 @@ export function linePass(nodes: readonly AdfNode[], container: LineContainer, pa // spec/flavour.md, Marks: a pass keeps the spanned line only where it needs no fallback; otherwise it writes the parted line and takes that line's fallbacks. function spannedLine(nodes: readonly AdfNode[], container: LineContainer, path: ConvertErrorPath, fallbacks: LineFallbacks): LineAttempt | undefined { - const spanned = spanSkippedNodes(nodes, (node, index) => carries(node, fallbacks.carried, index)) - if (spanned === nodes) return undefined + const spanned = spannedNodes(nodes, fallbacks.carried) + if (spanned === undefined) return undefined const emission = lineSegments(spanned, container, path, fallbacks) if (!emission.ok || emission.value.segments === undefined) return undefined - // A span that leaves whitespace at a line edge spells it `!adf:text`, which portable markdown lacks. - if (fallbacks.flavour === 'portable' && emission.value.segments.some((segment) => segment.escaping === 'none' && segment.text.startsWith('!adf:'))) return undefined + // A span can leave whitespace at a line edge, which only `!adf:text` spells. + if (!flavourClaims[fallbacks.flavour].directives && emission.value.segments.some((segment) => segment.escaping === 'none' && segment.text.startsWith(directivePrefix))) return undefined const attempt = attemptLine(emission.value.segments, container, fallbacks.flavour) return attempt.fallback === undefined ? attempt : undefined } @@ -229,7 +228,7 @@ function emitRun(nodes: readonly RunNode[], depth: number, firstIndex: number, c if (depth > largestNesting) { return failure('unsupported-nesting-depth', `the marks nest deeper than the ${largestNesting} levels the emitter carries`, context.path) } - const runs = inlineRuns(nodes, firstIndex, context.carried) + const runs = inlineRuns(nodes, depth, firstIndex, context.carried, false) const carries: NodeRange[] = [] const segments: InlineSegment[] = [] for (const [offset, run] of runs.entries()) { @@ -244,25 +243,70 @@ function emitRun(nodes: readonly RunNode[], depth: number, firstIndex: number, c return success(carry === undefined ? { segments } : { carries: [carry, ...others] }) } -function inlineRuns(nodes: readonly RunNode[], firstIndex: number, carried: ReadonlySet): InlineRun[] { +// Where `spans`, an emphasis or link run also takes in the nodes between two of its own that cannot hold its mark, lending them the mark. +function inlineRuns(nodes: readonly AdfNode[], depth: number, firstIndex: number, carried: ReadonlySet, spans: boolean): InlineRun[] { const runs: InlineRun[] = [] - for (const [offset, runNode] of nodes.entries()) { + let spanEnd = 0 + for (const [offset, node] of nodes.entries()) { const index = firstIndex + offset - const { node, outer } = runNode - // A carried node spells its marks inside the carry. - const mark = carries(node, carried, index) ? undefined : nodeMarks(node)[outer] - if (mark === undefined) { - runs.push({ index, kind: 'plain', node }) + const previous = runs[runs.length - 1] + if (offset < spanEnd && previous?.kind === 'marked') { + previous.nodes.push(withMark(node, previous.mark, depth)) continue } - const previous = runs[runs.length - 1] - const inside = previous?.kind === 'marked' ? insideRun(runNode, previous.mark) : undefined - if (previous?.kind === 'marked' && inside !== undefined) previous.nodes.push(inside) - else runs.push({ index, kind: 'marked', mark, nodes: [{ node, outer: outer + 1 }] }) + const mark = heldMark(node, index, depth, carried) + if (mark === undefined) runs.push({ index, kind: 'plain', node }) + else if (previous?.kind === 'marked' && identicalMark(previous.mark, mark)) previous.nodes.push(node) + else runs.push({ index, kind: 'marked', mark, nodes: [node] }) + if (spans && mark !== undefined) spanEnd = spannedTo(nodes, offset + 1, firstIndex, depth, mark, carried) } return runs } +// `depth` indexes each node's marks, outermost first; a carried node spells its marks inside the carry. +function heldMark(node: AdfNode, index: number, depth: number, carried: ReadonlySet): AdfMark | undefined { + return carries(node, carried, index) ? undefined : nodeMarks(node)[depth] +} + +// The offset where the nodes from `start` that a run of `mark` spans end: `start` itself unless a node holding the mark follows them. +function spannedTo(nodes: readonly AdfNode[], start: number, firstIndex: number, depth: number, mark: AdfMark, carried: ReadonlySet): number { + let end = start + while (spansNode(nodes[end], firstIndex + end, mark, carried)) end += 1 + const next = nodes[end] + const held = next === undefined ? undefined : heldMark(next, firstIndex + end, depth, carried) + return held !== undefined && identicalMark(held, mark) ? end : start +} + +// spec/flavour.md, Marks: the readers give no node a mark Atlassian's schema withholds from it, and a lent mark would erase the `marks=empty` of an empty marks array. +function spansNode(node: AdfNode | undefined, index: number, mark: AdfMark, carried: ReadonlySet): boolean { + const kind = markSpelling(mark.type)?.kind + return node !== undefined && (kind === 'emphasis' || kind === 'link') && !carries(node, carried, index) && !takesMark(node, mark.type) && node.marks?.length !== 0 +} + +function withMark(node: AdfNode, mark: AdfMark, depth: number): AdfNode { + const marks = nodeMarks(node) + return { ...node, marks: [...marks.slice(0, depth), mark, ...marks.slice(depth)] } +} + +// The nodes as the spanned line writes them, `undefined` where no run spans a node. +export function spannedNodes(nodes: readonly AdfNode[], carried: ReadonlySet): AdfNode[] | undefined { + const spanned: AdfNode[] = [] + lendMarks(nodes, 0, 0, carried, spanned) + return spanned.some((node, index) => node !== nodes[index]) ? spanned : undefined +} + +function lendMarks(nodes: readonly AdfNode[], depth: number, firstIndex: number, carried: ReadonlySet, spanned: AdfNode[]): void { + // Past this depth the spanned line is refused as the parted one is. + if (depth > largestNesting) { + for (const node of nodes) spanned.push(node) + return + } + for (const run of inlineRuns(nodes, depth, firstIndex, carried, true)) { + if (run.kind === 'plain') spanned.push(run.node) + else lendMarks(run.nodes, depth + 1, run.index, carried, spanned) + } +} + 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 diff --git a/src/markdown/emit/skipped-nodes.ts b/src/markdown/emit/skipped-nodes.ts deleted file mode 100644 index 5663abc..0000000 --- a/src/markdown/emit/skipped-nodes.ts +++ /dev/null @@ -1,61 +0,0 @@ -import type { AdfMark, AdfNode } from '../../adf/document.ts' -import { identicalMark, nodeMarks } from '../../adf/document.ts' -import { largestNesting } from '../../nesting.ts' -import { markSpelling } from '../mark-spellings.ts' -import { takesMark } from '../../adf/inline-nodes.ts' - -type Carries = (node: AdfNode, index: number) => boolean - -// Lends each emphasis or link run's mark to the nodes inside the run that cannot hold it; returns `nodes` itself where no run skips a node. -export function spanSkippedNodes(nodes: readonly AdfNode[], carries: Carries): readonly AdfNode[] { - const spanned = [...nodes] - return spanRuns(spanned, 0, spanned.length, 0, carries) ? spanned : nodes -} - -// Groups the runs as `inlineRuns` does. -function spanRuns(spanned: AdfNode[], first: number, end: number, depth: number, carries: Carries): boolean { - if (depth > largestNesting) return false - let lent = false - for (let start = first; start < end; ) { - const mark = heldMark(spanned, start, depth, carries) - if (mark === undefined) { - start += 1 - continue - } - let last = start - for (;;) { - let next = last + 1 - while (next < end && skipsMark(spanned, next, mark, carries)) next += 1 - const held = next < end ? heldMark(spanned, next, depth, carries) : undefined - if (held === undefined || !identicalMark(held, mark)) break - for (const [offset, node] of spanned.slice(last + 1, next).entries()) spanned[last + 1 + offset] = withMark(node, mark, depth) - lent ||= next > last + 1 - last = next - } - lent = spanRuns(spanned, start, last + 1, depth + 1, carries) || lent - start = last + 1 - } - return lent -} - -function heldMark(spanned: readonly AdfNode[], index: number, depth: number, carries: Carries): AdfMark | undefined { - const node = spanned[index] - return node === undefined || carries(node, index) ? undefined : nodeMarks(node)[depth] -} - -// spec/flavour.md, Marks: the readers give no node a mark Atlassian's schema withholds from it. -function skipsMark(spanned: readonly AdfNode[], index: number, mark: AdfMark, carries: Carries): boolean { - const node = spanned[index] - const kind = markSpelling(mark.type)?.kind - return node !== undefined && (kind === 'emphasis' || kind === 'link') && !carries(node, index) && !takesMark(node, mark.type) && !holdsEmptyMarks(node) -} - -// An empty marks array is spelled `marks=empty`, which a lent mark would erase. -function holdsEmptyMarks(node: AdfNode): boolean { - return Array.isArray(node.marks) && node.marks.length === 0 -} - -function withMark(node: AdfNode, mark: AdfMark, depth: number): AdfNode { - const marks = nodeMarks(node) - return { ...node, marks: [...marks.slice(0, depth), mark, ...marks.slice(depth)] } -} diff --git a/src/markdown/portable/inline-reduction.ts b/src/markdown/portable/inline-reduction.ts index ad92bd4..47d6935 100644 --- a/src/markdown/portable/inline-reduction.ts +++ b/src/markdown/portable/inline-reduction.ts @@ -9,8 +9,7 @@ import { insideRun, isEmphasis, type RunNode } from '../mark-spellings.ts' import { joinsWhenEditorNormal } from './editor-normal.ts' import { largestNesting } from '../../nesting.ts' import { partsShownText, referenceName, shownImage, shownLink, shownText, shownUrl } from '../../adf/shown-text.ts' -import { portableLineFallback, readBackPasses, type PortableLineFallback } from '../emit/inline-line.ts' -import { spanSkippedNodes } from '../emit/skipped-nodes.ts' +import { portableLineFallback, readBackPasses, spannedNodes, type PortableLineFallback } from '../emit/inline-line.ts' import { spellDestination } from '../commonmark/link-syntax.ts' const highlight = 'backgroundColor' @@ -166,8 +165,8 @@ export function trimmedEdges(leaves: readonly AdfNode[]): AdfNode[] { // Terminates: a pass that changes a leaf drops whitespace from the leaf's edge or takes a mark off that whitespace, and merging adds no whitespace or mark. for (let current = leaves; ; ) { const merged = withoutEdgeBreaks(mergeAdjacentText(current, joinsWhenEditorNormal)) - // Whitespace beside a code span inside a run keeps the run's mark, since the writer spans the run across the code. - const spanned = spanSkippedNodes(merged, () => false) + // Judged beside the spanned line's nodes, so whitespace beside a code span inside a run keeps the run's mark. + const spanned = spannedNodes(merged, new Set()) ?? merged let changed = false const trimmed: AdfNode[] = [] for (const [index, leaf] of merged.entries()) {