diff --git a/CHANGELOG.md b/CHANGELOG.md index a6d675f..665906a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -52,9 +52,8 @@ 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 holds only paragraphs: from its first other block, such as an image or - a code block, the rest of the item stands after the task list; a nested task list before that - block stays nested. + 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 diff --git a/README.md b/README.md index d8d9021..e769318 100644 --- a/README.md +++ b/README.md @@ -127,7 +127,7 @@ saving what this pair read replaces mentions, attachments and macros with text. | --- | --- | --- | | `panel` | a GitHub alert, `> [!WARNING]`: info `NOTE`, note `IMPORTANT`, tip and success `TIP`, warning `WARNING`, error `CAUTION`, custom `NOTE` | `NOTE` info, `IMPORTANT` note, `TIP` tip, `WARNING` warning, `CAUTION` error, and Obsidian's: hint tip; success, check, done success; attention warning; danger, failure, fail, missing, bug, error error; any other word info — in any case; the rest of the marker's line is the first paragraph | | `expand`, `nestedExpand` | Obsidian's folded callout, `> [!NOTE]- Title` | `-` or `+` after any word, the rest of the marker's line the title; a link reads `text (target)`, or its text alone where the text is the target with or without `mailto:`; an expand inside an expand is a `nestedExpand` | -| `taskList` | `- [x] Done`, `- [ ] Todo`; from an item's first block other than a paragraph, the rest of the item after the list, which resumes at the next task | a bullet list whose every item is so marked, `[X]` too; from an item's first block other than a paragraph or a nested task list, the rest of the item after the list, which resumes at the next task | +| `taskList` | `- [x] Done`, `- [ ] Todo`. An item keeps its paragraphs, then its nested task lists; the rest of the item stands after the list, which resumes at the next task | a bullet list whose every item is so marked, `[X]` too. An item keeps its paragraphs, then its nested task lists; the rest of the item stands after the list, which resumes at the next task | | `backgroundColor` | `==text==` | `==text==` on one line, the text touching both delimiters, bounded outside by whitespace, punctuation or a line edge, or touching a Han, Hangul, Hiragana, Katakana, Thai, Lao, Khmer or Myanmar character on either side, in the editor's default highlight `#f8e6a0` | | `table` | a pipe table: the first row its header, a cell's blocks on one line, a span kept under its header by empty cells | — | | `decisionList` | a bullet list | — | diff --git a/docs/decisions.md b/docs/decisions.md index 3e93e54..9550489 100644 --- a/docs/decisions.md +++ b/docs/decisions.md @@ -252,13 +252,12 @@ markdown pasted twice into one document repeats its ids: determinism wins over t 2026-10-09, a reader panel, 6 of 7. Goals 4, 6 and 7. Valid while Atlassian's schema holds a `blockTaskItem` to paragraphs and extensions. -In `portableMarkdownToAdf` and `adfToPortableMarkdown`, a task item keeps its paragraphs up to its -first other block. That block, the item's blocks after it and the task lists nested in the item -after that block stand after the task list, which resumes at the next task. The panel of two bots, -two LLM pipelines, a support agent, a product manager and an engineer, shown an image, a code block -and a nested bullet list in a task item, picked this over turning those blocks into text inside -the item (1 of 7) and over a plain bullet list keeping the markers as text (none): every reader -wanted the checkboxes, and six wanted the blocks in their own form. +In `portableMarkdownToAdf` and `adfToPortableMarkdown`, a task item keeps its paragraphs, then its +nested task lists. The rest of the item stands after the task list. The list resumes at the next +task. The panel of two bots, two LLM pipelines, a support agent, a product manager and an engineer, +shown an image, a code block and a nested bullet list in a task item, picked this over turning those +blocks into text inside the item (1 of 7) and over a plain bullet list keeping the markers as text +(none): every reader wanted the checkboxes, and six wanted the blocks in their own form. ## A callout title keeps its link targets diff --git a/src/conformance/adf-property.test.ts b/src/conformance/adf-property.test.ts index cbf9328..ff274eb 100644 --- a/src/conformance/adf-property.test.ts +++ b/src/conformance/adf-property.test.ts @@ -48,7 +48,7 @@ test('a generated document emits markdown that reads back to it', { timeout: pro ) }) -test('a generated document writes portable markdown refusing only what the guard refuses, and that markdown reads back to its text and to itself, a block task item holding paragraphs alone', { timeout: propertyTimeout }, () => { +test('a generated document writes portable markdown refusing only what the guard refuses, and that markdown reads back to its text, to itself and to block task items of paragraphs alone', { timeout: propertyTimeout }, () => { fc.assert( fc.property(adfDocument, (document) => { const written = adfToPortableMarkdown(document) diff --git a/src/markdown/parse/markdown-to-adf.ts b/src/markdown/parse/markdown-to-adf.ts index 0daa25b..4f43a2c 100644 --- a/src/markdown/parse/markdown-to-adf.ts +++ b/src/markdown/parse/markdown-to-adf.ts @@ -198,7 +198,6 @@ function filledNode(node: AdfNode, content: Result): Result return success({ ...node, content: content.value.length === 0 ? [{ type: 'paragraph' }] : content.value }) } -// A task list trailing an item's blocks stands beside it, as ADF nests one. function bulletNode(items: readonly Block[][], reading: Reading, path: ConvertErrorPath, depth: number): Result { const led = [] for (const [first, ...others] of items) { @@ -212,10 +211,7 @@ function bulletNode(items: readonly Block[][], reading: Reading, path: ConvertEr const marked: TaskParagraph[] = body === '' ? [] : [{ kind: 'taskParagraph', position, text: body }] const read = readBlocks([...marked, ...others], reading, [...path, 'content', index], depth + 1) if (!read.ok) return read - let beside = read.value.length - while (read.value[beside - 1]?.type === 'taskList') beside -= 1 - tasks.push({ attrs: { state: marker.state }, content: read.value.slice(0, beside), type: 'blockTaskItem' }) - for (const nested of read.value.slice(beside)) tasks.push(nested) + tasks.push({ attrs: { state: marker.state }, content: read.value, type: 'blockTaskItem' }) } return success({ content: tasks, type: 'taskList' }) } diff --git a/src/markdown/parse/portable-markdown-to-adf.test.ts b/src/markdown/parse/portable-markdown-to-adf.test.ts index 32c546a..f1fd1e9 100644 --- a/src/markdown/parse/portable-markdown-to-adf.test.ts +++ b/src/markdown/parse/portable-markdown-to-adf.test.ts @@ -167,6 +167,11 @@ test('stands a block a task item cannot hold, and what follows it in the item, a bare('codeBlock', text('x')), ]) assert.deepEqual(read('- [x] A\n - plain\n- [ ] B\n'), [bare('taskList', task('DONE', text('A'))), bare('bulletList', bare('listItem', said('plain'))), bare('taskList', task('TODO', text('B')))]) + assert.deepEqual(read('- [ ] A\n - [ ] Child\n\n # h\n- [ ] B\n'), [ + bare('taskList', task('TODO', text('A')), bare('taskList', task('TODO', text('Child')))), + node('heading', { level: 1 }, text('h')), + bare('taskList', task('TODO', text('B'))), + ]) assert.deepEqual(read('- [ ] A\n\n # h\n - [ ] Child\n- [ ] B\n'), [ bare('taskList', task('TODO', text('A'))), node('heading', { level: 1 }, text('h')), diff --git a/src/markdown/portable/adf-to-portable-markdown.ts b/src/markdown/portable/adf-to-portable-markdown.ts index 03e854c..a700323 100644 --- a/src/markdown/portable/adf-to-portable-markdown.ts +++ b/src/markdown/portable/adf-to-portable-markdown.ts @@ -304,17 +304,17 @@ function reduceTaskList(node: AdfNode, reduction: Reduction): Result continue } if (isTask(child)) { - nestIn(tasks, nested) + pushMerged(tasks, nested) nested = [] } for (const block of blocks) (isTask(child) ? tasks : nested).push(block) } - nestIn(tasks, nested) + pushMerged(tasks, nested) if (regular) return success(splitTaskList({ content: tasks, type: 'taskList' })) return success(listOf(nodeContent(tasksAsText({ content: tasks, type: 'taskList' })), 'bulletList')) } -function nestIn(tasks: AdfNode[], nested: readonly AdfNode[]): void { +function pushMerged(tasks: AdfNode[], nested: readonly AdfNode[]): void { for (const block of mergedLists(nested)) tasks.push(block) } diff --git a/src/markdown/portable/task-list.ts b/src/markdown/portable/task-list.ts index 3f5c21a..8b6f60b 100644 --- a/src/markdown/portable/task-list.ts +++ b/src/markdown/portable/task-list.ts @@ -1,7 +1,7 @@ import type { AdfNode } from '../../adf/document.ts' import { nodeContent } from '../../adf/document.ts' -// Atlassian's schema holds a block task item to paragraphs: from its first other block, the item's blocks and the lists beside it stand after the list, which resumes at the next task. +// Atlassian's schema holds a blockTaskItem to paragraphs and extensions, and nests a task list beside its item. export function splitTaskList(list: AdfNode): AdfNode[] { const blocks: AdfNode[] = [] let tasks: AdfNode[] = [] @@ -12,9 +12,13 @@ export function splitTaskList(list: AdfNode): AdfNode[] { let stands: readonly AdfNode[] = standing || !(isTask || child.type === 'taskList') ? [child] : [] if (child.type === 'blockTaskItem') { const content = nodeContent(child) - const held = content.findIndex((block) => block.type !== 'paragraph') - tasks.push(heldTask(child, held === -1 ? content : content.slice(0, held))) - stands = held === -1 ? [] : content.slice(held) + let held = 0 + while (content[held]?.type === 'paragraph') held += 1 + let nested = held + while (content[nested]?.type === 'taskList') nested += 1 + tasks.push(heldTask(child, content.slice(0, held))) + for (const taskList of content.slice(held, nested)) tasks.push(taskList) + stands = content.slice(nested) } else if (stands.length === 0) tasks.push(child) if (stands.length === 0) continue if (tasks.length > 0) blocks.push({ ...list, content: tasks }) diff --git a/todo.md b/todo.md index f7e2f55..4d3f181 100644 --- a/todo.md +++ b/todo.md @@ -75,10 +75,11 @@ Today `- a` then `+ b`, or `1.` then `1)`, reads as one list; CommonMark reads t 301 and 302). Goal 5 settles it for the lossless flavour too: CommonMark spells adjacent lists by changing the marker, so `adfToLosslessMarkdown` and `adfToPortableMarkdown` alternate `-` and `*` between adjacent bullet lists, a task list among them, and `.` and `)` between adjacent ordered -lists, and `!adf:listBreak` retires. Until then, a bullet list standing after a task list joins -it when written, so `adfToPortableMarkdown` writes every task's marker as text. `*` is the maintainer's pick (2026-10-04). Breaking: `MIGRATION.md`'s Readings and -Spellings tables gain their rows. Examples 301 and 302 lose their `pending` exceptions, and the -spelling leaves the README's "Seven CommonMark spellings" bullet, which counts one fewer. +lists, and `!adf:listBreak` retires. `*` is the maintainer's pick (2026-10-04). Breaking: +`MIGRATION.md`'s Readings and Spellings tables gain their rows. Examples 301 and 302 lose their +`pending` exceptions, and the spelling leaves the README's "Seven CommonMark spellings" bullet, +which counts one fewer. Until this lands, a bullet list written right after a task list would read +back as part of it, so `adfToPortableMarkdown` writes that task list's markers as text. ### 61. Have the emitter ask the inline reader how a line reads back, in place of `line-escaping.ts` predicting it.