From a38b61347a6d387f42378e955bb7213367c517bd Mon Sep 17 00:00:00 2001 From: Lilleman auf Larv Date: Sun, 11 Oct 2026 11:53:03 +0200 Subject: [PATCH] Report a refusal's path where the node stands once the task list moves it --- src/markdown/parse/markdown-to-adf.ts | 64 +++++++++++++++---- .../parse/portable-markdown-to-adf.test.ts | 19 ++++++ src/markdown/portable/task-list.ts | 16 +++++ todo.md | 8 --- 4 files changed, 88 insertions(+), 19 deletions(-) diff --git a/src/markdown/parse/markdown-to-adf.ts b/src/markdown/parse/markdown-to-adf.ts index c8f56f8..55d5ee3 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 } from '../block-directive.ts' -import { emptyKeys, nodeAttrs } from '../../adf/document.ts' +import { emptyKeys, nodeAttrs, nodeContent } 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,7 +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' +import { splitPlace, splitTaskList } from '../portable/task-list.ts' type Paragraph = Extract @@ -68,7 +68,7 @@ function typeName(value: unknown): string { } function readBlocks(blocks: readonly ReadBlock[], reading: Reading, path: ConvertErrorPath, depth: number): Result { - if (depth > largestNesting) return failure('unsupported-nesting-depth', `the input nests deeper than the ${largestNesting} levels the parser carries`, path) + if (depth > largestNesting) return tooDeep(path) const content: AdfNode[] = [] for (const block of blocks) { const nodePath = [...path, 'content', content.length] @@ -89,6 +89,10 @@ function readBlocks(blocks: readonly ReadBlock[], reading: Reading, path: Conver return success(content) } +function tooDeep(path: ConvertErrorPath): Result { + return failure('unsupported-nesting-depth', `the input nests deeper than the ${largestNesting} levels the parser carries`, path) +} + function documentFault(block: DirectiveBlock): ConvertFault | undefined { const spelled = block.attributes.get(documentAttribute.key) if (block.argument === undefined && block.attributes.size === 1 && spelled?.spelling === documentAttribute.value) return undefined @@ -186,27 +190,65 @@ function filledNode(node: AdfNode, content: Result): Result return success({ ...node, content: content.value.length === 0 ? [{ type: 'paragraph' }] : content.value }) } -function bulletBlocks(items: readonly Block[][], reading: Reading, path: ConvertErrorPath, depth: number): Result { +function taskItems(items: readonly Block[][]) { const led = [] for (const [first, ...others] of items) { const marked = markerLed(first, readTaskMarker) - if (marked === undefined) { - const list = listNode({ type: 'bulletList' }, items, reading, path, depth) - return list.ok ? success([list.value]) : list - } + if (marked === undefined) return undefined led.push({ ...marked, others }) } + return led +} + +function bulletBlocks(items: readonly Block[][], reading: Reading, path: ConvertErrorPath, depth: number): Result { + const led = taskItems(items) + if (led === undefined) { + const list = listNode({ type: 'bulletList' }, items, reading, path, depth) + return list.ok ? success([list.value]) : list + } + if (depth + 1 > largestNesting) return tooDeep([...path, 'content', 0]) const tasks: AdfNode[] = [] for (const [index, { marker, others, position, text }] of led.entries()) { const body = text.replace(/^(?:[ \t\n]|\\\n)+/, '') 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 - tasks.push({ attrs: { state: marker.state }, content: read.value, type: 'blockTaskItem' }) + const blocks = [...marked, ...others] + const task: AdfNode & { content: AdfNode[] } = { attrs: { state: marker.state }, content: [], type: 'blockTaskItem' } + for (const [at, block] of blocks.entries()) { + const read = readBlocks([block], reading, [...path, 'content', index], depth + 1) + if (!read.ok) return { error: { ...read.error, path: splitPath(read.error.path, { block, next: blocks[at + 1], path, reading, task, tasks }) }, ok: false } + for (const node of read.value) task.content.push(node) + } + tasks.push(task) } return success(splitTaskList({ content: tasks, type: 'taskList' })) } +type Refusal = { block: ReadBlock; next: ReadBlock | undefined; path: ConvertErrorPath; reading: Reading; task: AdfNode; tasks: readonly AdfNode[] } + +// splitTaskList moves a task's children, so a refusal in `block` names where its node would stand: in place of the node, the split places a stand-in of its type. +function splitPath(refused: ConvertErrorPath, { block, next, path, reading, task, tasks }: Refusal): ConvertErrorPath { + const [, offset, ...within] = refused.slice(path.length + 2) + const listIndex = path.at(-1) + if (typeof offset !== 'number' || typeof listIndex !== 'number') return refused + // A nested task list reads as a task list, then a block the split moves out, so a node past its first stands outside the task. + const before = Array.from({ length: offset }, (_, at): AdfNode => ({ type: at === 0 ? 'taskList' : 'bulletList' })) + const node: AdfNode & { content: [AdfNode] } = { content: [{ text: '', type: 'text' }], type: offset === 0 ? childType(block, reading) : 'bulletList' } + const after = next === undefined ? [] : [{ type: childType(next, reading) }] + const list = { content: [...tasks, { ...task, content: [...nodeContent(task), ...before, node, ...after] }], type: 'taskList' } + const place = splitPlace(list, node) + if (place === undefined) return refused + const [blockIndex, ...steps] = place + return [...path.slice(0, -1), listIndex + blockIndex, ...steps, ...within] +} + +// A task keeps only paragraphs and task lists, so another block's kind stands in for its node's type. +function childType(block: ReadBlock, reading: Reading): string { + if (block.kind === 'bulletList') return taskItems(block.items) === undefined ? 'bulletList' : 'taskList' + if (block.kind !== 'paragraph') return block.kind === 'taskParagraph' ? 'paragraph' : block.kind + const read = paragraphNode(block.text, reading, []) + return read.ok ? read.value.type : 'paragraph' +} + function directiveNode(block: DirectiveBlock, reading: Reading, path: ConvertErrorPath, depth: number): Result { const read = readBlockDirectiveNode(block.name, block.argument, block.attributes, path) if (!read.ok) return read diff --git a/src/markdown/parse/portable-markdown-to-adf.test.ts b/src/markdown/parse/portable-markdown-to-adf.test.ts index 5f8ca02..6738b6b 100644 --- a/src/markdown/parse/portable-markdown-to-adf.test.ts +++ b/src/markdown/parse/portable-markdown-to-adf.test.ts @@ -180,6 +180,25 @@ test('stands a block a task item cannot hold, and what follows it in the item, a ]) }) +test('names the place a refused block would stand once the task list moves it', () => { + const refused = (markdown: string): string => { + const parsed = portableMarkdownToAdf(markdown) + return parsed.ok ? 'read' : parsed.error.path.map((step) => '/' + step).join('') + } + assert.equal(refused('- [x] A\n\n ```\n x\n ```\n\n
x
\n'), '/content/2') + assert.equal(refused('- [ ] a x\n'), '/content/0/content/0') + assert.equal(refused('- [ ] a x\n\n more\n'), '/content/0/content/0/content/0') + assert.equal(refused('- [ ] a x\n\n ![i](u)\n'), '/content/0/content/0') + assert.equal(refused('- [ ] A\n\n b x\n'), '/content/0/content/0/content/1') + assert.equal(refused('- [ ] A\n\n # h\n\n b x\n'), '/content/2') + assert.equal(refused('- [ ] A\n\n # h\n- [ ] B x\n'), '/content/2/content/0') + assert.equal(refused('- [ ] A\n- [ ] B\n\n # h\n- [ ] C x\n'), '/content/2/content/0') + assert.equal(refused('- [ ] A\n - [ ] C x\n'), '/content/0/content/1/content/0') + assert.equal(refused('- [ ] A\n\n # h\n - [ ] C x\n'), '/content/2/content/0') + assert.equal(refused('- [ ] A\n - [ ] C\n\n # h\n\n
x
\n'), '/content/2') + assert.equal(refused('> q\n>\n> - [ ] A\n>\n> # h\n>\n>
x
\n'), '/content/0/content/3') +}) + test('reads a task list beside a list of another bullet character as two lists', () => { const tasks = (state: string, value: string): AdfNode => bare('taskList', node('taskItem', { state }, text(value))) assert.deepEqual(read('- [x] a\n* b\n'), [tasks('DONE', 'a'), bare('bulletList', bare('listItem', bare('paragraph', text('b'))))]) diff --git a/src/markdown/portable/task-list.ts b/src/markdown/portable/task-list.ts index 03c61ff..dc61b5f 100644 --- a/src/markdown/portable/task-list.ts +++ b/src/markdown/portable/task-list.ts @@ -1,4 +1,5 @@ import type { AdfNode } from '../../adf/document.ts' +import type { ConvertErrorPath } from '../../result.ts' import { isTaskItem } from '../../adf/block-nodes.ts' import { nodeContent } from '../../adf/document.ts' @@ -18,6 +19,21 @@ export function splitTaskList(list: AdfNode): AdfNode[] { return blocks } +// Where `node`, a child of one of the list's tasks, stands among the blocks splitTaskList returns; a lone paragraph, holding its inline node, stands as its taskItem. +export function splitPlace(list: AdfNode, node: AdfNode & { content: [AdfNode] }): [number, ...ConvertErrorPath] | undefined { + const [inline] = node.content + const places = splitTaskList(list).flatMap((block, index): [number, ...ConvertErrorPath][] => { + if (block === node) return [[index]] + if (block.type !== 'taskList') return [] + return nodeContent(block).flatMap((item, child): [number, ...ConvertErrorPath][] => { + if (item === node || nodeContent(item)[0] === inline) return [[index, 'content', child]] + const at = nodeContent(item).indexOf(node) + return at === -1 ? [] : [[index, 'content', child, 'content', at]] + }) + }) + return places[0] +} + // An item is a task and the children up to the next one. function itemsOf(children: readonly AdfNode[]): AdfNode[][] { const items: AdfNode[][] = [] diff --git a/todo.md b/todo.md index f4f2161..7681406 100644 --- a/todo.md +++ b/todo.md @@ -25,7 +25,6 @@ | ID | Release | Exempt | Item | R | S | A | G | Goals | Score | |---|---|---|---|---|---|---|---|---|---| -| 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 | | 89 | 0.2.1 | | **Build a `date`, `emoji`, `inlineCard`, `mention` or `status` holding no mark Atlassian's schema withholds from it, in every reader.** | 4 | 4 | 5 | 6 | 4 | 13.0 | @@ -70,13 +69,6 @@ ## Details -### 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 -`- [x] A\n\n ```\n x\n ```\n\n
x
\n` reports `/content/0/content/0/content/2` -for a block that would stand at `/content/2`. The README says `path` is the node's place from the -document root. - ### 86. 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`. Every reader reads `[![alt](url)](href)` and `![alt](url "caption")` as these shapes, and