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:
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
)
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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[]> {
|
||||
|
||||
@@ -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' }
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user