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

Group a task list into typed task items in one place, refusing a list a non-task opens

This commit is contained in:
2026-11-09 05:58:38 +01:00
committed by Lillem4n
parent 48ee494a69
commit 6b2693d385
7 changed files with 85 additions and 50 deletions
@@ -12,7 +12,7 @@ import { largestNesting } from '../../nesting.ts'
import { linkAttrs } from '../../adf/mark-attributes.ts'
import { nodeAttrs, nodeContent, nodeMarks, writableContent } from '../../adf/document.ts'
import { portableWriting } from './block-spelling.ts'
import { splitTaskList } from './task-list.ts'
import { splitTaskList, taskListItems } from './task-list.ts'
import { taskMarker } from './conventions.ts'
import { editorNormalForm, sourceIndex, toEditorNormal } from './editor-normal.ts'
import { withinEngineLimits } from '../../length-bound.ts'
@@ -269,14 +269,14 @@ function reduceTaskList(node: AdfNode, reduction: Reduction): Result<AdfNode[]>
if (!reduced.ok) return reduced
if (reduced.value.length > 0) kept.push({ blocks: reduced.value, child })
}
const regular = isTaskItem(kept[0]?.child) && kept.every(({ child }) => isTaskItem(child) || child.type === 'taskList')
const nestsTasks = kept.every(({ child }) => isTaskItem(child) || child.type === 'taskList')
const tasks: AdfNode[] = []
for (const { blocks, child } of kept) {
const standsAlone = !regular && !isTaskItem(child) && child.type !== 'taskList'
const standsAlone = !nestsTasks && !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' }))
return success(listOf(tasksAsText(tasks), 'bulletList'))
const items = nestsTasks ? taskListItems({ content: tasks, type: 'taskList' }) : undefined
return success(items === undefined ? listOf(tasksAsText(tasks), 'bulletList') : splitTaskList(items))
}
function reduceTask(task: AdfNode, at: Reduction): Result<AdfNode[]> {
@@ -0,0 +1,19 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import { portableWriting } from './block-spelling.ts'
import { writeMarkdown } from '../emit/adf-to-markdown.ts'
test('refuses a task list a child other than a task opens, where the portable reduction would have left none', () => {
const list = { content: [{ content: [{ text: 'stray', type: 'text' }], type: 'paragraph' }, { attrs: { state: 'TODO' }, type: 'taskItem' }], type: 'taskList' }
const written = writeMarkdown({ content: [{ type: 'rule' }, list], type: 'doc', version: 1 }, portableWriting)
assert.deepEqual(written.ok ? written.value : [written.error.code, written.error.path], ['unsupported-node-shape', ['content', 1]])
})
test('names the path of a failure inside a task list by the child it writes', () => {
const task = { attrs: { state: 'TODO' }, content: [{ text: 't', type: 'text' }], type: 'taskItem' }
const unwritable = { attrs: { state: 'TODO' }, content: [{ marks: [{ type: 'strong' }, { type: 'strong' }], text: 'x', type: 'text' }], type: 'taskItem' }
const list = { content: [task, { content: [task], type: 'taskList' }, unwritable], type: 'taskList' }
const written = writeMarkdown({ content: [list], type: 'doc', version: 1 }, portableWriting)
assert.deepEqual(written.ok ? written.value : written.error.path, ['content', 0, 'content', 2, 'content', 0])
})
+18 -11
View File
@@ -9,6 +9,7 @@ import { failure, success } from '../../result.ts'
import { isTaskItem } from '../../adf/block-nodes.ts'
import { largestNesting } from '../../nesting.ts'
import { nodeAttrs, nodeContent } from '../../adf/document.ts'
import { taskListItems } from './task-list.ts'
const portableBlockWriters: FlavourBlockWriters = {
// GitHub reads a task marker opening any item's first paragraph as a checkbox, whatever its siblings hold.
@@ -40,19 +41,25 @@ function headed(head: string): (body: BlockText) => BlockText {
}
function tryTaskList(node: AdfNode, path: ConvertErrorPath, depth: number, writing: Writing): Result<EmittedBlock> {
const items: PlacedBlock[][] = []
const items = taskListItems(node)
// The portable reduction opens every task list with a task: a child nesting in no task would have no list item to stand in.
if (items === undefined) return failure('unsupported-node-shape', 'a task list opens with a child other than a task', path)
const inners: BlockText[] = []
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 = 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)
const previous = items.at(-1)
if (task || previous === undefined) items.push([...walk.value.blocks])
else for (const block of walk.value.blocks) previous.push(block)
let index = 0
for (const { after, task } of items) {
const blocks: PlacedBlock[] = []
for (const child of [task, ...after]) {
const childPath = [...path, 'content', index]
const walk = isTaskItem(child) ? taskBlocks(child, childPath, depth + 1, writing) : placedBlock(child, childPath, depth + 1, writing)
if (!walk.ok) return walk
headroom = Math.min(headroom, walk.value.headroom)
for (const block of walk.value.blocks) blocks.push(block)
index += 1
}
inners.push(joinBlocks(blocks, 'list-item'))
}
const list = spelledList(items.map((blocks) => joinBlocks(blocks, 'list-item')), undefined, headroom)
const list = spelledList(inners, undefined, headroom)
// The portable reduction leaves no task a list item cannot hold: a directive here would break the flavour.
return list === undefined ? failure('unsupported-node-shape', 'a task holds blocks no list item spells', path) : success(list)
}
@@ -2,6 +2,7 @@ import type { AdfDocument, AdfNode } from '../../adf/document.ts'
import type { Block } from '../parse/blocks.ts'
import type { ConvertError, ConvertErrorPath, ParseError, Result, SourcePosition } from '../../result.ts'
import type { FlavourBlockReaders, FlavourReading } from '../parse/markdown-to-adf.ts'
import type { TaskListItem } from './task-list.ts'
import { containerNode, contentNode, listNode, readBlocks, readDocument, tooDeep } from '../parse/markdown-to-adf.ts'
import { largestNesting } from '../../nesting.ts'
import { leadingMarker, readAlertMarker, readTaskMarker } from './conventions.ts'
@@ -127,7 +128,7 @@ function taskBlocks(items: readonly Block[][], reading: FlavourReading, path: Co
const led = taskItems(items)
if (led === undefined) return undefined
if (depth + 1 > largestNesting) return tooDeep([...path, 'content', 0])
const tasks: AdfNode[] = []
const tasks: TaskListItem[] = []
let refusal: Refusal | undefined
for (const [index, { marker, others, position, text }] of led.entries()) {
const body = text.replace(/^(?:[ \t\n]|\\\n)+/, '')
@@ -138,10 +139,10 @@ function taskBlocks(items: readonly Block[][], reading: FlavourReading, path: Co
for (const node of read.nodes) task.content.push(node)
refusal ??= read.refusal
}
tasks.push(task)
tasks.push({ after: [], task })
if (refusal !== undefined) break
}
return success({ nodes: splitTaskList({ content: tasks, type: 'taskList' }), refusal })
return success({ nodes: splitTaskList(tasks), refusal })
}
// A refused block reads to a placeholder. The split tells only paragraphs and task lists from other blocks, so a paragraph that failed to read stays a paragraph.
+19 -2
View File
@@ -2,7 +2,7 @@ import assert from 'node:assert/strict'
import test from 'node:test'
import type { AdfNode } from '../../adf/document.ts'
import { pathTo, splitTaskList } from './task-list.ts'
import { pathTo, splitTaskList, taskListItems } from './task-list.ts'
test('names the steps from a block to a node deep in it', () => {
const target: AdfNode = { text: 'x', type: 'text' }
@@ -18,6 +18,23 @@ test('names no steps to a node the blocks do not hold', () => {
test('keeps the inline nodes of a lone paragraph in the taskItem it becomes, so pathTo still finds them', () => {
const inline: AdfNode = { text: 'x', type: 'text' }
const item: AdfNode = { attrs: { state: 'TODO' }, content: [{ content: [inline], type: 'paragraph' }], type: 'blockTaskItem' }
const blocks = splitTaskList({ content: [item], type: 'taskList' })
const blocks = splitTaskList([{ after: [], task: item }])
assert.deepEqual(pathTo(blocks, inline), [0, 'content', 0, 'content', 0])
})
test('groups a task list into tasks, each with the children after it up to the next task', () => {
const first: AdfNode = { attrs: { state: 'TODO' }, type: 'taskItem' }
const nested: AdfNode = { content: [first], type: 'taskList' }
const second: AdfNode = { attrs: { state: 'DONE' }, content: [{ type: 'paragraph' }], type: 'blockTaskItem' }
assert.deepEqual(taskListItems({ content: [first, nested, nested, second], type: 'taskList' }), [
{ after: [nested, nested], task: first },
{ after: [], task: second },
])
assert.deepEqual(taskListItems({ type: 'taskList' }), [])
})
test('groups no task list a child other than a task opens', () => {
const task: AdfNode = { attrs: { state: 'TODO' }, type: 'taskItem' }
assert.equal(taskListItems({ content: [{ type: 'paragraph' }, task], type: 'taskList' }), undefined)
assert.equal(taskListItems({ content: [{ content: [task], type: 'taskList' }, task], type: 'taskList' }), undefined)
})
+20 -18
View File
@@ -4,21 +4,36 @@ import { isTaskItem } from '../../adf/block-nodes.ts'
import { nodeContent } from '../../adf/document.ts'
// 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[] {
export function splitTaskList(items: readonly TaskListItem[]): AdfNode[] {
const blocks: AdfNode[] = []
let tasks: AdfNode[] = []
for (const item of itemsOf(nodeContent(list))) {
for (const item of items) {
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 })
if (tasks.length > 0) blocks.push({ content: tasks, type: 'taskList' })
tasks = []
for (const block of rest) blocks.push(block)
}
if (tasks.length > 0) blocks.push({ ...list, content: tasks })
if (tasks.length > 0) blocks.push({ content: tasks, type: 'taskList' })
return blocks
}
// A task and the children after it up to the next task, which nest in it.
export type TaskListItem = { after: AdfNode[]; task: AdfNode }
// The list's items, or undefined where a child other than a task opens it.
export function taskListItems(list: AdfNode): TaskListItem[] | undefined {
const items: TaskListItem[] = []
for (const child of nodeContent(list)) {
const item = items.at(-1)
if (isTaskItem(child)) items.push({ after: [], task: child })
else if (item === undefined) return undefined
else item.after.push(child)
}
return items
}
// Where `target` stands among `blocks`: its block's index, then the steps down to it. The split rebuilds lists and items but never an inline node, so an inline marker stays findable.
export function pathTo(blocks: readonly AdfNode[], target: AdfNode): [number, ...ConvertErrorPath] | undefined {
const pending: Visit[] = blocks.map((node, step) => ({ node, parent: undefined, step }))
@@ -38,21 +53,8 @@ function stepsTo(visit: Visit): [number, ...ConvertErrorPath] {
return [at.step, ...steps.reverse()]
}
// 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
}
// The item reads as one run: the task's blocks, then the children after it.
function itemParts(item: readonly AdfNode[]): { kept: AdfNode[]; rest: AdfNode[] } {
const [task, ...after] = item
if (task === undefined || !isTaskItem(task)) return { kept: [], rest: [...item] }
function itemParts({ after, task }: TaskListItem): { kept: AdfNode[]; rest: AdfNode[] } {
const content = task.type === 'blockTaskItem' ? nodeContent(task) : []
const run = [...content, ...after]
const paragraphs = runEnd(content, 0, 'paragraph')
-11
View File
@@ -13,7 +13,6 @@
| ID | Release | Blocked by | Exempt | Item | L | E | I | R | S | Moves | Score |
|---|---|---|---|---|---|---|---|---|---|---|---|
| 153 | 0.4.0 | | question | **Decide whether `src/markdown/` root may import `emit/`, `parse/` or `portable/`, and hold the answer in `.oxlintrc.json` and §The source parts by ADF and format.** | 1 | 2 | 2 | 3 | 2 | code health by 3 | 26.2 |
| 175 | 0.4.0 | | | **Delete the `previous === undefined` branch, the `'a task holds blocks no list item spells'` failure and the `path` arguments of `tryTaskList`, once a type makes a task list opening with a non-task unrepresentable.** | 1 | 1 | 1 | 1 | 2 | code health by 2 | 17.4 |
| 176 | 0.4.0 | | | **Delete `runEdge`'s `standing === 'moves'` give-up, and any code only it needs, once a test or an argument shows `runEdge` never reaches it, or else pin the case with a hand-worked line.** | 1 | 1 | 2 | 2 | 2 | code health by 2 | 17.4 |
| 45 | 0.4.0 | | defect | **Replace `isAdfDocument` with a reader returning `Result<AdfDocument>`.** | 2 | 1 | 3 | 4 | 3 | 1 by 2 | 13.1 |
| 127 | 0.4.0 | | principle | **Hold what `entity-references.test.ts`, `json-text.test.ts` and `result.test.ts` check by asserting outcomes, so none reads source text.** | 2 | 2 | 1 | 3 | 3 | code health by 2 | 13.1 |
@@ -72,16 +71,6 @@ a paragraph opening with a spellable `!adf:link` (§Nothing recurses unbounded).
`src/markdown/**` bans only `html/`; banning `emit/`, `parse/` and `portable/` for the root's own
files, with its `ci.sh` probe, extends the decision.
### 175. Delete the `previous === undefined` branch, the `'a task holds blocks no list item spells'` failure and the `path` arguments of `tryTaskList`, once a type makes a task list opening with a non-task unrepresentable.
`reduceToPortable` leaves every task list opening with a task. So `adfToPortableMarkdown` reaches
neither the `previous === undefined` branch of `tryTaskList` (`portable/block-spelling.ts`) nor
its failure. Deleting the branch made two panel seats rule the code harder to read: a block pushed
before the first task would vanish silently. Delete it only together with a type that makes a task
list opening with a non-task unrepresentable. Mutants of the `path` arguments to `taskBlocks` and
`placedBlock` survived, so no test shows they reach an error. The `headroom` is not a guard: a list
that cannot be a markdown list refuses by it, and a test pins that.
### 176. Delete `runEdge`'s `standing === 'moves'` give-up, and any code only it needs, once a test or an argument shows `runEdge` never reaches it, or else pin the case with a hand-worked line.
In `src/markdown/emit/unspellable-runs.ts`, Stryker's mutants of `runEdge`'s