From fa7f4ef9875e8db2667b467a8c0eaab2b70487d4 Mon Sep 17 00:00:00 2001 From: Lilleman auf Larv Date: Sat, 10 Oct 2026 11:04:24 +0200 Subject: [PATCH] Read two lists a link reference definition parts as two lists, and put the changelog's breaking entries first --- CHANGELOG.md | 58 +++++++++++----------- MIGRATION.md | 1 + README.md | 13 +++-- src/markdown/parse/blocks.ts | 10 +++- src/markdown/parse/markdown-to-adf.test.ts | 10 +++- todo.md | 15 ------ 6 files changed, 54 insertions(+), 53 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e757363..ec3350d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,13 +2,12 @@ ## Unreleased -- `adfToLosslessMarkdown` and `adfToPortableMarkdown` write text holding a long run of `*`, `_`, `~` - or backticks, or a heading ending in a long run of `#`, in time linear in the run: a run of 20000 - `*` took seconds before, and now takes milliseconds. - **Breaking:** a list whose bullet character or ordered delimiter changes reads as two lists, as CommonMark reads it: `- a` then `* b`, `1. a` then `1) b`. `adfToLosslessMarkdown` gives a list right after one of its kind the other marker, `* ` or `1)`, in place of the `::listBreak` separator. Convert stored markdown per `MIGRATION.md`. +- **Breaking:** two lists a link reference definition parts read as two lists, as CommonMark + reads them, where they read as one; see `MIGRATION.md`. - **Breaking:** each call names the markdown it reads or writes. `adfToMarkdown` and `markdownToAdf` are now `adfToLosslessMarkdown` and `losslessMarkdownToAdf`. A bot's or a model's markdown reads in `portableMarkdownToAdf`, and markdown known to be strict CommonMark in the new @@ -21,10 +20,6 @@ converters refuse it as `not-an-adf-document`. Before, the converters wrote it as `{}` or its own keys. Build it as JSON holds it: `date.toISOString()`, `Object.fromEntries(map)`, `Array.from(bytes)`, `{ ...instance }`. -- The README opens with what the package does and for whom, and `package.json` carries search - keywords. -- The source moves to [github.com/larvit/adf-codec](https://github.com/larvit/adf-codec), where - issues and pull requests go. - **Breaking:** directives, the inline opaque carry among them (now `!adf:carry{json="…"}`), are spelled under an `!adf:` prefix (`!adf:name … !adf:/name`, `!adf:name[content]{attrs}`, `!adf:name arg {attrs}`) in place of the `:::`/`::`/`:name` forms: text holding an unescaped @@ -35,12 +30,6 @@ - **Breaking:** the markdown readers read markdown holding no block as a document whose `content` is empty, as Atlassian's schema requires; `!adf:doc {content=none}` spells a document holding no `content` key. -- `losslessMarkdownToAdf(adfToLosslessMarkdown(doc))` deep-equals `doc` as `JSON.parse` builds it: - two adjacent text nodes CommonMark would read back as one are parted by `!adf:textBreak{}`, an - empty `attrs`, `content` or `marks` is spelled `{attrs=empty}`, `{content=empty}` or - `{marks=empty}`, `-0` is spelled `-0`, and a `codeBlock` of several text nodes is a fence per - node. A `codeBlock` holding other than plain text nodes rides the block carry, where it was - refused. - **Breaking:** `unspellable-character` and `unspellable-line-start` leave `ConvertErrorCode`, and `adfToLosslessMarkdown` no longer returns `unspellable-whitespace`. A carriage return in text is spelled ` ` and a null character `!adf:text{text="\u0000"}`; a paragraph line opening with a @@ -48,8 +37,6 @@ either character, a code span holding a null character, a text node holding no text or holding `content`, and an `emoji`, `mention` or `status` whose `text` holds a line ending or a null character ride the carry. See `MIGRATION.md`. -- Fix a link `title` holding a null character: it is written as `!adf:link[text]{attrs}`, where it - read back as U+FFFD. - **Breaking:** `unspellable-link` leaves `ConvertErrorCode`; a link whose `href` or `title` no CommonMark escape spells is written as `!adf:link[text]{attrs}`. - **Breaking:** some directive refusals carry `malformed-directive` where they carried @@ -57,14 +44,6 @@ `MIGRATION.md` lists each. - **Breaking:** a link whose text holds another link keeps the inner link and leaves the outer brackets literal text, where `0.1.0` split the outer link around it; see `MIGRATION.md`. -- Add `adfToPortableMarkdown` and `portableMarkdownToAdf`, a lossy pair converting ADF to and from - markdown GitHub, GitLab and Obsidian render: alerts, callouts, task lists, `==highlights==` and - pipe tables. A task item keeps its paragraphs, then its nested task lists; the rest of the item, - such as an image or a code block, stands after the task list. -- Spell `rule`'s `color`, `style` and `weight`, `layoutSection`'s `columnRuleStyle` and a link's - `collection`, `id` and `occurrenceKey` directly where they rode the opaque carry. -- Fix an image inside another image's description: it flattens into the alt text, where it was - refused. - **Breaking:** `unmappable-image` leaves `ConvertErrorCode`, and every image reads. An image reads as an image only alone in its paragraph, and its title becomes the caption. Anywhere else, after a task marker too, it reads as its alt text linked to its URL; inside a link, as plain text the @@ -73,20 +52,43 @@ `MIGRATION.md`. - **Breaking:** an empty link text reads as its destination, `[](/url)` as `/url` linked, and `[]()` as nothing, where both spellings stayed literal text. See `MIGRATION.md`. -- Fix a JSON key holding an escape, in a carried node or a JSON attribute such as `parameters`: - `losslessMarkdownToAdf` reads it as the key it spells, where on V8 13.6 and later (Node 24, Deno, - 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. A link 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="…"}`. See `MIGRATION.md`. +- `adfToLosslessMarkdown` and `adfToPortableMarkdown` write text holding a long run of `*`, `_`, `~` + or backticks, or a heading ending in a long run of `#`, in time linear in the run: a run of 20000 + `*` took seconds before, and now takes milliseconds. +- `losslessMarkdownToAdf(adfToLosslessMarkdown(doc))` deep-equals `doc` as `JSON.parse` builds it: + two adjacent text nodes CommonMark would read back as one are parted by `!adf:textBreak{}`, an + empty `attrs`, `content` or `marks` is spelled `{attrs=empty}`, `{content=empty}` or + `{marks=empty}`, `-0` is spelled `-0`, and a `codeBlock` of several text nodes is a fence per + node. A `codeBlock` holding other than plain text nodes rides the block carry, where it was + refused. +- Fix a link `title` holding a null character: it is written as `!adf:link[text]{attrs}`, where it + read back as U+FFFD. +- Fix an image inside another image's description: it flattens into the alt text, where it was + refused. +- Fix a JSON key holding an escape, in a carried node or a JSON attribute such as `parameters`: + `losslessMarkdownToAdf` reads it as the key it spells, where on V8 13.6 and later (Node 24, Deno, + 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)). - 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. +- The README opens with what the package does and for whom, and `package.json` carries search + keywords. +- The source moves to [github.com/larvit/adf-codec](https://github.com/larvit/adf-codec), where + issues and pull requests go. +- Add `adfToPortableMarkdown` and `portableMarkdownToAdf`, a lossy pair converting ADF to and from + markdown GitHub, GitLab and Obsidian render: alerts, callouts, task lists, `==highlights==` and + pipe tables. A task item keeps its paragraphs, then its nested task lists; the rest of the item, + such as an image or a code block, stands after the task list. +- Spell `rule`'s `color`, `style` and `weight`, `layoutSection`'s `columnRuleStyle` and a link's + `collection`, `id` and `occurrenceKey` directly where they rode the opaque carry. ## 0.1.0 diff --git a/MIGRATION.md b/MIGRATION.md index 0c1a2fc..5fde8a1 100644 --- a/MIGRATION.md +++ b/MIGRATION.md @@ -77,6 +77,7 @@ Markdown the spelling table leaves alone, which `0.2.0` reads as a different doc | Input | `0.1.0` | `0.2.0` | | --- | --- | --- | | a list whose bullet character or ordered delimiter changes (`- a` then `* b`, `1. a` then `1) b`) | one list | two lists, as CommonMark reads them | +| two lists a link reference definition parts (`- a`, `[r]: /u`, `- b`, a blank line between each) | one list | two lists, as CommonMark reads them | | 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, and the recipe above drops the marks `0.1.0` gave it wherever text beside it holds them. A link whose text is only hard breaks reads as its destination linked, followed by those breaks | diff --git a/README.md b/README.md index 3b808ea..d6a73cc 100644 --- a/README.md +++ b/README.md @@ -239,7 +239,7 @@ Serves Goals 1, 4 and 5. without a delimiter row, is an error result; each can be kept literal (`spec/flavour.md`). - A document any reader built, written back with `adfToLosslessMarkdown`, takes the library's canonical spelling, which round-trips byte-identically. -- Seven CommonMark spellings parse without an error and build a document the reference +- Six CommonMark spellings parse without an error and build a document the reference implementation renders differently. Four because ADF holds no node for what it renders: - An empty link text reads as its destination, `[](/url)` as `/url` linked to `/url`, and `[]()` as nothing. A link text holding only hard breaks reads the same, followed by its hard breaks. @@ -253,12 +253,11 @@ Serves Goals 1, 4 and 5. - An image's title becomes its caption when the image stays an image, and its link's title when it becomes linked alt text. Inside another link, the title is dropped. - And three more: a shortcut reference matching its definition only under Unicode case folding - stays unresolved, two lists a link reference definition parts stay one list against CommonMark's - two, and a link whose text holds an autolink keeps the inner link and leaves the outer brackets - literal text, which the spec requires and the reference itself breaks, nesting one `` in the - other. `corpus/commonmark-spec/exceptions.json` pins the first four `node-model` where the suite - reaches them and the fifth `pending`; the suite holds no example of the last two. + And two more: a shortcut reference matching its definition only under Unicode case folding + stays unresolved, and a link whose text holds an autolink keeps the inner link and leaves the + outer brackets literal text, which the spec requires and the reference itself breaks, nesting one + `` in the other. `corpus/commonmark-spec/exceptions.json` pins the first four `node-model` + where the suite reaches them and the fifth `pending`; the suite holds no example of the last. - Not every document converts back: `adfToLosslessMarkdown` refuses a document nesting past 500 levels, and the node shapes the `unsupported-node-shape` row lists. Show the refusal and keep the document read-only; saving markdown you could not produce is the loss the round-trip exists to diff --git a/src/markdown/parse/blocks.ts b/src/markdown/parse/blocks.ts index 61f16cf..8301004 100644 --- a/src/markdown/parse/blocks.ts +++ b/src/markdown/parse/blocks.ts @@ -75,6 +75,8 @@ type ContainerStack = { type Walk = ParsedBlocks & { claims: Claims + // The block a paragraph of link reference definitions alone last closed after, which ends a list as any block does. + definedAfter: Block | undefined leaf: OpenLeaf | undefined leafOpeners: Map> position: SourcePosition @@ -89,6 +91,7 @@ export function parseBlocks(markdown: string, claims: Claims): ParsedBlocks { const walk: Walk = { blocks: [], claims, + definedAfter: undefined, definitions: new Map(), leaf: undefined, leafOpeners: new Map(), @@ -251,7 +254,7 @@ function openedList(walk: Walk, start: Extract if (!start.fresh) return start.list const blocks = currentBlocks(walk) const previous = blocks.at(-1) - if ((previous?.kind === 'bulletList' || previous?.kind === 'orderedList') && continuesList(previous, start.list)) return previous + if ((previous?.kind === 'bulletList' || previous?.kind === 'orderedList') && previous !== walk.definedAfter && continuesList(previous, start.list)) return previous blocks.push(start.list) return start.list } @@ -514,7 +517,10 @@ function takeParagraph(walk: Walk): Extract | unde if (leaf?.kind !== 'paragraph') return undefined walk.leaf = undefined const text = readLinkDefinitions(walk.definitions, leaf.lines.join('\n')) - if (text === '') return undefined + if (text === '') { + walk.definedAfter = currentBlocks(walk).at(-1) + return undefined + } const kept = leaf.positions[leaf.lines.length - text.split('\n').length] return { kind: 'paragraph', position: kept ?? leaf.position, text } } diff --git a/src/markdown/parse/markdown-to-adf.test.ts b/src/markdown/parse/markdown-to-adf.test.ts index 0cda293..d05852d 100644 --- a/src/markdown/parse/markdown-to-adf.test.ts +++ b/src/markdown/parse/markdown-to-adf.test.ts @@ -673,10 +673,18 @@ test('reads a bullet list, the marker width setting the continuation', () => { assert.deepEqual(content(losslessMarkdownToAdf('- Code.\n')), [bulletList(item({ content: [text('Code.')], type: 'codeBlock' }))]) assert.deepEqual(content(losslessMarkdownToAdf('- a\n-\n\n- c\n')), [bulletList(item(paragraph('a')), item(), item(paragraph('c')))]) assert.deepEqual(content(losslessMarkdownToAdf('- a\n1. b\n')), [bulletList(item(paragraph('a'))), orderedList(1, item(paragraph('b')))]) - assert.deepEqual(content(losslessMarkdownToAdf('- a\n\n[r]: /u\n\n- b\n')), [bulletList(item(paragraph('a')), item(paragraph('b')))]) assert.deepEqual(content(losslessMarkdownToAdf('-\n\n Part.\n')), [bulletList(item()), paragraph('Part.')]) }) +test('reads two lists a link reference definition parts as two lists, in every reader', () => { + for (const read of [commonMarkToAdf, losslessMarkdownToAdf, portableMarkdownToAdf]) { + assert.deepEqual(content(read('- a\n\n[r]: /u\n\n- b\n')), [bulletList(item(paragraph('a'))), bulletList(item(paragraph('b')))]) + assert.deepEqual(content(read('- a\n\n[r]: /u\n- b\n')), [bulletList(item(paragraph('a'))), bulletList(item(paragraph('b')))]) + assert.deepEqual(content(read('-\n\n[r]: /u\n\n- b\n')), [bulletList(item()), bulletList(item(paragraph('b')))]) + assert.deepEqual(content(read('- a\n\n [r]: /u\n- b\n')), [bulletList(item(paragraph('a')), item(paragraph('b')))]) + } +}) + test('reads an ordered list, its first marker the order attribute', () => { assert.deepEqual(content(losslessMarkdownToAdf('9. Bolt M8\n10. Nut M8\n')), [orderedList(9, item(paragraph('Bolt M8')), item(paragraph('Nut M8')))]) assert.deepEqual(content(losslessMarkdownToAdf('1) Loosen the clamp\n')), [orderedList(1, item(paragraph('Loosen the clamp')))]) diff --git a/todo.md b/todo.md index 0b3e71f..97c1c59 100644 --- a/todo.md +++ b/todo.md @@ -25,8 +25,6 @@ | ID | Release | Exempt | Item | R | S | A | G | Goals | Score | |---|---|---|---|---|---|---|---|---|---| -| 94 | 0.2.0 | defect | **Read two lists a link reference definition parts as two lists, in every reader.** | 3 | 3 | 4 | 7 | 4, 5 | 13.6 | -| 97 | 0.2.0 | | **Put every Breaking entry first under `CHANGELOG.md`'s `## Unreleased`, then fixes and additions.** | 1 | 1 | 5 | 3 | 3 | 13.4 | | 93 | 0.2.0 | | **Report a refusal's `path` where the node would stand after `portableMarkdownToAdf` moves it out of a task item.** | 2 | 3 | 3 | 5 | 1 | 13.2 | | 86 | 0.2.0 | | **Write an image whose `media` only a link marks, and one captioned with plain text, in the CommonMark spelling a writer panel picks, in `adfToLosslessMarkdown`.** | 4 | 4 | 5 | 6 | 4, 6 | 13.0 | | 85 | 0.2.0 | defect | **Write an external image a link marks as `[![alt](url)](href)` in `adfToPortableMarkdown`, keeping the link target.** | 3 | 3 | 6 | 7 | 7 | 12.9 | @@ -71,19 +69,6 @@ ## Details -### 94. Read two lists a link reference definition parts as two lists, in every reader. - -`openedList` in `src/markdown/parse/blocks.ts` joins a fresh list to the list just before it when -no block stands between, so `- a\n\n[r]: /u\n\n- b` reads as one list; CommonMark reads two, -the definition closing the first. The join exists for an empty item a blank line ends, which -continues its list. `markdown-to-adf.test.ts` asserts the one list. Breaking: `MIGRATION.md`'s -Readings table gains its row, and the README's guarantees drop the exception and count one fewer. - -### 97. Put every Breaking entry first under `CHANGELOG.md`'s `## Unreleased`, then fixes and additions. - -The bullets run in the order the work landed, so an app developer upgrading meets a fix before the -breaking changes they must act on. - ### 93. Report a refusal's `path` where the node would stand after `portableMarkdownToAdf` moves it out of a task item. `bulletBlocks` fixes a block's path before `splitTaskList` moves it beside or after its task, so