diff --git a/CHANGELOG.md b/CHANGELOG.md index 6a54bfd..d0cd693 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -75,8 +75,7 @@ whose text is only hard breaks reads as its destination linked, followed by those breaks, and `losslessMarkdownToAdf` refuses a directive mark wrapping only hard breaks as `unsupported-node-shape`. `adfToLosslessMarkdown` writes a hard break holding marks as - `!adf:carry{json="…"}`, and spells an emphasis or link across the hard breaks inside it once, - `_a\` then `b_`, where `adfToMarkdown` closed and reopened it. See `MIGRATION.md`. + `!adf:carry{json="…"}`. See `MIGRATION.md`. - Fix a cyclic document, in which an object holds itself: `adfToLosslessMarkdown` and `adfToPortableMarkdown` refuse it as `not-an-adf-document` and `isAdfDocument` returns `false`, where all three hung. diff --git a/spec/flavour.md b/spec/flavour.md index 2a4ef24..12bdbe7 100644 --- a/spec/flavour.md +++ b/spec/flavour.md @@ -540,9 +540,8 @@ gives marks `[em, underline]`, `!adf:underline[_x_]` the reverse. `adfToLossless the order the array holds rather than sorting it — `docs/decisions.md` §Equality is deep restores 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}`. An emphasis or link spelling also spans the hard breaks -between two nodes of its run. A run breaks at every node the emitter carries, so no emitted carry -sits inside a mark spelling. +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. An inline node whose marks no nesting spells — a hard break holding any mark, a mark type not listed here, an attrs key its spelling does not list, a value that is not the spelling's type, an attribute diff --git a/src/markdown/emit/adf-to-markdown.test.ts b/src/markdown/emit/adf-to-markdown.test.ts index 64780f0..2b85b86 100644 --- a/src/markdown/emit/adf-to-markdown.test.ts +++ b/src/markdown/emit/adf-to-markdown.test.ts @@ -713,17 +713,17 @@ 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('spells a mark run across the hard break between two nodes holding the mark', () => { +test('closes a mark run at the hard break 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\\\nb_\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, 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') 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!adf:hardBreak{}b]{href="/u" id=x}\n') + 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') 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') }) diff --git a/src/markdown/emit/inline-line.ts b/src/markdown/emit/inline-line.ts index d8e07e5..868d0b4 100644 --- a/src/markdown/emit/inline-line.ts +++ b/src/markdown/emit/inline-line.ts @@ -27,7 +27,6 @@ type InlineContext = { bracketed: boolean carried: ReadonlySet flavour: WrittenFlavour - joinsBreaks: boolean openingLinkAsDirective: boolean path: ConvertErrorPath spansLines: boolean @@ -37,7 +36,7 @@ type InlineRun = { index: number; kind: 'marked'; mark: AdfMark; nodes: AdfNode[ type LineAttempt = { fallback: NodeRange | 'opening-link'; line?: undefined } | { fallback?: undefined; line: string } -type LineFallbacks = { carried: Set; flavour: WrittenFlavour; joinsBreaks: boolean; openingLinkAsDirective: boolean } +type LineFallbacks = { carried: Set; flavour: WrittenFlavour; openingLinkAsDirective: boolean } export type PortableLineFallback = { kind: 'opening-link' } | { kind: 'unspellable-run'; runs: [MarkRun, ...MarkRun[]] } @@ -54,8 +53,7 @@ export function openingLinkTakesDirective(nodes: readonly AdfNode[], path: Conve } export function portableLineFallback(nodes: readonly AdfNode[], container: LineContainer, path: ConvertErrorPath): Result { - // Unjoined: the spelling `emitLine` falls back to where the joined one fails. - const emission = lineSegments(nodes, container, path, { carried: new Set(), flavour: 'portable', joinsBreaks: false, openingLinkAsDirective: false }) + const emission = lineSegments(nodes, container, path, { carried: new Set(), flavour: 'portable', openingLinkAsDirective: false }) if (!emission.ok) return emission if (emission.value.carry !== undefined) return failure('unsupported-node-shape', 'an inline node on a portable line has no spelling but the carry', path) const verdict = lineVerdict(emission.value.segments, container, 'portable') @@ -78,7 +76,7 @@ export function tryImageLine(alt: string | undefined, href: string): string | un } function emitLine(nodes: readonly AdfNode[], container: LineContainer, path: ConvertErrorPath, flavour: WrittenFlavour): Result { - const fallbacks: LineFallbacks = { carried: new Set(), flavour, joinsBreaks: nodes.some((node) => node.type === 'hardBreak'), openingLinkAsDirective: false } + const fallbacks: LineFallbacks = { carried: new Set(), flavour, openingLinkAsDirective: false } // Terminates because takeFallback refuses a pass that took no new fallback. for (;;) { const emission = lineSegments(nodes, container, path, fallbacks) @@ -97,12 +95,7 @@ function emitLine(nodes: readonly AdfNode[], container: LineContainer, path: Con } } -// A line's first fallback stops runs joining across hard breaks, so no fallback takes a break `joinedBreaks` joined. function takeFallback(fallbacks: LineFallbacks, fallback: NodeRange | 'opening-link', path: ConvertErrorPath): Result { - if (fallbacks.joinsBreaks) { - fallbacks.joinsBreaks = false - return success(null) - } if (fallback === 'opening-link') { if (fallbacks.openingLinkAsDirective) return failure('unsupported-node-shape', 'an opening link spelled as a directive still reads as a link definition, so the line has no spelling left', path) fallbacks.openingLinkAsDirective = true @@ -184,7 +177,7 @@ function emitRun(nodes: readonly AdfNode[], 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, depth, firstIndex, context) + const runs = inlineRuns(nodes, depth, firstIndex, context.carried) const segments: InlineSegment[] = [] for (const [offset, run] of runs.entries()) { if (takesTextBreak(runs[offset - 1], run, context.carried)) segments.push(syntax(textBreakSpelling)) @@ -197,47 +190,23 @@ function emitRun(nodes: readonly AdfNode[], depth: number, firstIndex: number, c return success({ segments }) } -function inlineRuns(nodes: readonly AdfNode[], depth: number, firstIndex: number, context: InlineContext): InlineRun[] { +function inlineRuns(nodes: readonly AdfNode[], depth: number, firstIndex: number, carried: ReadonlySet): InlineRun[] { const runs: InlineRun[] = [] - for (let offset = 0; offset < nodes.length; offset += 1) { - const node = nodes[offset] - if (node === undefined) continue + for (const [offset, node] of nodes.entries()) { const index = firstIndex + offset // `depth` indexes each node's marks, outermost first; a carried node spells its marks inside the carry. - const mark = carries(node, context.carried, index) ? undefined : nodeMarks(node)[depth] - const previous = runs[runs.length - 1] - const joined = mark === undefined && previous?.kind === 'marked' && context.joinsBreaks ? joinedBreaks(nodes, offset, firstIndex, previous.mark, depth, context.carried) : 0 - if (previous?.kind === 'marked' && joined > 0) { - for (const held of nodes.slice(offset, offset + joined)) previous.nodes.push(held) - offset += joined - 1 - continue - } + const mark = carries(node, carried, index) ? undefined : nodeMarks(node)[depth] if (mark === undefined) { runs.push({ index, kind: 'plain', node }) continue } + const previous = runs[runs.length - 1] if (previous?.kind === 'marked' && identicalMark(previous.mark, mark)) previous.nodes.push(node) else runs.push({ index, kind: 'marked', mark, nodes: [node] }) } return runs } -// The hard breaks from `offset` an emphasis or link run spans because the node after them holds its mark; a directive would spell each `!adf:hardBreak{}`. -function joinedBreaks(nodes: readonly AdfNode[], offset: number, firstIndex: number, mark: AdfMark, depth: number, carried: ReadonlySet): number { - const kind = markSpelling(mark.type)?.kind - if (kind !== 'emphasis' && kind !== 'link') return 0 - let ahead = offset - while (isUncarriedBreak(nodes[ahead], carried, firstIndex + ahead)) ahead += 1 - const next = nodes[ahead] - if (next === undefined || carries(next, carried, firstIndex + ahead)) return 0 - const nextMark = nodeMarks(next)[depth] - return nextMark !== undefined && identicalMark(nextMark, mark) ? ahead - offset : 0 -} - -function isUncarriedBreak(node: AdfNode | undefined, carried: ReadonlySet, index: number): boolean { - return node?.type === 'hardBreak' && !carries(node, carried, index) -} - 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 @@ -313,7 +282,7 @@ function emitMarkedRun(nodes: readonly AdfNode[], mark: AdfMark, depth: number, if (spelling.kind === 'emphasis') return emitEmphasis(nodes, spelling.spelling, depth, range, context) const link = spelling.kind === 'link' ? tryLink(nodes, mark, depth, range, context) : undefined if (link !== undefined) return link - const inner = emitRun(nodes, depth + 1, index, { ...context, bracketed: true, spansLines: false }) + const inner = emitRun(nodes, depth + 1, index, { ...context, bracketed: true }) if (!inner.ok) return inner if (inner.value.carry !== undefined) return inner return success({ segments: [syntax(spellInlineDirectiveOpener(mark.type)), ...inner.value.segments, syntax(`]${attributes}`)] }) diff --git a/src/markdown/portable/adf-to-portable-markdown.test.ts b/src/markdown/portable/adf-to-portable-markdown.test.ts index a666903..f26bee6 100644 --- a/src/markdown/portable/adf-to-portable-markdown.test.ts +++ b/src/markdown/portable/adf-to-portable-markdown.test.ts @@ -309,7 +309,7 @@ test('breaks a line at a newline and trims whitespace at every edge CommonMark s 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\\\nc**\n') + assert.equal(portable(paragraph(text(' '), text('b', strong), text(' \n'), text('c', strong), text(' '))), '**b**\\\n**c**\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/todo.md b/todo.md index e668217..5c51cb6 100644 --- a/todo.md +++ b/todo.md @@ -6,7 +6,7 @@ `Bar = 9` -`Next ID = 91` +`Next ID = 92` | Goal | W | |---|---| @@ -52,6 +52,7 @@ | 52 | 0.3.0 | | **Spell `colwidth` as a comma list, `colwidth="340,420"`.** | 3 | 3 | 5 | 6 | 6 | 11.5 | | 51 | 0.3.0 | | **Match a reference label to its definition under Unicode case folding.** | 2 | 2 | 2 | 7 | 4, 5 | 10.9 | | 53 | 0.3.0 | | **Put a block's `marks` spelling to the writer panel and adopt its pick.** | 4 | 5 | 5 | 6 | 6 | 10.0 | +| 91 | 0.3.0 | | **Spell an emphasis or link once across the hard breaks between two nodes it marks, in both writers.** | 5 | 5 | 5 | 6 | 6 | 9.0 | | 73 | 0.4.0 | | **Announce the package where someone needing an ADF converter already reads: JRACLOUD-77436, the Atlassian developer community and Stack Overflow's ADF-to-markdown questions.** | 1 | 2 | 6 | 8 | 3 | 23.0 | | 9 | 0.4.0 | | **Ship an online sandbox: a web page with two textboxes converting between ADF and markdown on the library's browser build.** | 2 | 6 | 6 | 8 | 3 | 21.0 | | 38 | 0.4.0 | | **Spell a lone surrogate in a text node so it survives a UTF-8 encode.** | 2 | 2 | 4 | 7 | 1 | 19.5 | @@ -145,8 +146,8 @@ types a node takes, and the emitter carries a node holding another. `adfToLosslessMarkdown` spends 23 s on one paragraph of 2000 × `un` plus `**-r**`: each run its flanking cannot spell re-emits the whole line before riding the carry, quadratic in the runs, and the portable reduction's `spellableLine` drops one mark per re-emit the same way. An inline node -whose attributes no spelling writes re-emits the line the same way before riding the carry, and so -does an emphasis or link run that parts at the hard breaks it spans. Make all four linear. +whose attributes no spelling writes re-emits the line the same way before riding the carry. Make all +three linear. ### 7. Ship HTML: `adfToHtml`, `htmlToAdf`, and markdown to and from HTML composed through ADF, each call named by its flavour. @@ -280,6 +281,15 @@ Today `marks="[{\"attrs\":{\"mode\":\"wide\"},\"type\":\"breakout\"}]"`, the mar escaped JSON. Breaking where the panel picks another spelling: `MIGRATION.md`'s Spellings table gains its row. +### 91. Spell an emphasis or link once across the hard breaks between two nodes it marks, in both writers. + +Lands after item 61. No reader marks a hard break, so `*One\` then `Two*` writes back as `_One_\` then +`_Two_`, and a link typed across a line as two links. Joining the run across the break in +`inline-line.ts` ruled worse with two comprehension panels (PR #22): a joined run that cannot be +spelled needs its own fallback, first as a set of parted breaks, then as a flag the first fallback +clears. Once the emitter asks the reader how a line reads back, the joined spelling can be tried and +kept only where it reads back. + ### 73. Announce the package where someone needing an ADF converter already reads: JRACLOUD-77436, the Atlassian developer community and Stack Overflow's ADF-to-markdown questions. Lands after `0.3.0` is published with item 71, so each post links the GitHub repository, the