diff --git a/CHANGELOG.md b/CHANGELOG.md index 0bdbf05..ba9a2ac 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -70,6 +70,10 @@ Chrome) `markdownToAdf` could refuse markdown `adfToMarkdown` wrote, as `unsupported-node-shape`, once the process had read a key holding a backslash ([V8 bug](https://issues.chromium.org/issues/521080746)). +- **Breaking:** a hard break inside emphasis, strike, a link or a directive mark reads holding no + marks, as Atlassian's schema requires, where every reader gave it the marks around it. + `adfToLosslessMarkdown` writes a hard break holding marks as `!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/MIGRATION.md b/MIGRATION.md index 3daa88d..c8f5197 100644 --- a/MIGRATION.md +++ b/MIGRATION.md @@ -66,6 +66,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 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/corpus/commonmark-spec/exceptions.json b/corpus/commonmark-spec/exceptions.json index 9464e01..9911f37 100644 --- a/corpus/commonmark-spec/exceptions.json +++ b/corpus/commonmark-spec/exceptions.json @@ -463,16 +463,16 @@ }, { "check": "count", - "divergence": "em 1/3", + "divergence": "em 1/2", "example": 638, - "kind": "pending", - "reason": "The hard break carries the em mark, which ADF's schema forbids; todo.md item 87 strips it." + "kind": "mark-model", + "reason": "The mark spans adjacent text nodes, counted once per node where CommonMark nests one element." }, { "check": "count", - "divergence": "em 1/3", + "divergence": "em 1/2", "example": 639, - "kind": "pending", - "reason": "The hard break carries the em mark, which ADF's schema forbids; todo.md item 87 strips it." + "kind": "mark-model", + "reason": "The mark spans adjacent text nodes, counted once per node where CommonMark nests one element." } ] diff --git a/spec/flavour.md b/spec/flavour.md index afbaa4d..933ceae 100644 --- a/spec/flavour.md +++ b/spec/flavour.md @@ -533,24 +533,25 @@ destination is empty too, `[]()`, as nothing. - `textColor` — Attributes: `color` (string, `#rrggbb`). - `underline` — Attributes: none. -A spelling adds its mark to every inline node it wraps, and nesting is the marks array in order, -outermost first: `_!adf:underline[x]_` 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. +A spelling adds its mark to every inline node it wraps but a hard break, which Atlassian's schema +gives no marks, and nesting is the marks array in order, outermost first: `_!adf:underline[x]_` +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. -An inline node whose marks no nesting spells — 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 the spelling needs and -the mark lacks, an empty `attrs` on a mark CommonMark spells, an order putting a code span outside -another mark, `code` over anything but a text node holding text, or over text holding a line ending -or a null character, or a spelling CommonMark's flanking rules cannot open or close where the run -sits (`un**-real**istic`), or one CommonMark's matching pairs elsewhere — the intra-word `*` runs -together with a neighbouring `**`, and the multiple-of-3 rule can leave the merged run's pairing to -another delimiter — rides the inline carry whole. An opaque carry inside a mark spelling is a named -error in input: the carry restores its node exactly, marks included (`docs/decisions.md` §Unknown -nodes ride the carry). +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 +the spelling needs and the mark lacks, an empty `attrs` on a mark CommonMark spells, an order +putting a code span outside another mark, `code` over anything but a text node holding text, or over +text holding a line ending or a null character, or a spelling CommonMark's flanking rules cannot +open or close where the run sits (`un**-real**istic`), or one CommonMark's matching pairs elsewhere +— the intra-word `*` runs together with a neighbouring `**`, and the multiple-of-3 rule can leave +the merged run's pairing to another delimiter — rides the inline carry whole. An opaque carry inside +a mark spelling is a named error in input: the carry restores its node exactly, marks included +(`docs/decisions.md` §Unknown nodes ride the carry). ``` !adf:textColor[**Overdue**]{color="#ae2e24"}, H!adf:subsup[2]{type=sub}O, !adf:underline[signed]. diff --git a/src/markdown/emit/adf-to-markdown.test.ts b/src/markdown/emit/adf-to-markdown.test.ts index 984c4c9..0e97a8d 100644 --- a/src/markdown/emit/adf-to-markdown.test.ts +++ b/src/markdown/emit/adf-to-markdown.test.ts @@ -488,10 +488,6 @@ test('carries a text node holding no text, or holding content', () => { test('carries a mark run whose edge holds whitespace CommonMark flanking counts', () => { const em = { type: 'em' } - assert.equal( - markdown(adfToLosslessMarkdown(document(paragraph({ marks: [em], text: 'a', type: 'text' }, { marks: [em], type: 'hardBreak' }, { text: 'b', type: 'text' })))), - '!adf:carry{json="{\\"marks\\":[{\\"type\\":\\"em\\"}],\\"text\\":\\"a\\",\\"type\\":\\"text\\"}"}!adf:carry{json="{\\"marks\\":[{\\"type\\":\\"em\\"}],\\"type\\":\\"hardBreak\\"}"}b\n', - ) assert.equal( markdown(adfToLosslessMarkdown(document(paragraph({ marks: [em], text: '\u00a0a', type: 'text' })))), '!adf:carry{json="{\\"marks\\":[{\\"type\\":\\"em\\"}],\\"text\\":\\"\u00a0a\\",\\"type\\":\\"text\\"}"}\n', @@ -714,15 +710,14 @@ test('spells the directive marks around the longest run they cover', () => { assert.equal(emitted(marked('x', { attrs: { color: '#091e42', size: 2 }, type: 'border' })), '!adf:border[x]{color="#091e42" size=2}\n') assert.equal(emitted(marked('x', { type: 'em' }, underline)), '_!adf:underline[x]_\n') assert.equal(emitted(marked('x', underline, { type: 'em' })), '!adf:underline[_x_]\n') - assert.equal(emitted(marked('a', underline), { marks: [underline], type: 'hardBreak' }, marked('b', underline)), '!adf:underline[a!adf:hardBreak{}b]\n') + assert.equal(emitted(marked('a', underline), { type: 'hardBreak' }, marked('b', underline)), '!adf:underline[a]\\\n!adf:underline[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( - emitted(marked('a', underline, link), { marks: [underline, link], type: 'hardBreak' }, marked('b', underline, link)), - '!adf:underline[[a!adf:hardBreak{}b](https://example.com/)]\n', - ) - assert.equal( - emitted(marked('a', link), { marks: [link], type: 'hardBreak' }, marked('b', link)), - '[a\\\nb](https://example.com/)\n', + readsBack(document(paragraph({ marks: [link], text: 'a', type: 'text' }, { marks: [link], type: 'hardBreak' }, { marks: [link], text: 'b', type: 'text' }))), + '[a](https://example.com/)!adf:carry{json="{\\"marks\\":[{\\"attrs\\":{\\"href\\":\\"https://example.com/\\"},\\"type\\":\\"link\\"}],\\"type\\":\\"hardBreak\\"}"}[b](https://example.com/)\n', ) }) diff --git a/src/markdown/emit/inline-line.ts b/src/markdown/emit/inline-line.ts index 167098c..32fd8f5 100644 --- a/src/markdown/emit/inline-line.ts +++ b/src/markdown/emit/inline-line.ts @@ -221,6 +221,7 @@ function nodePath(context: InlineContext, index: number): ConvertErrorPath { function carries(node: AdfNode, carried: ReadonlySet, index: number): boolean { if (carried.has(index)) return true if (node.type === 'text') return !isSpellableText(node) || typeof node.text !== 'string' || node.text === '' + if (node.type === 'hardBreak') return nodeMarks(node).length > 0 const model = inlineNodeModel(node.type) if (model === undefined) return true const slot = model.textAttribute === undefined ? undefined : nodeAttrs(node)[model.textAttribute] diff --git a/src/markdown/parse/inline-content.ts b/src/markdown/parse/inline-content.ts index 020685e..776948c 100644 --- a/src/markdown/parse/inline-content.ts +++ b/src/markdown/parse/inline-content.ts @@ -669,10 +669,10 @@ function markType(character: string, used: number): string { return used === 2 ? 'strong' : 'em' } -// `*(*a*)*` builds one `em`: a node holds at most one mark of each type. +// `*(*a*)*` builds one `em`: a node holds at most one mark of each type. Atlassian's schema gives a hard break no marks. function applyMark(nodes: readonly Inline[], mark: AdfMark): Inline[] { return nodes.map((node) => { - if (!isNode(node)) return node + if (!isNode(node) || node.type === 'hardBreak') return node const marks = nodeMarks(node) return marks.some((carried) => carried.type === mark.type) ? node : { ...node, marks: [mark, ...marks] } }) diff --git a/src/markdown/parse/markdown-to-adf.test.ts b/src/markdown/parse/markdown-to-adf.test.ts index bafa2c8..4aefe35 100644 --- a/src/markdown/parse/markdown-to-adf.test.ts +++ b/src/markdown/parse/markdown-to-adf.test.ts @@ -828,6 +828,18 @@ test('reads a hard break from a trailing backslash and from two trailing spaces assert.deepEqual(content(losslessMarkdownToAdf('> One\\\n> two.\n')), [quote({ content: [text('One'), hardBreak(), text('two.')], type: 'paragraph' })]) }) +test('builds the hard break a mark spelling spans with no marks, in every reader', () => { + const broken = (...marks: AdfMark[]): AdfNode[] => [{ content: [marked('a', ...marks), hardBreak(), marked('b', ...marks)], type: 'paragraph' }] + for (const read of [commonMarkToAdf, losslessMarkdownToAdf, portableMarkdownToAdf]) { + assert.deepEqual(content(read('*a \nb*\n')), broken(em)) + assert.deepEqual(content(read('**a\\\nb**\n')), broken(strong)) + assert.deepEqual(content(read('[a\\\nb](/u)\n')), broken(link('/u'))) + } + assert.deepEqual(content(losslessMarkdownToAdf('~~a\\\nb~~\n')), broken(strike)) + assert.deepEqual(content(losslessMarkdownToAdf('!adf:underline[a!adf:hardBreak{}b]\n')), broken(underline)) + assert.deepEqual(content(losslessMarkdownToAdf('_!adf:hardBreak{localId=x}_\n')), [{ content: [{ attrs: { localId: 'x' }, type: 'hardBreak' }], type: 'paragraph' }]) +}) + test('decodes the fenced info string the block walk leaves raw', () => { assert.deepEqual(content(losslessMarkdownToAdf('```java​script\nx\n```\n')), [ { attrs: { language: 'java\u200bscript' }, content: [text('x')], type: 'codeBlock' }, diff --git a/todo.md b/todo.md index 0f8f7e1..f9a8da8 100644 --- a/todo.md +++ b/todo.md @@ -25,7 +25,6 @@ | ID | Release | Exempt | Item | R | S | A | G | Goals | Score | |---|---|---|---|---|---|---|---|---|---| -| 87 | 0.2.0 | defect | **Build a `hardBreak` carrying no marks where emphasis or a link spans a line break, in every reader.** | 4 | 3 | 6 | 7 | 4 | 16.6 | | 88 | 0.2.0 | defect | **Build a `blockTaskItem` holding only the paragraphs and extensions Atlassian's schema allows, in `portableMarkdownToAdf`.** | 4 | 4 | 6 | 7 | 4 | 16.4 | | 49 | 0.2.0 | | **Read a list whose bullet or ordered delimiter changes as two lists, in every reader, and retire `!adf:listBreak`.** | 4 | 5 | 5 | 8 | 4, 5 | 15.5 | | 61 | 0.2.0 | decision | **Have the emitter ask the inline reader how a line reads back, in place of `line-escaping.ts` predicting it.** | 6 | 8 | 3 | 8 | 1 | 14.0 | @@ -67,13 +66,6 @@ ## Details -### 87. Build a `hardBreak` carrying no marks where emphasis or a link spans a line break, in every reader. - -`*foo \nbar*` builds a `hardBreak` marked `em` (CommonMark examples 638 and 639), which Atlassian's -schema refuses: its `hardBreak_node` holds no `marks` (`spec/adf-schema/full.json`). So -`commonMarkToAdf` builds a document a site may reject from plain CommonMark. The suite's `em 1/3` -`pending` exceptions for 638 and 639 then return to `mark-model` and `em 1/2`. - ### 88. Build a `blockTaskItem` holding only the paragraphs and extensions Atlassian's schema allows, in `portableMarkdownToAdf`. `- [x] done\n\n ![a](u)` builds a `blockTaskItem` holding a `mediaSingle`, and `- [x] done\n\n > q`