From a99c5b17c8bf54c74a1aafa1f8585363c0380d94 Mon Sep 17 00:00:00 2001 From: Lilleman auf Larv Date: Fri, 9 Oct 2026 14:15:40 +0200 Subject: [PATCH] Answer architecture round 1: splitTaskList groups a list into items, bulletBlocks returns the split blocks itself, isTaskItem lives in adf/, a property reads generated task lists through portableMarkdownToAdf, and items 56 and 93 name today's code --- src/adf/block-nodes.ts | 5 ++ src/conformance/markdown-property.test.ts | 37 +++++++++++- src/markdown/emit/adf-to-markdown.ts | 4 +- src/markdown/parse/markdown-to-adf.ts | 20 +++++-- .../portable/adf-to-portable-markdown.ts | 19 +++--- src/markdown/portable/task-list.ts | 59 ++++++++++++------- todo.md | 10 ++-- 7 files changed, 111 insertions(+), 43 deletions(-) diff --git a/src/adf/block-nodes.ts b/src/adf/block-nodes.ts index 7db6457..6b1f90b 100644 --- a/src/adf/block-nodes.ts +++ b/src/adf/block-nodes.ts @@ -1,3 +1,4 @@ +import type { AdfNode } from './document.ts' import type { AttributeVocabulary } from './attribute-vocabulary.ts' export type BlockNodeModel = { @@ -88,6 +89,10 @@ export function blockNodeModel(type: string): BlockNodeModel | undefined { return isBlockType(type) ? blockNodes[type] : undefined } +export function isTaskItem(node: AdfNode | undefined): boolean { + return node?.type === 'taskItem' || node?.type === 'blockTaskItem' +} + function isBlockType(type: string): type is BlockType { return Object.hasOwn(blockNodes, type) } diff --git a/src/conformance/markdown-property.test.ts b/src/conformance/markdown-property.test.ts index 1a7b93c..ffefbff 100644 --- a/src/conformance/markdown-property.test.ts +++ b/src/conformance/markdown-property.test.ts @@ -9,10 +9,11 @@ import type { JsonValue } from '../json-value.ts' import type { Result } from '../result.ts' import { adfDocument, attributes, jsonKey, jsonValue, markdownPieces, propertyRuns, propertyTimeout, textOf } from './property-harness.ts' import { adfToLosslessMarkdown } from '../markdown/emit/adf-to-markdown.ts' +import { adfToPortableMarkdown } from '../markdown/portable/adf-to-portable-markdown.ts' import { blockArgument, listBreakName, marksAttribute } from '../markdown/block-directive.ts' import { blockNodes } from '../adf/block-nodes.ts' import { carryName } from '../markdown/opaque-carry.ts' -import { commonMarkToAdf, losslessMarkdownToAdf } from '../markdown/parse/markdown-to-adf.ts' +import { commonMarkToAdf, losslessMarkdownToAdf, portableMarkdownToAdf } from '../markdown/parse/markdown-to-adf.ts' import { directivePrefix, spellAttributes, @@ -409,6 +410,24 @@ const document = fc .tuple(markdown, fc.oneof({ arbitrary: fc.constant('\n'), weight: 8 }, { arbitrary: fc.constantFrom('\r\n', '\r'), weight: 1 }), fc.constantFrom('', '', '\n', ' \n')) .map(([held, ending, trailing]) => `${held}${trailing}`.replaceAll('\n', ending)) +function taskListOf(bodies: Arbitrary): Arbitrary { + return fc + .array(fc.tuple(fc.constantFrom('[ ]', '[x]'), fc.constantFrom('', 'Done\n\n', 'Done\n'), bodies), { maxLength: 3, minLength: 1 }) + .map((items) => items.map(([marker, lead, body]) => prefixLines(`${lead}${body}`, `- ${marker} `, () => ' '.slice(0, marker.length + 3))).join('\n')) +} + +const taskMarkdown = taskListOf(fc.oneof(cleanMarkdown, hostileMarkdown, taskListOf(cleanMarkdown))) + +function blockTaskItemChildren(document: AdfDocument): string[] { + const types: string[] = [] + const pending = [...nodeContent(document)] + for (let node = pending.pop(); node !== undefined; node = pending.pop()) { + if (node.type === 'blockTaskItem') for (const child of nodeContent(node)) types.push(child.type) + pending.push(...nodeContent(node)) + } + return types +} + function holdsDirectiveShape(document: AdfDocument): boolean { const pending = [...nodeContent(document)] for (let node = pending.pop(); node !== undefined; node = pending.pop()) { @@ -458,3 +477,19 @@ test('generated markdown read as CommonMark refuses, or its document reads back propertyRuns(gateRuns), ) }) + +test('generated task lists read as portable markdown refuse, or build block task items of paragraphs alone and write a fixpoint', { timeout: propertyTimeout }, () => { + fc.assert( + fc.property(taskMarkdown, (input) => { + const parsed = portableMarkdownToAdf(input) + if (!parsed.ok) return + assert.deepEqual(blockTaskItemChildren(parsed.value).filter((type) => type !== 'paragraph'), [], `reading ${JSON.stringify(input)}`) + const written = adfToPortableMarkdown(parsed.value) + assert.ok(written.ok, written.ok ? '' : `${written.error.code}: ${written.error.message} — spelling ${JSON.stringify(input)}`) + const read = portableMarkdownToAdf(written.value) + assert.ok(read.ok, read.ok ? '' : `${read.error.code}: ${read.error.message} — reading ${JSON.stringify(written.value)}`) + assert.deepEqual(adfToPortableMarkdown(read.value), written, `reading ${JSON.stringify(written.value)}`) + }), + propertyRuns(gateRuns), + ) +}) diff --git a/src/markdown/emit/adf-to-markdown.ts b/src/markdown/emit/adf-to-markdown.ts index 98913b4..00f4008 100644 --- a/src/markdown/emit/adf-to-markdown.ts +++ b/src/markdown/emit/adf-to-markdown.ts @@ -4,7 +4,7 @@ import type { WrittenFlavour } from '../portable/conventions.ts' import { adfDocumentFault, holdsOnlyAttributes, isUnmarkedSpellableText, nodeAttrs, nodeContent } from '../../adf/document.ts' import { alertMarker, flavourClaims, foldedAlertMarker, leadingMarker, readAlertMarker, readTaskMarker, taskMarker } from '../portable/conventions.ts' import { blockDirectiveForm, documentSpelling, listBreakSpelling } from '../block-directive.ts' -import { blockNodeModel, blockNodes } from '../../adf/block-nodes.ts' +import { blockNodeModel, blockNodes, isTaskItem } from '../../adf/block-nodes.ts' import { carriedBlock } from '../opaque-carry.ts' import { emitInlineLine } from './inline-line.ts' import { failure, faulted, success, type ConvertErrorPath, type Result } from '../../result.ts' @@ -167,7 +167,7 @@ function tryTaskList(node: AdfNode, path: ConvertErrorPath, depth: number, writi let headroom = largestNesting - depth - 1 // A child other than a task nests in the task before it. for (const [index, child] of nodeContent(node).entries()) { - const task = child.type === 'taskItem' || child.type === 'blockTaskItem' + const task = isTaskItem(child) const walk = task ? taskBlocks(child, [...path, 'content', index], depth + 1, writing) : placedBlock(child, [...path, 'content', index], depth + 1, writing) if (!walk.ok) return walk headroom = Math.min(headroom, walk.value.headroom) diff --git a/src/markdown/parse/markdown-to-adf.ts b/src/markdown/parse/markdown-to-adf.ts index 4f43a2c..db788f7 100644 --- a/src/markdown/parse/markdown-to-adf.ts +++ b/src/markdown/parse/markdown-to-adf.ts @@ -81,10 +81,15 @@ function readBlocks(blocks: readonly ReadBlock[], reading: Reading, path: Conver if (fault !== undefined) return positioned(faulted(fault, nodePath), block.position) continue } + if (block.kind === 'bulletList' && reading.claims.taskMarkers) { + const listed = positioned(bulletBlocks(block.items, reading, nodePath, depth), block.position) + if (!listed.ok) return listed + for (const node of listed.value) content.push(node) + continue + } const node = positioned(readBlock(block, reading, nodePath, depth), block.position) if (!node.ok) return node - if (node.value.type !== 'taskList' || block.kind !== 'bulletList') content.push(node.value) - else for (const split of splitTaskList(node.value)) content.push(split) + content.push(node.value) } return success(content) } @@ -116,7 +121,7 @@ function readBlock(block: ReadBlock, reading: Reading, path: ConvertErrorPath, d case 'blockquote': return reading.claims.alerts ? quoteNode(block.blocks, reading, path, depth) : containerNode({ type: 'blockquote' }, block.blocks, reading, path, depth) case 'bulletList': - return reading.claims.taskMarkers ? bulletNode(block.items, reading, path, depth) : listNode({ type: 'bulletList' }, block.items, reading, path, depth) + return listNode({ type: 'bulletList' }, block.items, reading, path, depth) case 'code': return codeBlockNode(block.language, block.text, reading, path, depth) case 'directive': @@ -198,11 +203,14 @@ function filledNode(node: AdfNode, content: Result): Result return success({ ...node, content: content.value.length === 0 ? [{ type: 'paragraph' }] : content.value }) } -function bulletNode(items: readonly Block[][], reading: Reading, path: ConvertErrorPath, depth: number): Result { +function bulletBlocks(items: readonly Block[][], reading: Reading, path: ConvertErrorPath, depth: number): Result { const led = [] for (const [first, ...others] of items) { const marked = markerLed(first, readTaskMarker) - if (marked === undefined) return listNode({ type: 'bulletList' }, items, reading, path, depth) + if (marked === undefined) { + const list = listNode({ type: 'bulletList' }, items, reading, path, depth) + return list.ok ? success([list.value]) : list + } led.push({ ...marked, others }) } const tasks: AdfNode[] = [] @@ -213,7 +221,7 @@ function bulletNode(items: readonly Block[][], reading: Reading, path: ConvertEr if (!read.ok) return read tasks.push({ attrs: { state: marker.state }, content: read.value, type: 'blockTaskItem' }) } - return success({ content: tasks, type: 'taskList' }) + return success(splitTaskList({ content: tasks, type: 'taskList' })) } function directiveNode(block: DirectiveBlock, reading: Reading, path: ConvertErrorPath, depth: number): Result { diff --git a/src/markdown/portable/adf-to-portable-markdown.ts b/src/markdown/portable/adf-to-portable-markdown.ts index b96c6e4..5cce06b 100644 --- a/src/markdown/portable/adf-to-portable-markdown.ts +++ b/src/markdown/portable/adf-to-portable-markdown.ts @@ -1,6 +1,6 @@ import type { AdfDocument, AdfNode } from '../../adf/document.ts' import { adfDocumentFault, nodeAttrs, nodeContent } from '../../adf/document.ts' -import { blockNodeModel } from '../../adf/block-nodes.ts' +import { blockNodeModel, isTaskItem } from '../../adf/block-nodes.ts' import { centeredImage, externalMedia } from '../external-image.ts' import { commonMarkSpelling, largestListMarker, writeMarkdown, type SpellingMemo } from '../emit/adf-to-markdown.ts' import { failure, faulted, success, type ConvertErrorPath, type Result } from '../../result.ts' @@ -179,7 +179,7 @@ function tasksAsText(list: AdfNode): AdfNode { if (list.type !== 'taskList') return list const items: AdfNode[] = [] for (const child of nodeContent(list)) { - const previous = isTask(child) || child.type === 'listItem' ? undefined : items.pop() + const previous = isTaskItem(child) || child.type === 'listItem' ? undefined : items.pop() items.push(itemOf(previous === undefined ? taskAsText(child) : mergedLists([...nodeContent(previous), child]))) } return { content: items, type: 'bulletList' } @@ -285,27 +285,28 @@ function blankedLines(code: readonly AdfNode[]): AdfNode[] { return blanked === '' ? [] : [text(blanked)] } -// A task list opening with a task and holding tasks and task lists alone keeps its spelling, a list nesting in the task before it; any other keeps its markers as text. A child reducing to nothing counts for neither. +// A task list opening with a task and holding tasks and task lists alone keeps its spelling; any other keeps its markers as text. A child reducing to nothing counts for neither. function reduceTaskList(node: AdfNode, reduction: Reduction): Result { const kept: { blocks: AdfNode[]; child: AdfNode }[] = [] for (const [index, child] of nodeContent(node).entries()) { const at = childReduction(reduction, index) - const reduced = isTask(child) ? reduceTask(child, at) : reduceStanding(child, at) + const reduced = isTaskItem(child) ? reduceTask(child, at) : reduceStanding(child, at) if (!reduced.ok) return reduced if (reduced.value.length > 0) kept.push({ blocks: reduced.value, child }) } - const regular = isTask(kept[0]?.child) && kept.every(({ child }) => isTask(child) || child.type === 'taskList') + const regular = isTaskItem(kept[0]?.child) && kept.every(({ child }) => isTaskItem(child) || child.type === 'taskList') const tasks: AdfNode[] = [] for (const { blocks, child } of kept) { - const standsAlone = !regular && !isTask(child) && child.type !== 'taskList' + const standsAlone = !regular && !isTaskItem(child) && child.type !== 'taskList' for (const block of standsAlone ? [{ content: blocks, type: 'listItem' }] : blocks) tasks.push(block) } - if (regular) return success(splitTaskList({ content: tasks, type: 'taskList' }).map((block) => (block.type === 'taskList' ? { ...block, content: mergedLists(nodeContent(block)) } : block))) + if (regular) return success(splitTasks({ content: tasks, type: 'taskList' })) return success(listOf(nodeContent(tasksAsText({ content: tasks, type: 'taskList' })), 'bulletList')) } -function isTask(node: AdfNode | undefined): boolean { - return node?.type === 'taskItem' || node?.type === 'blockTaskItem' +// The split sets a task's nested lists beside the list after it, and adjacent lists read back as one. +function splitTasks(list: AdfNode): AdfNode[] { + return splitTaskList(list).map((block) => (block.type === 'taskList' ? { ...block, content: mergedLists(nodeContent(block)) } : block)) } function reduceTask(task: AdfNode, at: Reduction): Result { diff --git a/src/markdown/portable/task-list.ts b/src/markdown/portable/task-list.ts index 8b6f60b..3b0f526 100644 --- a/src/markdown/portable/task-list.ts +++ b/src/markdown/portable/task-list.ts @@ -1,36 +1,55 @@ import type { AdfNode } from '../../adf/document.ts' +import { isTaskItem } from '../../adf/block-nodes.ts' import { nodeContent } from '../../adf/document.ts' -// Atlassian's schema holds a blockTaskItem to paragraphs and extensions, and nests a task list beside its item. +// Atlassian's schema holds a blockTaskItem to paragraphs and extensions, and the portable flavour spells no extension: an item keeps its paragraphs, then the task lists nested in it, and the rest of it stands 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) - 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 + for (const item of itemsOf(nodeContent(list))) { + const { kept, rest } = itemParts(item) + for (const node of kept) tasks.push(node) + if (rest.length === 0) continue if (tasks.length > 0) blocks.push({ ...list, content: tasks }) tasks = [] - for (const block of stands) blocks.push(block) - standing = true + for (const block of rest) blocks.push(block) } if (tasks.length > 0) blocks.push({ ...list, content: tasks }) return blocks } -function heldTask(task: AdfNode, paragraphs: readonly AdfNode[]): AdfNode { +// An item is a task and the children up to the next one. +function itemsOf(children: readonly AdfNode[]): AdfNode[][] { + const items: AdfNode[][] = [] + for (const child of children) { + const item = items.at(-1) + if (item === undefined || isTaskItem(child)) items.push([child]) + else item.push(child) + } + return items +} + +function itemParts(item: readonly AdfNode[]): { kept: AdfNode[]; rest: AdfNode[] } { + const [task] = item + if (task === undefined || !isTaskItem(task)) return { kept: [], rest: [...item] } + const content = task.type === 'blockTaskItem' ? nodeContent(task) : [] + const paragraphs = runEnd(content, 0, 'paragraph') + const nested = runEnd(content, paragraphs, 'taskList') + const unheld = content.slice(nested) + const after = item.slice(1) + const beside = unheld.length === 0 ? runEnd(after, 0, 'taskList') : 0 + const head = task.type === 'blockTaskItem' ? taskOfParagraphs(task, content.slice(0, paragraphs)) : task + return { kept: [head, ...content.slice(paragraphs, nested), ...after.slice(0, beside)], rest: [...unheld, ...after.slice(beside)] } +} + +function runEnd(nodes: readonly AdfNode[], start: number, type: string): number { + let end = start + while (nodes[end]?.type === type) end += 1 + return end +} + +// One paragraph or none reads as a taskItem. +function taskOfParagraphs(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 4d3f181..904d302 100644 --- a/todo.md +++ b/todo.md @@ -91,10 +91,10 @@ of one character from each of its characters. ### 93. Report a refusal's `path` where the node would stand after `portableMarkdownToAdf` moves it out of a task item. -`readBlocks` fixes a block's path before `splitTaskList` and `bulletNode` move blocks beside or -after their 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. +`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`. @@ -354,7 +354,7 @@ functions write `pending`, `pieces`, `deactivatedBefore` and `openingSpellableLi `mintTaskIds` (`portable/task-ids.ts`), which writes `attrs` on the document `readDocument` built; `takeFallback` (`emit/inline-line.ts`), which writes the record the next `lineSegments` pass reads; `writeUnpaired`, `markPairings` and `markHighlights` (`inline-content.ts`), whose order changes the -output; and `nestIn` (`portable/adf-to-portable-markdown.ts`), which pushes into its caller's array. +output. Once `Scan`'s pieces have owners, the bracket resolution — `linkedAlt` through `linkTo`, `holdsLink`, and `closeBracket` through `altText` — can leave `inline-content.ts` (about 690 lines) for a module of its own.