3
0
mirror of https://github.com/larvit/adf-codec.git synced 2026-11-13 04:08:29 +01:00

Stand a block a task item cannot hold after its task list, in portableMarkdownToAdf and adfToPortableMarkdown

This commit is contained in:
2026-10-09 13:16:22 +02:00
parent 1870111427
commit 16d8cb6c2e
10 changed files with 87 additions and 29 deletions
+1
View File
@@ -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
+2 -1
View File
@@ -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
+1 -1
View File
@@ -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 | — |
+13
View File
@@ -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.
+5 -7
View File
@@ -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<Block, { kind: 'paragraph' }>
@@ -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' })
@@ -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))])
@@ -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')
})
@@ -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<AdfNode[]>
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<AdfNode[]> {
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.
+33
View File
@@ -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' }
}
+2 -10
View File
@@ -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.