mirror of
https://github.com/larvit/adf-codec.git
synced 2026-11-12 19:58:28 +01:00
Spell an emphasis or link once across the nodes its mark skips, in both writers
This commit is contained in:
@@ -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(
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user