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:
@@ -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
@@ -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
|
||||
|
||||
@@ -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 | — |
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 \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.
|
||||
|
||||
@@ -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' }
|
||||
}
|
||||
@@ -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 ` 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.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user