From 852d010ff7ccd1ebbad16d2fbd170b0c2865c520 Mon Sep 17 00:00:00 2001 From: Lilleman auf Larv Date: Fri, 9 Oct 2026 11:15:58 +0200 Subject: [PATCH] Answer product-owner round 1: an emphasis or link run spans an unmarked hard break, the recipe drops a hard break's marks, and the refusal names hard breaks --- MIGRATION.md | 13 ++++++++++--- README.md | 2 +- docs/decisions.md | 5 +++-- spec/flavour.md | 7 ++++--- src/markdown/emit/adf-to-markdown.test.ts | 11 +++++++++++ src/markdown/emit/inline-line.ts | 15 ++++++++++++++- src/markdown/parse/inline-content.ts | 9 ++++++--- src/markdown/parse/markdown-to-adf.test.ts | 7 ++++--- .../portable/adf-to-portable-markdown.test.ts | 2 +- 9 files changed, 54 insertions(+), 17 deletions(-) diff --git a/MIGRATION.md b/MIGRATION.md index 9753706..55168f5 100644 --- a/MIGRATION.md +++ b/MIGRATION.md @@ -26,13 +26,20 @@ npm install @larvit/adf-codec@0.2.0 adf-codec-0.1@npm:@larvit/adf-codec@0.1.0 ``` ```ts -import { adfToLosslessMarkdown } from '@larvit/adf-codec' +import { adfToLosslessMarkdown, type AdfNode } from '@larvit/adf-codec' import { markdownToAdf as markdownToAdf010 } from 'adf-codec-0.1' +// 0.1.0 gave a hard break the marks spelled around it; Atlassian's schema gives it none. +function unmarkHardBreaks(node: AdfNode): AdfNode { + const { marks, ...unmarked } = node + if (node.type === 'hardBreak') return unmarked + return node.content === undefined ? node : { ...node, content: node.content.map(unmarkHardBreaks) } +} + function migrateMarkdown(stored: string) { const parsed = markdownToAdf010(stored) // 0.1.0 dropped an empty content array, so a document with no content key meant an empty one. - return parsed.ok ? adfToLosslessMarkdown({ ...parsed.value, content: parsed.value.content ?? [] }) : parsed + return parsed.ok ? adfToLosslessMarkdown({ ...parsed.value, content: (parsed.value.content ?? []).map(unmarkHardBreaks) }) : parsed } ``` @@ -66,7 +73,7 @@ Markdown the spelling table leaves alone, which `0.2.0` reads as a different doc | --- | --- | --- | | a link whose text already holds one (`[ab](/v)`) | marks every node the inner link does not, splitting the outer link around it | leaves the outer brackets literal text; write the pieces as separate links to keep them | | markdown holding no block (`""`) | `{ type: 'doc', version: 1 }` | `{ content: [], type: 'doc', version: 1 }`; `!adf:doc {content=none}` reads as the former | -| a hard break inside a mark's spelling (`*a\` then `b*` on the next line) | the hard break holds the mark | the hard break holds no marks, as Atlassian's schema requires; converting per the recipe above keeps a stored one's marks in the inline carry. A link whose text is only a hard break reads as its destination linked | +| a hard break inside a mark's spelling (`*a\` then `b*` on the next line) | the hard break holds the mark | the hard break holds no marks, as Atlassian's schema requires, and the recipe above drops them from stored markdown. A link whose text is only hard breaks reads as its destination linked | | a link whose text is empty (`[](/url)`, `[]()`) | literal text | `/url` linked to `/url`, and nothing where the destination is empty too | | a code fence whose info string opens `adf:` (```` ```adf:x ````) | a `codeBlock` with that language | in `losslessMarkdownToAdf`, the block carry, refusing a body that is not one node's canonical JSON; write `!adf:codeBlock {language="adf:x"}` around a bare fence to keep the code block | diff --git a/README.md b/README.md index f2fae1e..871225c 100644 --- a/README.md +++ b/README.md @@ -215,7 +215,7 @@ emit refuses: | Code | Fires when | What you can do | | --- | --- | --- | | `unsupported-nesting-depth` | blocks, marks, an attribute's JSON or a carried node's JSON nest past 500 levels | keep the ADF and pass the document over, or show it read-only; flatten the input where you are the one who wrote it | -| `unsupported-node-shape` | parsing, in `losslessMarkdownToAdf`: markdown spells a node with an attribute, value, argument or body its type does not take, or without one it needs, writes as a directive a node or mark the lossless flavour spells as CommonMark, or puts a reserved directive out of place: `!adf:textBreak{}` or `!adf:listBreak` parting nothing, `!adf:doc` anywhere but as the whole document. Emitting: a node carrying one mark type twice, unless it rides the carry, a block or a non-text inline node holding `text`, or a leaf block or a non-text inline node holding `content` | fix what the message names; `spec/flavour.md` lists every type's attributes and body | +| `unsupported-node-shape` | parsing, in `losslessMarkdownToAdf`: markdown spells a node with an attribute, value, argument or body its type does not take, or without one it needs, wraps a directive mark around nothing or only hard breaks, writes as a directive a node or mark the lossless flavour spells as CommonMark, or puts a reserved directive out of place: `!adf:textBreak{}` or `!adf:listBreak` parting nothing, `!adf:doc` anywhere but as the whole document. Emitting: a node carrying one mark type twice, unless it rides the carry, a block or a non-text inline node holding `text`, or a leaf block or a non-text inline node holding `content` | fix what the message names; `spec/flavour.md` lists every type's attributes and body | ## The guarantees diff --git a/docs/decisions.md b/docs/decisions.md index 50496f4..39ff295 100644 --- a/docs/decisions.md +++ b/docs/decisions.md @@ -205,8 +205,9 @@ where its reference implementation nests one `` in another. ## An image reads as an image only alone in its paragraph, else as its alt text linked; a link text holding nothing a mark rides reads as its destination 2026-10-04, the maintainer and a writer panel, 3 of 3; `[]()` 2026-10-09, by Goals 4 and 7, as the -maintainer's plan item asked; a link text of hard breaks alone 2026-10-09, by Goal 7. Goals 4, 5, 6 and 7. Valid while `mediaInline` carries no URL and ADF -holds no empty text node. +maintainer's plan item asked; a link text of hard breaks alone 2026-10-09, by Goal 7. Goals 4, 5, 6 +and 7. Valid while `mediaInline` carries no URL, ADF holds no empty text node and a hard break takes +no marks. Every reader reads every CommonMark image and empty link, as `spec/flavour.md` §The CommonMark image and §Marks spell out. An image alone in its paragraph reads as the image, its title as the caption. diff --git a/spec/flavour.md b/spec/flavour.md index b9eb949..fe1e4b3 100644 --- a/spec/flavour.md +++ b/spec/flavour.md @@ -538,9 +538,10 @@ gives no marks, and nesting is the marks array in order, outermost first: `_!adf gives marks `[em, underline]`, `!adf:underline[_x_]` the reverse. `adfToLosslessMarkdown` nests in 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}`. A run breaks at every node the emitter carries, so no -emitted carry sits inside a mark spelling. +carrying an identical mark, attributes included, at that depth, an emphasis or link spelling also +spanning a hard break between two of them: `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. 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 0e97a8d..13c9d34 100644 --- a/src/markdown/emit/adf-to-markdown.test.ts +++ b/src/markdown/emit/adf-to-markdown.test.ts @@ -713,6 +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', () => { + 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\\\n_b_\n') +}) + test('carries a hard break holding marks, which Atlassian\'s schema gives it none of', () => { 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 a6adb76..205f15f 100644 --- a/src/markdown/emit/inline-line.ts +++ b/src/markdown/emit/inline-line.ts @@ -196,17 +196,30 @@ function inlineRuns(nodes: readonly AdfNode[], depth: number, firstIndex: number 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, carried, index) ? undefined : nodeMarks(node)[depth] + const previous = runs[runs.length - 1] + if (mark === undefined && previous?.kind === 'marked' && joinsRun(node, index, nodes[offset + 1], previous.mark, depth, carried)) { + previous.nodes.push(node) + continue + } 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 reader marks no hard break, so one between two nodes holding an emphasis or link mark reads back inside its spelling. +function joinsRun(node: AdfNode, index: number, next: AdfNode | undefined, mark: AdfMark, depth: number, carried: ReadonlySet): boolean { + if (node.type !== 'hardBreak' || carries(node, carried, index) || next === undefined || carries(next, carried, index + 1)) return false + const kind = markSpelling(mark.type)?.kind + if (kind !== 'emphasis' && kind !== 'link') return false + const nextMark = nodeMarks(next)[depth] + return nextMark !== undefined && identicalMark(nextMark, mark) +} + 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/parse/inline-content.ts b/src/markdown/parse/inline-content.ts index c81c3f7..41ae8d8 100644 --- a/src/markdown/parse/inline-content.ts +++ b/src/markdown/parse/inline-content.ts @@ -260,9 +260,12 @@ function directivePiece(scan: Scan, span: DirectiveSpan, index: number): Result< } function directiveMarkPiece(scan: Scan, name: string, mark: AdfMark, slot: SlotContent | undefined, index: number): Result { - if (slot?.nodes.some(holdsOwnMarks) === true) return failure('unsupported-node-shape', ownMarksInMark, scan.path) - if (slot === undefined || slot.nodes.every(takesNoMarks)) { - return failure('unsupported-node-shape', `the ${name} mark wraps the [content] it marks: this one wraps nothing a mark rides`, scan.path) + if (slot === undefined || slot.nodes.length === 0) { + return failure('unsupported-node-shape', `the ${name} mark wraps the [content] it marks: this one wraps none`, scan.path) + } + if (slot.nodes.some(holdsOwnMarks)) return failure('unsupported-node-shape', ownMarksInMark, scan.path) + if (slot.nodes.every(takesNoMarks)) { + return failure('unsupported-node-shape', `the ${name} mark wraps the [content] it marks: this one wraps only hard breaks, which take no marks`, scan.path) } const refused = mark.type === 'link' ? refuseLinkDirective(scan, mark, slot.nodes, index) : undefined if (refused !== undefined) return refused diff --git a/src/markdown/parse/markdown-to-adf.test.ts b/src/markdown/parse/markdown-to-adf.test.ts index c3853f7..206b8ab 100644 --- a/src/markdown/parse/markdown-to-adf.test.ts +++ b/src/markdown/parse/markdown-to-adf.test.ts @@ -1181,11 +1181,12 @@ test('names the link a directive link wraps, no link holding another', () => { }) test('names the directive mark left without the content it wraps', () => { - const named = 'unsupported-node-shape: the underline mark wraps the [content] it marks: this one wraps nothing a mark rides' + const named = 'unsupported-node-shape: the underline mark wraps the [content] it marks: this one wraps none' assert.equal(content(losslessMarkdownToAdf('!adf:underline[]\n')), named) assert.equal(content(losslessMarkdownToAdf('!adf:underline{}\n')), named) - assert.equal(content(losslessMarkdownToAdf('!adf:underline[!adf:hardBreak{}]\n')), named) - assert.equal(content(losslessMarkdownToAdf('!adf:link[!adf:hardBreak{}]{href="/u" title=t}\n')), named.replace('underline', 'link')) + const breaks = 'the [content] it marks: this one wraps only hard breaks, which take no marks' + assert.equal(content(losslessMarkdownToAdf('!adf:underline[!adf:hardBreak{}]\n')), `unsupported-node-shape: the underline mark wraps ${breaks}`) + assert.equal(content(losslessMarkdownToAdf('!adf:link[!adf:hardBreak{}]{href="/u" title=t}\n')), `unsupported-node-shape: the link mark wraps ${breaks}`) }) test('reads the text break only between two text nodes CommonMark joins, building no node', () => { diff --git a/src/markdown/portable/adf-to-portable-markdown.test.ts b/src/markdown/portable/adf-to-portable-markdown.test.ts index a94c5f6..c3f04c3 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**\\\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('a'), text(' b ', em, strong), text(' ', em), text('c', em))), 'a _**b** c_\n') assert.equal(portable(paragraph(text(' x ', code))), '` x `\n') assert.equal(portable(node('heading', { level: 1 }, text(' h\ni '))), '# h i\n')