From 16d8cb6c2e7335fb5e9327247c5707338d82dbe9 Mon Sep 17 00:00:00 2001 From: Lilleman auf Larv Date: Fri, 9 Oct 2026 13:16:22 +0200 Subject: [PATCH] Stand a block a task item cannot hold after its task list, in portableMarkdownToAdf and adfToPortableMarkdown --- AGENTS.md | 1 + CHANGELOG.md | 3 +- README.md | 2 +- docs/decisions.md | 13 ++++++++ src/markdown/parse/markdown-to-adf.ts | 12 +++---- .../parse/portable-markdown-to-adf.test.ts | 31 +++++++++++++---- .../portable/adf-to-portable-markdown.test.ts | 4 +-- .../portable/adf-to-portable-markdown.ts | 5 +-- src/markdown/portable/task-list.ts | 33 +++++++++++++++++++ todo.md | 12 ++----- 10 files changed, 87 insertions(+), 29 deletions(-) create mode 100644 src/markdown/portable/task-list.ts diff --git a/AGENTS.md b/AGENTS.md index 29d951b..0bbc619 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -28,6 +28,7 @@ In `docs/decisions.md`: - An image reads as an image only alone in its paragraph, else as its alt text linked; a link text holding no node that takes marks reads as its destination, then its hard breaks - Ids stay site-local - Portable task ids come from position +- A block a task item cannot hold stands after its task list - A callout title keeps its link targets - The portable flavour's spellings - The HTML dialect diff --git a/CHANGELOG.md b/CHANGELOG.md index d0cd693..e03b1cb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -52,7 +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. + pipe tables. A task item holds only paragraphs, as Atlassian's schema requires: from its first + other block, such as an image or a code block, the rest of the item 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 893d11e..952f871 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` | a bullet list whose every item is so marked, `[X]` too | +| `taskList` | `- [x] Done`, `- [ ] Todo`; an item keeps its paragraphs up to its first other block, which stands with the rest of the item after the list, the list resuming at the next task | a bullet list whose every item is so marked, `[X]` too; an item's blocks as written | | `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 81a8a71..870de75 100644 --- a/docs/decisions.md +++ b/docs/decisions.md @@ -247,6 +247,19 @@ editor's UUID v4 shape, hashed from the whole markdown and the node's order amon the same markdown reads to the same ids every run, different markdown to different ids. The same markdown pasted twice into one document repeats its ids: determinism wins over that case. +## A block a task item cannot hold stands after its task list + +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 +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. + ## A callout title keeps its link targets 2026-09-29, the maintainer. Goals 6 and 7. Valid while an expand's `title` is a string. diff --git a/src/markdown/parse/markdown-to-adf.ts b/src/markdown/parse/markdown-to-adf.ts index a82a275..0daa25b 100644 --- a/src/markdown/parse/markdown-to-adf.ts +++ b/src/markdown/parse/markdown-to-adf.ts @@ -8,7 +8,7 @@ import type { LinkDefinitions } from './inline-content.ts' import { carryFenceType, readCarriedBlock } from '../opaque-carry.ts' import { commonMarkSpelling, type SpellingMemo } from '../emit/adf-to-markdown.ts' import { documentAttribute, documentName, documentSpelling, listBreakName, listBreakSpelling } from '../block-directive.ts' -import { emptyKeys, nodeAttrs, nodeContent } from '../../adf/document.ts' +import { emptyKeys, nodeAttrs } from '../../adf/document.ts' import { failure, faulted, positioned, success, type ConvertErrorPath, type ParseError, type Result, type SourcePosition } from '../../result.ts' import { flavourClaims, leadingMarker, readAlertMarker, readTaskMarker } from '../portable/conventions.ts' import { languageSlot } from '../code-language.ts' @@ -19,6 +19,7 @@ import { parseInlineContent } from './inline-content.ts' import { readBlockDirectiveNode } from './directive-nodes.ts' import { spellStringAttribute, unsupportedNodeShape } from '../directive-syntax.ts' import { spellsEmpty } from '../empty-keys.ts' +import { splitTaskList } from '../portable/task-list.ts' type Paragraph = Extract @@ -82,7 +83,8 @@ function readBlocks(blocks: readonly ReadBlock[], reading: Reading, path: Conver } const node = positioned(readBlock(block, reading, nodePath, depth), block.position) if (!node.ok) return node - content.push(node.value) + if (node.value.type !== 'taskList' || block.kind !== 'bulletList') content.push(node.value) + else for (const split of splitTaskList(node.value)) content.push(split) } return success(content) } @@ -212,11 +214,7 @@ function bulletNode(items: readonly Block[][], reading: Reading, path: ConvertEr if (!read.ok) return read let beside = read.value.length while (read.value[beside - 1]?.type === 'taskList') beside -= 1 - const kept = read.value.slice(0, beside) - const [only] = kept - const attrs = { state: marker.state } - const inline = kept.length <= 1 && (only === undefined || only.type === 'paragraph') - tasks.push(inline ? { attrs, content: nodeContent(only ?? {}).slice(), type: 'taskItem' } : { attrs, content: kept, type: 'blockTaskItem' }) + 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) } 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 644e39e..86e95f8 100644 --- a/src/markdown/parse/portable-markdown-to-adf.test.ts +++ b/src/markdown/parse/portable-markdown-to-adf.test.ts @@ -145,12 +145,31 @@ test('reads a bullet list whose every item leads with a task marker to a task li ]) }) -test('moves a nested task list beside its item and makes an item holding more than one block a block task item', () => { +test('moves a nested task list beside its item and makes an item holding more than one paragraph a block task item', () => { assert.deepEqual(read('- [x] Parent\n - [ ] Child\n- [ ] Next\n'), [bare('taskList', task('DONE', text('Parent')), bare('taskList', task('TODO', text('Child'))), task('TODO', text('Next')))]) - assert.deepEqual(read('- [x] First.\n\n Second.\n- [ ]\n\n ```\n x\n ```\n'), [ - bare('taskList', node('blockTaskItem', { state: 'DONE' }, said('First.'), said('Second.')), node('blockTaskItem', { state: 'TODO' }, bare('codeBlock', text('x')))), + assert.deepEqual(read('- [x] First.\n\n Second.\n- [ ] Next\n'), [bare('taskList', node('blockTaskItem', { state: 'DONE' }, said('First.'), said('Second.')), task('TODO', text('Next')))]) +}) + +test('stands a block a task item cannot hold, and what follows it in the item, after the task list, which resumes at the next task', () => { + assert.deepEqual(read('- [x] Deploy\n\n ![a](u)\n- [ ] Tell\n'), [ + bare('taskList', task('DONE', text('Deploy'))), + node('mediaSingle', { layout: 'center' }, { attrs: { alt: 'a', type: 'external', url: 'u' }, type: 'media' }), + bare('taskList', task('TODO', text('Tell'))), + ]) + assert.deepEqual(read('- [x] First.\n\n Second.\n\n > q\n\n Third.\n- [ ]\n\n ```\n x\n ```\n'), [ + bare('taskList', node('blockTaskItem', { state: 'DONE' }, said('First.'), said('Second.'))), + bare('blockquote', said('q')), + said('Third.'), + bare('taskList', task('TODO')), + 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\n # h\n - [ ] Child\n- [ ] B\n'), [ + bare('taskList', task('TODO', text('A'))), + node('heading', { level: 1 }, text('h')), + bare('taskList', task('TODO', text('Child'))), + bare('taskList', task('TODO', text('B'))), ]) - assert.deepEqual(read('- [x] A\n - plain\n'), [bare('taskList', node('blockTaskItem', { state: 'DONE' }, said('A'), bare('bulletList', bare('listItem', said('plain')))))]) }) test('leaves mixed, ordered and unmarked lists plain', () => { @@ -209,9 +228,9 @@ test('reads what the reduction wrote back to the node it reduced, less the attri task('DONE', text('Write')), bare('taskList', task('TODO', text('Review'))), node('blockTaskItem', { state: 'TODO' }, said('First.'), said('Second.')), - node('blockTaskItem', { state: 'DONE' }, bare('codeBlock', text('x'))), + task('DONE'), ) - assert.deepEqual(roundTripped(tasks), [plainTasks]) + assert.deepEqual(roundTripped(tasks), [plainTasks, bare('codeBlock', text('x'))]) const colour: AdfMark = { attrs: { color: '#c6edfb' }, type: 'backgroundColor' } assert.deepEqual(roundTripped(paragraph(text('a '), text('hi', colour, strong), text(' b'))), [paragraph(text('a '), text('hi', highlight, strong), text(' b'))]) assert.deepEqual(roundTripped(paragraph(text('=', colour), text(' '), text('a==b', colour))), [paragraph(text('=', highlight), text(' '), text('a==b', highlight))]) diff --git a/src/markdown/portable/adf-to-portable-markdown.test.ts b/src/markdown/portable/adf-to-portable-markdown.test.ts index f26bee6..55e71ca 100644 --- a/src/markdown/portable/adf-to-portable-markdown.test.ts +++ b/src/markdown/portable/adf-to-portable-markdown.test.ts @@ -131,9 +131,9 @@ test('spells a task list as a bullet list whose items lead with their state', () assert.equal(portable(node('taskList', {}), task('TODO', 'Loose')), 'Loose\n') const blockTask = node('blockTaskItem', { state: 'DONE' }, said('First.'), said('Second.')) const codeTask = node('blockTaskItem', { state: 'TODO' }, { content: [text('x')], type: 'codeBlock' }) - assert.equal(portable(node('taskList', {}, blockTask, codeTask)), '- [x] First.\n\n Second.\n- [ ]\n\n ```\n x\n ```\n') + assert.equal(portable(node('taskList', {}, blockTask, codeTask)), '- [x] First.\n\n Second.\n- [ ]\n\n```\nx\n```\n') const listTask = node('blockTaskItem', { state: 'DONE' }, bulletList(item(said('a')))) - assert.equal(portable(node('taskList', {}, task('TODO', ''), listTask, node('taskList', {}, task('TODO', 'b')))), '- [ ]\n- [x]\n - a\n - \\[ ] b\n') + assert.equal(portable(node('taskList', {}, task('TODO', ''), listTask, node('taskList', {}, task('TODO', 'b')))), '- \\[ ]\n- \\[x]\n- a\n- \\[ ] b\n') assert.equal(portable(node('taskList', {}, task('DONE', 'a'), node('taskList', {}, task('TODO', '')))), '- [x] a\n - [ ]\n') }) diff --git a/src/markdown/portable/adf-to-portable-markdown.ts b/src/markdown/portable/adf-to-portable-markdown.ts index 8a1a228..7fe393c 100644 --- a/src/markdown/portable/adf-to-portable-markdown.ts +++ b/src/markdown/portable/adf-to-portable-markdown.ts @@ -9,6 +9,7 @@ import { inlineLeaves, isBlockNodeType, oneLine, reduceInline, writableHref } fr import { inlineNodeModel } from '../../adf/inline-nodes.ts' import { languageSlot } from '../code-language.ts' import { largestNesting } from '../../nesting.ts' +import { splitTaskList } from './task-list.ts' import { toEditorNormal } from './editor-normal.ts' // depth: the level the node reduced stands at, counted as the emitter counts it. @@ -309,7 +310,7 @@ function reduceTaskList(node: AdfNode, reduction: Reduction): Result for (const block of blocks) (isTask(child) ? tasks : nested).push(block) } nestIn(tasks, nested) - if (regular) return success([{ content: tasks, type: 'taskList' }]) + if (regular) return success(splitTaskList({ content: tasks, type: 'taskList' })) return success(listOf(nodeContent(tasksAsText({ content: tasks, type: 'taskList' })), 'bulletList')) } @@ -331,7 +332,7 @@ function reduceTask(task: AdfNode, at: Reduction): Result { return content.ok ? success([{ attrs, content: content.value, type: 'taskItem' }]) : content } const blocks = reduceBlocks(nodeContent(task), at) - return blocks.ok ? success([{ attrs, content: blankedCode(blocks.value), type: 'blockTaskItem' }]) : blocks + return blocks.ok ? success([{ attrs, content: blocks.value, type: 'blockTaskItem' }]) : blocks } // The marker leads the first paragraph, or stands as one where the blocks open with another. diff --git a/src/markdown/portable/task-list.ts b/src/markdown/portable/task-list.ts new file mode 100644 index 0000000..3f5c21a --- /dev/null +++ b/src/markdown/portable/task-list.ts @@ -0,0 +1,33 @@ +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. +export function splitTaskList(list: AdfNode): AdfNode[] { + const blocks: AdfNode[] = [] + let tasks: AdfNode[] = [] + let standing = false + for (const child of nodeContent(list)) { + const isTask = child.type === 'taskItem' || child.type === 'blockTaskItem' + if (isTask) standing = false + 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) + } else if (stands.length === 0) tasks.push(child) + if (stands.length === 0) continue + if (tasks.length > 0) blocks.push({ ...list, content: tasks }) + tasks = [] + for (const block of stands) blocks.push(block) + standing = true + } + if (tasks.length > 0) blocks.push({ ...list, content: tasks }) + return blocks +} + +function heldTask(task: AdfNode, paragraphs: readonly AdfNode[]): AdfNode { + const [only] = paragraphs + if (paragraphs.length > 1) return { ...task, content: [...paragraphs] } + return { ...task, content: nodeContent(only ?? {}).slice(), type: 'taskItem' } +} diff --git a/todo.md b/todo.md index 1e86bda..de235be 100644 --- a/todo.md +++ b/todo.md @@ -25,7 +25,6 @@ | ID | Release | Exempt | Item | R | S | A | G | Goals | Score | |---|---|---|---|---|---|---|---|---|---| -| 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 | | 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 | @@ -68,20 +67,13 @@ ## Details -### 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` -one holding a `blockquote`; the schema's `blockTaskItem_node` takes paragraphs and extensions only -(`spec/adf-schema/full.json`), so a site may reject the document. The reader needs a reading for -the other blocks, settled by Goals 4 and 7. - ### 49. Read a list whose bullet or ordered delimiter changes as two lists, in every reader, and retire `!adf:listBreak`. Today `- a` then `+ b`, or `1.` then `1)`, reads as one list; CommonMark reads two (spec examples 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 and `.` and `)` between adjacent ordered lists, and `!adf:listBreak` -retires. `*` is the maintainer's pick (2026-10-04). Breaking: `MIGRATION.md`'s Readings and +between adjacent bullet lists, a task list among them, and `.` and `)` between adjacent ordered +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.