3
0
mirror of https://github.com/larvit/adf-codec.git synced 2026-11-13 12:18:28 +01:00

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

This commit is contained in:
2026-10-09 14:15:40 +02:00
parent 90dfe71bbe
commit a99c5b17c8
7 changed files with 111 additions and 43 deletions
+5
View File
@@ -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)
}
+36 -1
View File
@@ -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<string>): Arbitrary<string> {
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),
)
})
+2 -2
View File
@@ -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)
+14 -6
View File
@@ -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<AdfNode[]>): Result<AdfNode>
return success({ ...node, content: content.value.length === 0 ? [{ type: 'paragraph' }] : content.value })
}
function bulletNode(items: readonly Block[][], reading: Reading, path: ConvertErrorPath, depth: number): Result<AdfNode> {
function bulletBlocks(items: readonly Block[][], reading: Reading, path: ConvertErrorPath, depth: number): Result<AdfNode[]> {
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<AdfNode> {
@@ -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<AdfNode[]> {
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<AdfNode[]> {
+39 -20
View File
@@ -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' }
+5 -5
View File
@@ -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 <div>x</div>\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 <div>x</div>\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.