From 6a8d4871282a8414f5a24ece6cf8fa1e42d1e1ac Mon Sep 17 00:00:00 2001 From: Lilleman auf Larv Date: Sat, 31 Oct 2026 03:22:37 +0100 Subject: [PATCH] Spell an emphasis or link once across the nodes its mark skips, in both writers --- CHANGELOG.md | 6 ++ MIGRATION.md | 4 +- spec/flavour.md | 5 +- src/markdown/emit/adf-to-markdown.test.ts | 32 +++++++++-- src/markdown/emit/inline-line.ts | 14 +++++ src/markdown/emit/skipped-nodes.ts | 56 +++++++++++++++++++ .../portable/adf-to-portable-markdown.test.ts | 9 ++- src/markdown/portable/inline-reduction.ts | 5 +- 8 files changed, 119 insertions(+), 12 deletions(-) create mode 100644 src/markdown/emit/skipped-nodes.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 7776433..23e1ff6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -33,6 +33,12 @@ `!adf:border[x]{color="#091e42" size=2}` is `unsupported-node-shape`, and only a `mediaInline` takes `border`. `adfToLosslessMarkdown` writes text holding `border` as `!adf:carry{json="…"}`. `MIGRATION.md` says how to convert stored markdown. +- `adfToLosslessMarkdown` and `adfToPortableMarkdown` write an emphasis or a link once across the + hard breaks between two texts it marks: `_One\` then `Two_`, where they wrote `_One_\` then + `_Two_`. They write an emphasis once across code spans, as ``**Run `npm ci` first**``, and + `adfToLosslessMarkdown` also across mentions, emoji, dates, statuses, inline cards and inline + media, as `**Ping !adf:mention[@A]{id=x} now**`, and a link across those but inline media. Each + reads back as before; a line that would read back otherwise is written as before. - Every reader keeps the URL and title of an image inside a link's text, where it dropped them: after the link comes `(image)`, which links to the image, so `[look at ![the moon](moon.png "Full") tonight](/moon)` reads as diff --git a/MIGRATION.md b/MIGRATION.md index c6bf824..55907f2 100644 --- a/MIGRATION.md +++ b/MIGRATION.md @@ -14,9 +14,7 @@ also skips every other node Atlassian's schema withholds the mark from: `0.3.0`'s `adfToLosslessMarkdown` writes a node holding a withheld mark as `!adf:carry{json="…"}`, where `0.2.0` wrote these spellings. `0.3.0` reads markdown stored from such a document without the -mark, and refuses it where the mark wrapped only those nodes. Written back, a space beside the node -inside the mark's spelling becomes `!adf:text{text=" "}`: ``**Run `npm ci` first**`` writes back as -``**Run!adf:text{text=" "}**`npm ci`**!adf:text{text=" "}first**``. Keep the mark only in markdown +mark, and refuses it where the mark wrapped only those nodes. Keep the mark only in markdown written from ADF that held it: Atlassian's schema withholds it, so a site may refuse the document. To keep it, read the markdown with `0.2.0` and write it with `0.3.0`, installed side by side: diff --git a/spec/flavour.md b/spec/flavour.md index a1474bd..f412ac1 100644 --- a/spec/flavour.md +++ b/spec/flavour.md @@ -564,7 +564,10 @@ marks array in order, outermost first: `_!adf:underline[x]_` gives marks `[em, u the array, not a set — and opens each spelling once over the longest run of adjacent inline nodes carrying an identical mark, attributes included, at that depth: `attrs: {}` differs from no `attrs`, and a directive spells it `{attrs=empty}`. A run breaks at every node the emitter carries, so no -emitted carry sits inside a mark spelling. +emitted carry sits inside a mark spelling. An emphasis or link run also spans the nodes between two +it marks that take no mark of its type, where the line reads back so: `_One\` then `Two_` is one +`em` over two texts and the hard break between, ``**Run `npm ci` first**`` one `strong` around +plain code. `adfToPortableMarkdown` spans hard breaks and code spans the same way. An inline node whose marks no nesting spells — a node holding a mark Atlassian's schema withholds from it, a mark type the first list above does not hold, an attrs key its spelling does not list, a value that is not the diff --git a/src/markdown/emit/adf-to-markdown.test.ts b/src/markdown/emit/adf-to-markdown.test.ts index f0cf278..ece6199 100644 --- a/src/markdown/emit/adf-to-markdown.test.ts +++ b/src/markdown/emit/adf-to-markdown.test.ts @@ -907,21 +907,41 @@ test('spells the directive marks around the longest run they cover', () => { assert.equal(emitted(marked('a', underline), { type: 'hardBreak' }, marked('b', underline)), '!adf:underline[a]\\\n!adf:underline[b]\n') }) -test('closes a mark run at the hard break between two nodes holding the mark', () => { +test('spells an emphasis or link run once across the hard breaks between two nodes holding the mark', () => { const em: AdfMark = { type: 'em' } const link: AdfMark = { attrs: { href: '/u' }, type: 'link' } const marked = (text: string, ...marks: AdfMark[]): AdfNode => ({ marks, text, type: 'text' }) const hardBreak: AdfNode = { type: 'hardBreak' } - assert.equal(readsBack(document(paragraph(marked('a', em), hardBreak, marked('b', em)))), '_a_\\\n_b_\n') - assert.equal(readsBack(document(paragraph(marked('a', link), hardBreak, marked('b', link)))), '[a](/u)\\\n[b](/u)\n') - assert.equal(readsBack(document(paragraph(marked('a', em, link), hardBreak, marked('b', em)))), '_[a](/u)_\\\n_b_\n') - assert.equal(readsBack(document(paragraph(marked('a', em), hardBreak, hardBreak, marked('b', em)))), '_a_\\\n\\\n_b_\n') + assert.equal(readsBack(document(paragraph(marked('One', em), hardBreak, marked('Two', em)))), '_One\\\nTwo_\n') + assert.equal(readsBack(document(paragraph(marked('a', link), hardBreak, marked('b', link)))), '[a\\\nb](/u)\n') + assert.equal(readsBack(document(paragraph(marked('a', em, link), hardBreak, marked('b', em)))), '_[a](/u)\\\nb_\n') + assert.equal(readsBack(document(paragraph(marked('a', em), hardBreak, hardBreak, marked('b', em)))), '_a\\\n\\\nb_\n') + assert.equal(readsBack(document(paragraph(marked('a', em), hardBreak, { text: 'b', type: 'text' }))), '_a_\\\nb\n') const directive: AdfMark = { attrs: { href: '/u', id: 'x' }, type: 'link' } - assert.equal(readsBack(document(paragraph(marked('a', directive), hardBreak, marked('b', directive)))), '!adf:link[a]{href="/u" id=x}\\\n!adf:link[b]{href="/u" id=x}\n') + assert.equal(readsBack(document(paragraph(marked('a', directive), hardBreak, marked('b', directive)))), '!adf:link[a!adf:hardBreak{}b]{href="/u" id=x}\n') readsBack(document(paragraph(marked(']: /x', link, { type: 'code' }), hardBreak, marked('b', link)))) assert.equal(readsBack(document(paragraph({ text: 'x', type: 'text' }, marked('.', em), hardBreak, marked('b', em)))), 'x!adf:carry{json="{\\"marks\\":[{\\"type\\":\\"em\\"}],\\"text\\":\\".\\",\\"type\\":\\"text\\"}"}\\\n_b_\n') }) +test('spells an emphasis run once across the code spans, inline nodes and inline media between two nodes holding the mark', () => { + const strong: AdfMark = { type: 'strong' } + const marked = (text: string, ...marks: AdfMark[]): AdfNode => ({ marks, text, type: 'text' }) + const mention: AdfNode = { attrs: { id: 'x', text: '@A' }, type: 'mention' } + assert.equal(readsBack(document(paragraph(marked('Ping ', strong), mention, marked(' now', strong)))), '**Ping !adf:mention[@A]{id=x} now**\n') + assert.equal(readsBack(document(paragraph(marked('Run ', strong), marked('npm ci', { type: 'code' }), marked(' first', strong)))), '**Run `npm ci` first**\n') + assert.equal(readsBack(document(paragraph(marked('a ', strong), { attrs: { timestamp: '0' }, type: 'date' }, marked(' b', strong)))), '**a !adf:date{timestamp=0} b**\n') + assert.equal(readsBack(document(paragraph(marked('a ', strong), { attrs: { id: 'm' }, type: 'mediaInline' }, marked(' b', strong)))), '**a !adf:mediaInline{id=m} b**\n') + assert.equal(readsBack(document(paragraph(marked('a ', strong), marked('c', { type: 'code' }), mention, { type: 'hardBreak' }, marked(' b', strong)))), '**a `c`!adf:mention[@A]{id=x}\\\n!adf:text{text=" "}b**\n') +}) + +test('spells a link run once across the inline nodes between two nodes holding it, and parts it at code and inline media', () => { + const link: AdfMark = { attrs: { href: '/u' }, type: 'link' } + const marked = (text: string, ...marks: AdfMark[]): AdfNode => ({ marks, text, type: 'text' }) + assert.equal(readsBack(document(paragraph(marked('a ', link), { attrs: { id: 'x', text: '@A' }, type: 'mention' }, marked(' b', link)))), '[a !adf:mention[@A]{id=x} b](/u)\n') + assert.equal(readsBack(document(paragraph(marked('a', link), { attrs: { url: 'https://c/' }, type: 'inlineCard' }, marked('b', link)))), '[a!adf:inlineCard{url="https://c/"}b](/u)\n') + assert.equal(readsBack(document(paragraph(marked('a', link), marked('c', { type: 'code' }), marked('b', link)))), '[a](/u)`c`[b](/u)\n') +}) + test('carries a hard break holding marks, which Atlassian\'s schema withholds', () => { const link: AdfMark = { attrs: { href: 'https://example.com/' }, type: 'link' } assert.equal( diff --git a/src/markdown/emit/inline-line.ts b/src/markdown/emit/inline-line.ts index 5d7abe4..f1e3121 100644 --- a/src/markdown/emit/inline-line.ts +++ b/src/markdown/emit/inline-line.ts @@ -18,6 +18,7 @@ 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' @@ -68,6 +69,7 @@ export function portableLineFallback(nodes: readonly AdfNode[], container: LineC const emission = lineSegments(nodes, container, path, fallbacks) if (!emission.ok) return emission if (fallbacks.carried.size > 0 || emission.value.carries !== undefined) return failure('unsupported-node-shape', 'an inline node on a portable line has no spelling but the carry', path) + if (spannedLine(nodes, container, path, fallbacks) !== undefined) return success(undefined) const verdict = lineVerdict(emission.value.segments, container, 'portable', { kind: 'drop', nodes }) return success(verdict.kind === 'line' ? undefined : verdict) } @@ -107,12 +109,24 @@ function emitLine(nodes: readonly AdfNode[], container: LineContainer, path: Con } export function linePass(nodes: readonly AdfNode[], container: LineContainer, path: ConvertErrorPath, fallbacks: LineFallbacks): Result { + const spanned = spannedLine(nodes, container, path, fallbacks) + if (spanned !== undefined) return success(spanned) const emission = lineSegments(nodes, container, path, fallbacks) if (!emission.ok) return emission const { carries, segments } = emission.value return success(segments === undefined ? { fallback: carries } : attemptLine(segments, container, fallbacks.flavour)) } +// spec/flavour.md, Marks: a run spans the nodes its mark skips where the line reads back so; the line parted at them is the one whose fallbacks a pass takes. +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 emission = lineSegments(spanned, container, path, fallbacks) + if (!emission.ok || emission.value.segments === undefined) return undefined + const attempt = attemptLine(emission.value.segments, container, fallbacks.flavour) + return attempt.fallback === undefined ? attempt : undefined +} + function carryEveryEmphasis(fallbacks: LineFallbacks, nodes: readonly AdfNode[]): void { for (const [index, node] of nodes.entries()) if (nodeMarks(node).some(isEmphasis)) fallbacks.carried.add(index) } diff --git a/src/markdown/emit/skipped-nodes.ts b/src/markdown/emit/skipped-nodes.ts new file mode 100644 index 0000000..4f5251d --- /dev/null +++ b/src/markdown/emit/skipped-nodes.ts @@ -0,0 +1,56 @@ +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 + +// The nodes, each that an emphasis or link run skips between two nodes it marks holding the run's mark at the run's depth, or `nodes` itself where no run skips one. +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, then lends each run's mark to the nodes it skips; returns whether it lent any. +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) +} + +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/adf-to-portable-markdown.test.ts b/src/markdown/portable/adf-to-portable-markdown.test.ts index 5deef9c..85def69 100644 --- a/src/markdown/portable/adf-to-portable-markdown.test.ts +++ b/src/markdown/portable/adf-to-portable-markdown.test.ts @@ -422,11 +422,18 @@ test('refuses a portable line holding a node only the carry spells, at the line' } }) +test('spells an emphasis once across the code spans and hard breaks between two leaves holding it, and a link across the hard breaks', () => { + assert.equal(portable(paragraph(text('Run ', strong), text('npm ci', code), text(' first', strong))), '**Run `npm ci` first**\n') + assert.equal(portable(paragraph(text('One', em), { type: 'hardBreak' }, text('Two', em))), '_One\\\nTwo_\n') + assert.equal(portable(paragraph(text('a', link('/u')), { type: 'hardBreak' }, text('b', link('/u')))), '[a\\\nb](/u)\n') + assert.equal(portable(paragraph(text('Ping ', strong), { attrs: { id: 'x', text: '@A' }, type: 'mention' }, text(' now', strong))), '**Ping** @A **now**\n') +}) + test('breaks a line at a newline and trims whitespace at every edge CommonMark strips', () => { assert.equal(portable(paragraph(text(' \n a \n b\n'))), 'a\\\nb\n') assert.equal(portable(paragraph({ type: 'hardBreak' }, text('a'), { attrs: { text: '\n' }, type: 'hardBreak' }, text('b'), { type: 'hardBreak' })), 'a\\\nb\n') assert.equal(portable(paragraph(text('a'), text(' b ', strong), text('c'))), 'a **b** c\n') - assert.equal(portable(paragraph(text(' '), text('b', strong), text(' \n'), text('c', strong), text(' '))), '**b**\\\n**c**\n') + assert.equal(portable(paragraph(text(' '), text('b', strong), text(' \n'), text('c', strong), text(' '))), '**b\\\nc**\n') assert.equal(portable(paragraph(text('x'), text('.', em), { type: 'hardBreak' }, text('b', em))), 'x.\\\n_b_\n') assert.equal(portable(paragraph(text('a'), text(' b ', em, strong), text(' ', em), text('c', em))), 'a _**b** c_\n') assert.equal(portable(paragraph(text(' x ', code))), '` x `\n') diff --git a/src/markdown/portable/inline-reduction.ts b/src/markdown/portable/inline-reduction.ts index e279af5..ea7939e 100644 --- a/src/markdown/portable/inline-reduction.ts +++ b/src/markdown/portable/inline-reduction.ts @@ -10,6 +10,7 @@ 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 { spellDestination } from '../commonmark/link-syntax.ts' const highlight = 'backgroundColor' @@ -165,10 +166,12 @@ 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 a run spans keeps the run's mark, where the line written spanned reads it. + const spanned = spanSkippedNodes(merged, () => false) let changed = false const trimmed: AdfNode[] = [] for (const [index, leaf] of merged.entries()) { - const edges = leafEdges(leaf, merged[index - 1], merged[index + 1]) + const edges = leafEdges(leaf, spanned[index - 1], spanned[index + 1]) if (edges === undefined) { trimmed.push(leaf) continue