3
0
mirror of https://github.com/larvit/adf-codec.git synced 2026-11-12 11:48:29 +01:00

Spell an emphasis or link once across the nodes its mark skips, in both writers

This commit is contained in:
2026-10-31 03:22:37 +01:00
parent 4c917f5e9d
commit ea2e37d648
8 changed files with 119 additions and 12 deletions
+6
View File
@@ -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
+1 -3
View File
@@ -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:
+4 -1
View File
@@ -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
+26 -6
View File
@@ -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(
+14
View File
@@ -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<LineAttempt> {
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)
}
+56
View File
@@ -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)] }
}
@@ -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')
+4 -1
View File
@@ -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