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

Spell nested lists and quotes in time linear in the markdown

This commit is contained in:
2026-10-29 13:30:05 +01:00
committed by Lillem4n
parent 1f8619e272
commit 10cb24e706
5 changed files with 150 additions and 80 deletions
+6
View File
@@ -56,6 +56,12 @@
closing brackets follow a link: 8000 links inside 8000 `*` took two seconds, now about 100 ms.
- Every reader takes time linear in the inline markdown when it holds many `](` that open no link,
as in `[a](b` repeated: 16000 of them took 25 seconds, now 80 ms.
- `adfToLosslessMarkdown` and `adfToPortableMarkdown` take time linear in a line's marks when many
of its runs need a carry or lose their mark, as in `un**-r**` repeated: 1000 of them took 10
seconds, now 150 ms.
- `adfToLosslessMarkdown` and `adfToPortableMarkdown` take time linear in the markdown when lists
and quotes nest deeply, and so does `losslessMarkdownToAdf`, which spells what it reads: a list
nested 480 levels around 64 KB of paragraphs took 2 seconds to write, now 100 ms.
- Every reader leaves `[text]`, `[text][]`, `[text][label]` and a definition `[label]: url` literal
when the label runs over 999 characters, where it read them as a link or a definition. CommonMark
caps a label at 999 characters, an escape counting as two.
+50 -52
View File
@@ -3,20 +3,21 @@ import type { BlockNodeModel } from '../../adf/block-nodes.ts'
import type { WrittenFlavour } from '../flavour.ts'
import { blockDirectiveForm, documentSpelling } from '../directive/block.ts'
import { blockNodeModel, blockNodes } from '../../adf/block-nodes.ts'
import { blockText, emptyLine, joinedText, prefixedText, quotedText, writtenText, type BlockText } from './block-text.ts'
import { carriedBlock } from '../opaque-carry.ts'
import { emitInlineLine } from './inline-line.ts'
import { failure, success, type ConvertErrorPath, type Result } from '../../result.ts'
import { fencedCodeBlock } from '../commonmark/backtick-runs.ts'
import { flavourClaims } from '../flavour.ts'
import { holdsNullCharacter, isBlankLine, isThematicBreak, markerInterruptsParagraph } from '../commonmark/grammar.ts'
import { holdsNullCharacter, isThematicBreak, markerInterruptsParagraph } from '../commonmark/grammar.ts'
import { holdsOnlyAttributes, isUnmarkedSpellableText, nodeAttrs, nodeContent, writableContent } from '../../adf/document.ts'
import { languageSlot, type LanguageSlot } from '../code-language.ts'
import { largestNesting } from '../../nesting.ts'
import { longestOutput, tooLong, withinEngineLimits } from '../../length-bound.ts'
import { spellBlockDirectiveOpener } from '../directive/block-spelling.ts'
import { spellDirectiveCloser, spellDirectiveOpener } from '../directive/syntax.ts'
import { tryImage } from './image.ts'
import { tryPipeTable } from './pipe-table.ts'
import { withinEngineLimits, withinLongest } from '../../length-bound.ts'
type BlockContainer = 'directive' | 'document' | 'list-item'
type Delimiters = readonly [string, string]
@@ -24,15 +25,15 @@ type Delimiters = readonly [string, string]
export type EmittedBlock = SpelledBlock & { headroom: number }
// spell: the flavour's own blocks, tried before CommonMark's; claimsItem, claimsQuote: whether a list item's or quote's text opens with a marker the flavour's reader would take, so the writer escapes it.
export type FlavourBlockWriters = {
claimsItem: (text: string) => boolean
claimsQuote: (text: string) => boolean
claimsItem: (text: BlockText) => boolean
claimsQuote: (text: BlockText) => boolean
spell: (node: AdfNode, path: ConvertErrorPath, depth: number, writing: Writing) => Result<EmittedBlock> | undefined
}
type KeptSpelling = { block: EmittedBlock | undefined; depth: number }
export type PlacedBlock = SpelledBlock & { node: AdfNode }
export type PlacedBlocks = { blocks: readonly PlacedBlock[]; headroom: number }
// CommonMark starts a new list where the marker changes, so every second list in a run of one kind takes `alternateText`.
type SpelledBlock = { spelling: 'commonmark' | 'directive'; text: string } | { alternateText: string; interruptsParagraph: boolean; ordered: boolean; spelling: 'list'; text: string }
type SpelledBlock = { spelling: 'commonmark' | 'directive'; text: BlockText } | { alternateText: BlockText; interruptsParagraph: boolean; ordered: boolean; spelling: 'list'; text: BlockText }
// Keyed by reference: only a caller building one object per position (the parse, the portable reduction) passes one; a consumer's document may share a node.
export type SpellingMemo = Map<AdfNode, KeptSpelling>
type WalkedItem = { node: AdfNode; walk: PlacedBlocks }
@@ -56,7 +57,8 @@ export function writeMarkdown(document: AdfDocument, writtenAs: WrittenAs): Resu
const walk = walkBlocks(content.value, [], 0, { ...writtenAs, memo: undefined })
if (!walk.ok) return walk
const text = joinBlocks(walk.value.blocks, 'document')
return withinLongest(text === '' ? '' : `${text}\n`)
if (text.length + 1 > longestOutput) return tooLong()
return success(text.length === 0 ? '' : `${writtenText(text)}\n`)
}
export function walkBlocks(nodes: readonly AdfNode[], path: ConvertErrorPath, depth: number, writing: Writing): Result<PlacedBlocks> {
@@ -67,7 +69,7 @@ export function walkBlocks(nodes: readonly AdfNode[], path: ConvertErrorPath, de
const block = emitBlock(node, [...path, 'content', index], depth, writing)
if (!block.ok) return block
// Refused here, so an engine holding longer strings stops at the block where V8 throws.
const fits = withinLongest(block.value.text)
const fits = withinLongest(block.value)
if (!fits.ok) return fits
headroom = Math.min(headroom, block.value.headroom)
blocks.push({ ...block.value, node })
@@ -75,30 +77,34 @@ export function walkBlocks(nodes: readonly AdfNode[], path: ConvertErrorPath, de
return success({ blocks, headroom })
}
function withinLongest(block: EmittedBlock): Result<EmittedBlock> {
return block.text.length > longestOutput ? tooLong() : success(block)
}
function tooDeep(path: ConvertErrorPath): Result<never> {
return failure('unsupported-nesting-depth', `the document nests deeper than the ${largestNesting} levels the emitter carries`, path)
}
export function joinBlocks(blocks: readonly PlacedBlock[], container: BlockContainer): string {
let text = ''
export function joinBlocks(blocks: readonly PlacedBlock[], container: BlockContainer): BlockText {
const parts: BlockText[] = []
let alternated = false
for (const [index, block] of blocks.entries()) {
const previous = blocks[index - 1]
if (previous !== undefined) text += separationBetween(previous, block, container)
if (previous !== undefined && blankLineBetween(previous, block, container)) parts.push(emptyLine)
alternated = !alternated && previous !== undefined && sharesMarker(previous, block)
text += alternated && block.spelling === 'list' ? block.alternateText : block.text
parts.push(alternated && block.spelling === 'list' ? block.alternateText : block.text)
}
return text
return joinedText(parts)
}
function sharesMarker(previous: PlacedBlock, next: PlacedBlock): boolean {
return previous.spelling === 'list' && next.spelling === 'list' && previous.ordered === next.ordered
}
function separationBetween(previous: PlacedBlock, next: PlacedBlock, container: BlockContainer): string {
function blankLineBetween(previous: PlacedBlock, next: PlacedBlock, container: BlockContainer): boolean {
const bothCommonMark = previous.spelling !== 'directive' && next.spelling !== 'directive'
if (bothCommonMark && next.spelling === 'list' && container === 'list-item') return next.interruptsParagraph ? '\n' : '\n\n'
return container === 'directive' && !bothCommonMark ? '\n' : '\n\n'
if (bothCommonMark && next.spelling === 'list' && container === 'list-item') return !next.interruptsParagraph
return container !== 'directive' || bothCommonMark
}
export function emitBlock(node: AdfNode, path: ConvertErrorPath, depth: number, writing: Writing): Result<EmittedBlock> {
@@ -114,7 +120,7 @@ export function commonMarkSpelling(node: AdfNode, path: ConvertErrorPath, depth:
const readable = readableBlock(node, path, depth, writing, 'asked')
if (readable === undefined) return success(false)
if (!readable.ok) return readable
const fits = withinLongest(readable.value.text)
const fits = withinLongest(readable.value)
if (!fits.ok) return fits
return success(readable.value.spelling !== 'directive')
}
@@ -149,28 +155,22 @@ function spellReadableBlock(node: AdfNode, path: ConvertErrorPath, depth: number
return undefined
}
function quoted(text: string): string {
return text
.split('\n')
.map((line) => (line === '' ? '>' : `> ${line}`))
.join('\n')
}
function readableText(text: string | undefined): Result<EmittedBlock> | undefined {
return text === undefined ? undefined : success(commonMarkText(text))
return text === undefined ? undefined : success(commonMarkText(blockText(text)))
}
function carriedFence(carried: Result<{ headroom: number; text: string }>): Result<EmittedBlock> {
if (!carried.ok) return carried
return success({ ...carried.value, spelling: 'commonmark' })
return success(commonMarkText(blockText(carried.value.text), carried.value.headroom))
}
function commonMarkText(text: string, headroom: number = Number.POSITIVE_INFINITY): EmittedBlock {
function commonMarkText(text: BlockText, headroom: number = Number.POSITIVE_INFINITY): EmittedBlock {
return { headroom, spelling: 'commonmark', text }
}
function directivePair(node: AdfNode, opener: string, body: string, headroom: number = Number.POSITIVE_INFINITY): EmittedBlock {
return { headroom, spelling: 'directive', text: `${opener}\n${body === '' ? '' : `${body}\n`}${spellDirectiveCloser(node.type)}` }
function directivePair(node: AdfNode, opener: string, body: BlockText, headroom: number = Number.POSITIVE_INFINITY): EmittedBlock {
const closer = blockText(spellDirectiveCloser(node.type))
return { headroom, spelling: 'directive', text: joinedText(body.length === 0 ? [blockText(opener), closer] : [blockText(opener), body, closer]) }
}
function emitDirectiveBlock(node: AdfNode, model: BlockNodeModel, path: ConvertErrorPath, depth: number, walkBody: () => Result<PlacedBlocks>): Result<EmittedBlock> {
@@ -184,26 +184,26 @@ function emitDirectiveBlock(node: AdfNode, model: BlockNodeModel, path: ConvertE
}
function emitDirectiveBody(node: AdfNode, model: BlockNodeModel, opener: string, path: ConvertErrorPath, walkBody: () => Result<PlacedBlocks>): Result<EmittedBlock> {
if (blockDirectiveForm(node.type) === 'leaf') return success({ headroom: Number.POSITIVE_INFINITY, spelling: 'directive', text: opener })
if (blockDirectiveForm(node.type) === 'leaf') return success({ headroom: Number.POSITIVE_INFINITY, spelling: 'directive', text: blockText(opener) })
if (model.contentModel === 'inline') {
const line = emitInlineLine(nodeContent(node), 'paragraph', path, 'lossless')
if (!line.ok) return line
return success(directivePair(node, opener, line.value))
return success(directivePair(node, opener, blockText(line.value)))
}
const walk = walkBody()
if (!walk.ok) return walk
return success(directivePair(node, opener, joinBlocks(walk.value.blocks, 'directive'), walk.value.headroom))
}
function alertEscaped(body: string, writing: Writing): string {
return writing.flavour === 'portable' && writing.blocks.claimsQuote(body) ? `\\${body}` : body
function alertEscaped(body: BlockText, writing: Writing): BlockText {
return writing.flavour === 'portable' && writing.blocks.claimsQuote(body) ? prefixedText('\\', '', body) : body
}
// Quote spellers call this directly, never through a wrapper: each frame a quote level adds lowers the depth the stack holds (docs/decisions.md §Nothing recurses unbounded).
export function quotedBlocks(node: AdfNode, path: ConvertErrorPath, depth: number, writing: Writing, bodyText: (body: string) => string): Result<EmittedBlock> {
export function quotedBlocks(node: AdfNode, path: ConvertErrorPath, depth: number, writing: Writing, bodyText: (body: BlockText) => BlockText): Result<EmittedBlock> {
const inner = walkBlocks(nodeContent(node), path, depth + 1, writing)
if (!inner.ok) return inner
return success(commonMarkText(quoted(bodyText(joinBlocks(inner.value.blocks, 'document'))), inner.value.headroom))
return success(commonMarkText(quotedText(bodyText(joinBlocks(inner.value.blocks, 'document'))), inner.value.headroom))
}
function tryCodeBlock(node: AdfNode, flavour: WrittenFlavour): string | undefined {
@@ -224,7 +224,7 @@ function emitCodeDirective(node: AdfNode, model: BlockNodeModel, path: ConvertEr
if (opener === undefined) return carriedFence(carriedBlock(node, path, depth))
if (!opener.ok) return opener
const info = slot.kind === 'fence' ? slot.info : ''
return success(directivePair(node, opener.value, texts.map((text) => fencedCodeBlock(info, text)).join('\n')))
return success(directivePair(node, opener.value, blockText(texts.map((text) => fencedCodeBlock(info, text)).join('\n'))))
}
// The text of each fence, or `undefined` where a child holds what no fence keeps, so the block carry takes the node.
@@ -244,10 +244,10 @@ function tryHeading(node: AdfNode, path: ConvertErrorPath, flavour: WrittenFlavo
if (typeof level !== 'number' || !Number.isInteger(level) || level < 1 || level > 6) return undefined
const hashes = '#'.repeat(level)
const content = nodeContent(node)
if (content.length === 0) return success(commonMarkText(hashes))
if (content.length === 0) return success(commonMarkText(blockText(hashes)))
const line = emitInlineLine(content, 'heading', path, flavour)
if (!line.ok) return line
return success(commonMarkText(`${hashes} ${line.value}`))
return success(commonMarkText(blockText(`${hashes} ${line.value}`)))
}
function tryList(node: AdfNode, path: ConvertErrorPath, depth: number, writing: Writing): Result<EmittedBlock> | undefined {
@@ -267,7 +267,7 @@ function tryList(node: AdfNode, path: ConvertErrorPath, depth: number, writing:
}
const inners = walked.map((item) => {
const inner = joinBlocks(item.walk.blocks, 'list-item')
return writing.flavour === 'portable' && writing.blocks.claimsItem(inner) ? `\\${inner}` : inner
return writing.flavour === 'portable' && writing.blocks.claimsItem(inner) ? prefixedText('\\', '', inner) : inner
})
const list = spelledList(inners, ordered ? start : undefined, headroom)
if (list !== undefined) return success(list)
@@ -277,23 +277,23 @@ function tryList(node: AdfNode, path: ConvertErrorPath, depth: number, writing:
}
// start: an ordered list's first number, undefined for a bullet list.
export function spelledList(inners: readonly string[], start: number | undefined, headroom: number): EmittedBlock | undefined {
export function spelledList(inners: readonly BlockText[], start: number | undefined, headroom: number): EmittedBlock | undefined {
const [usual, alternate] = start === undefined ? bulletDelimiters : orderedDelimiters
const number = (offset: number): string => (start === undefined ? '' : `${start + offset}`)
const text = listLines(inners, number, usual)
const alternateText = listLines(inners, number, alternate)
if (text === undefined || alternateText === undefined) return undefined
return { alternateText, headroom, interruptsParagraph: markerInterruptsParagraph(start, inners[0] === ''), ordered: start !== undefined, spelling: 'list', text }
return { alternateText, headroom, interruptsParagraph: markerInterruptsParagraph(start, inners[0]?.length === 0), ordered: start !== undefined, spelling: 'list', text }
}
function listLines(inners: readonly string[], number: (offset: number) => string, delimiter: string): string | undefined {
const lines: string[] = []
function listLines(inners: readonly BlockText[], number: (offset: number) => string, delimiter: string): BlockText | undefined {
const lines: BlockText[] = []
for (const [offset, inner] of inners.entries()) {
const line = tryListItemLines(inner, `${number(offset)}${delimiter} `)
if (line === undefined) return undefined
lines.push(line)
}
return lines.join('\n')
return joinedText(lines)
}
function directiveItems(items: readonly WalkedItem[]): PlacedBlock[] {
@@ -307,16 +307,14 @@ function listStart(node: AdfNode, items: number): number | undefined {
return start + items - 1 > largestListMarker ? undefined : start
}
function tryListItemLines(inner: string, marker: string): string | undefined {
if (inner === '') return marker.trimEnd()
const body = inner.split('\n')
if (body.some((line) => line !== '' && isBlankLine(line))) return undefined
function tryListItemLines(inner: BlockText, marker: string): BlockText | undefined {
if (inner.length === 0) return blockText(marker.trimEnd())
if (inner.firstBlank || inner.blankRest > 0) return undefined
const indent = ' '.repeat(marker.length)
const lines = body.map((line, index) => (index === 0 ? `${marker}${line}` : line === '' ? '' : `${indent}${line}`))
const first = lines[0] ?? ''
if (!isThematicBreak(first)) return lines.join('\n')
const lines = prefixedText(marker, indent, inner)
if (!lines.firstRuleCharacters || !isThematicBreak(lines.first)) return lines
// An item opens with `*` only as text, a list there taking `- ` and a rule `---`; a backslash keeps a first line reading as a rule text.
return inner.startsWith('*') ? [`${marker}\\${first.slice(marker.length)}`, ...lines.slice(1)].join('\n') : undefined
return inner.firstCharacter === '*' ? prefixedText(`${marker}\\`, indent, inner) : undefined
}
function tryParagraph(node: AdfNode, path: ConvertErrorPath, flavour: WrittenFlavour): Result<EmittedBlock> | undefined {
@@ -324,7 +322,7 @@ function tryParagraph(node: AdfNode, path: ConvertErrorPath, flavour: WrittenFla
if (content.length === 0 || !holdsOnlyAttributes(node, [])) return undefined
const line = emitInlineLine(content, 'paragraph', path, flavour)
if (!line.ok) return line
return success(commonMarkText(line.value))
return success(commonMarkText(blockText(line.value)))
}
function tryRule(node: AdfNode): string | undefined {
+84
View File
@@ -0,0 +1,84 @@
import { isBlankLine } from '../commonmark/grammar.ts'
// A block's markdown, its prefixes applied only when written out, so a nesting level costs its own blocks rather than the text below it.
export type BlockText = Lines & Shape
type Prefix = { prefix: Lines & { kind: 'prefixed' | 'quoted' }; started: boolean }
type Lines =
| { body: BlockText; head: string; indent: string; kind: 'prefixed' }
| { body: BlockText; kind: 'quoted' }
| { kind: 'joined'; parts: readonly BlockText[] }
| { kind: 'text'; lines: readonly string[] }
// The written text's measures; the flags answer for the first line unread, since reading a concatenated string copies it whole, at every level it nests.
type Shape = { blankRest: number; emptyRest: number; first: string; firstBlank: boolean; firstCharacter: string; firstRuleCharacters: boolean; length: number; lineCount: number }
const ruleCharacters = /^[-*_ \t]*$/
export const emptyLine = blockText('')
export function blockText(text: string): BlockText {
const [first = '', ...rest] = text.split('\n')
const blankRest = rest.filter((line) => line !== '' && isBlankLine(line)).length
const firstShape = { first, firstBlank: first !== '' && isBlankLine(first), firstCharacter: first.charAt(0), firstRuleCharacters: ruleCharacters.test(first) }
return { ...firstShape, blankRest, emptyRest: rest.filter((line) => line === '').length, kind: 'text', length: text.length, lineCount: rest.length + 1, lines: [first, ...rest] }
}
// The parts' lines one after another.
export function joinedText(parts: readonly BlockText[]): BlockText {
const [first, ...others] = parts
if (first === undefined) return emptyLine
let { blankRest, emptyRest, length, lineCount } = first
for (const part of others) {
blankRest += part.blankRest + (part.firstBlank ? 1 : 0)
emptyRest += part.emptyRest + (part.first === '' ? 1 : 0)
length += 1 + part.length
lineCount += part.lineCount
}
const { firstBlank, firstCharacter, firstRuleCharacters } = first
return { blankRest, emptyRest, first: first.first, firstBlank, firstCharacter, firstRuleCharacters, kind: 'joined', length, lineCount, parts }
}
// head, never blank, leads the first line, and indent, spaces alone, every later line holding text.
export function prefixedText(head: string, indent: string, body: BlockText): BlockText {
const first = `${head}${body.first}`
const length = body.length + head.length + indent.length * (body.lineCount - 1 - body.emptyRest)
const firstRuleCharacters = ruleCharacters.test(head) && body.firstRuleCharacters
return { blankRest: body.blankRest, body, emptyRest: body.emptyRest, first, firstBlank: false, firstCharacter: head.charAt(0), firstRuleCharacters, head, indent, kind: 'prefixed', length, lineCount: body.lineCount }
}
export function quotedText(body: BlockText): BlockText {
const empty = body.emptyRest + (body.first === '' ? 1 : 0)
const first = body.first === '' ? '>' : `> ${body.first}`
return { blankRest: 0, body, emptyRest: 0, first, firstBlank: false, firstCharacter: '>', firstRuleCharacters: false, kind: 'quoted', length: body.length + 2 * body.lineCount - empty, lineCount: body.lineCount }
}
// The first line, and the line break after it where one follows: enough to read a marker opening the text.
export function opening(text: BlockText): string {
return text.lineCount > 1 ? `${text.first}\n` : text.first
}
export function writtenText(text: BlockText): string {
const lines: string[] = []
const prefixes: Prefix[] = []
const pending: (BlockText | 'leave')[] = [text]
for (let next = pending.pop(); next !== undefined; next = pending.pop()) {
if (next === 'leave') prefixes.pop()
else if (next.kind === 'text') for (const line of next.lines) lines.push(prefixedLine(line, prefixes))
else if (next.kind === 'joined') for (const part of next.parts.slice().reverse()) pending.push(part)
else {
prefixes.push({ prefix: next, started: false })
pending.push('leave', next.body)
}
}
return lines.join('\n')
}
// Innermost first: whether a line is empty when an outer prefix meets it depends on the prefixes inside.
function prefixedLine(line: string, prefixes: readonly Prefix[]): string {
return prefixes.reduceRight((written, level) => {
const { prefix, started } = level
level.started = true
if (prefix.kind === 'quoted') return written === '' ? '>' : `> ${written}`
if (!started) return `${prefix.head}${written}`
return written === '' ? '' : `${prefix.indent}${written}`
}, line)
}
+8 -7
View File
@@ -2,6 +2,7 @@ import type { AdfNode } from '../../adf/document.ts'
import type { ConvertErrorPath, Result } from '../../result.ts'
import type { EmittedBlock, FlavourBlockWriters, PlacedBlock, PlacedBlocks, Writing, WrittenAs } from '../emit/adf-to-markdown.ts'
import { alertMarker, foldedAlertMarker, leadingMarker, readAlertMarker, readTaskMarker, taskMarker } from './conventions.ts'
import { blockText, emptyLine, joinedText, opening, prefixedText, type BlockText } from '../emit/block-text.ts'
import { emitBlock, joinBlocks, quotedBlocks, spelledList, walkBlocks } from '../emit/adf-to-markdown.ts'
import { emitInlineLine } from '../emit/inline-line.ts'
import { failure, success } from '../../result.ts'
@@ -11,8 +12,8 @@ import { nodeAttrs, nodeContent } from '../../adf/document.ts'
const portableBlockWriters: FlavourBlockWriters = {
// GitHub reads a task marker opening any item's first paragraph as a checkbox, whatever its siblings hold.
claimsItem: (text) => leadingMarker(text, readTaskMarker) !== undefined,
claimsQuote: (text) => leadingMarker(text, readAlertMarker) !== undefined,
claimsItem: (text) => text.firstCharacter === '[' && leadingMarker(opening(text), readTaskMarker) !== undefined,
claimsQuote: (text) => text.firstCharacter === '[' && leadingMarker(opening(text), readAlertMarker) !== undefined,
spell: spellPortableBlock,
}
@@ -29,8 +30,8 @@ function spellPortableBlock(node: AdfNode, path: ConvertErrorPath, depth: number
return line.ok ? quotedBlocks(node, path, depth, writing, headed(`${foldedAlertMarker} ${line.value}`)) : line
}
function headed(head: string): (body: string) => string {
return (body) => (body === '' ? head : `${head}\n\n${body}`)
function headed(head: string): (body: BlockText) => BlockText {
return (body) => (body.length === 0 ? blockText(head) : joinedText([blockText(head), emptyLine, body]))
}
function tryTaskList(node: AdfNode, path: ConvertErrorPath, depth: number, writing: Writing): Result<EmittedBlock> {
@@ -59,16 +60,16 @@ function placedBlock(node: AdfNode, path: ConvertErrorPath, depth: number, writi
// The marker leads the first paragraph, or stands as one where the blocks open with another.
function taskBlocks(task: AdfNode, path: ConvertErrorPath, depth: number, writing: Writing): Result<PlacedBlocks> {
const marker = taskMarker(nodeAttrs(task)['state'])
const markerBlock: PlacedBlock = { node: { type: 'paragraph' }, spelling: 'commonmark', text: marker }
const markerBlock: PlacedBlock = { node: { type: 'paragraph' }, spelling: 'commonmark', text: blockText(marker) }
if (task.type === 'taskItem') {
const content = nodeContent(task)
const line = content.length === 0 ? success('') : emitInlineLine(content, 'paragraph', path, writing.flavour)
if (!line.ok) return line
return success({ blocks: [{ ...markerBlock, text: line.value === '' ? marker : `${marker} ${line.value}` }], headroom: Number.POSITIVE_INFINITY })
return success({ blocks: [{ ...markerBlock, text: blockText(line.value === '' ? marker : `${marker} ${line.value}`) }], headroom: Number.POSITIVE_INFINITY })
}
const walk = walkBlocks(nodeContent(task), path, depth, writing)
if (!walk.ok) return walk
const [first, ...rest] = walk.value.blocks
const blocks = first?.node.type === 'paragraph' ? [{ ...first, text: `${marker} ${first.text}` }, ...rest] : [markerBlock, ...walk.value.blocks]
const blocks = first?.node.type === 'paragraph' ? [{ ...first, text: prefixedText(`${marker} `, '', first.text) }, ...rest] : [markerBlock, ...walk.value.blocks]
return success({ blocks, headroom: walk.value.headroom })
}
+2 -21
View File
@@ -12,15 +12,13 @@
| ID | Release | Blocked by | Exempt | Item | L | E | I | R | S | Moves | Score |
|---|---|---|---|---|---|---|---|---|---|---|---|
| 33 | 0.3.0 | | defect | **Emit a line in time linear in its mark runs, in `adfToLosslessMarkdown` and `adfToPortableMarkdown`.** | 3 | 1 | 2 | 4 | 5 | 9 by 3 | 7.8 |
| 109 | 0.3.0 | | defect | **Spell nested lists and quotes in time linear in the markdown.** | 3 | 1 | 2 | 4 | 5 | 9 by 3 | 7.8 |
| 150 | 0.3.0 | | decision | **Read `adfToHtml`'s HTML in `htmlToAdf` as a browser serializes it too — a raw `"` in text, `&nbsp;`, `\r\n` line endings — so a page's `innerHTML` or a form post reads back.** | 2 | 2 | 2 | 4 | 4 | 6 by 5 | 5.0 |
| 115 | 0.3.0 | | defect | **Build text holding no `border` mark in `losslessMarkdownToAdf`, and have `adfToLosslessMarkdown` carry text holding one.** | 2 | 1 | 2 | 3 | 3 | 4 by 3 | 4.6 |
| 91 | 0.3.0 | | | **Spell an emphasis once across the code spans, an emphasis or link once across the hard breaks, dates, emoji, inline cards, mentions and statuses between two nodes it marks, and an emphasis across inline media, in both writers.** | 3 | 2 | 2 | 5 | 5 | 6 by 6 | 3.6 |
| 133 | 0.3.0 | | decision | **Write mentions, emoji, dates, statuses and inline cards as semantic HTML in `adfToHtml` and read them back in `htmlToAdf`, in place of the carry element.** | 2 | 2 | 2 | 4 | 4 | 6 by 3 | 3.0 |
| 132 | 0.3.0 | | | **Write tables as `<table>` in `adfToHtml` and read them back in `htmlToAdf`, in place of the carry element.** | 2 | 2 | 2 | 4 | 5 | 6 by 4 | 2.4 |
| 73 | 0.3.0 | 33, 91, 109, 115, 132, 133, 150 | | **Announce the package where someone needing an ADF converter already reads: JRACLOUD-77436, the Atlassian developer community and Stack Overflow's ADF-to-markdown questions.** | 1 | 2 | 2 | 3 | 2 | 3 by 8 | 16.9 |
| 72 | 0.3.0 | 33, 91, 109, 115, 132, 133, 150 | decision | **Re-create `gitea.larvit.se/larvit/adf-codec` as a read-only pull mirror of GitHub, its issues and Actions off and its description pointing at GitHub.** | 1 | 2 | 1 | 2 | 1 | 3 by 1 | 2.5 |
| 73 | 0.3.0 | 91, 115, 132, 133, 150 | | **Announce the package where someone needing an ADF converter already reads: JRACLOUD-77436, the Atlassian developer community and Stack Overflow's ADF-to-markdown questions.** | 1 | 2 | 2 | 3 | 2 | 3 by 8 | 16.9 |
| 72 | 0.3.0 | 91, 115, 132, 133, 150 | decision | **Re-create `gitea.larvit.se/larvit/adf-codec` as a read-only pull mirror of GitHub, its issues and Actions off and its description pointing at GitHub.** | 1 | 2 | 1 | 2 | 1 | 3 by 1 | 2.5 |
| 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 |
| 156 | 0.4.0 | | | **Stop a leg's container when its script is interrupted, so Ctrl-C ends `ci.sh` or `mutation-tests/run.sh` at once.** | 1 | 2 | 1 | 2 | 1 | code health by 2 | 20.9 |
| 128 | 0.4.0 | | | **Pin the portable flavour's block reading and writing with tests: callout titles past one line, a link beside other marks in a title, the nesting limit of callouts and task lists, refusals inside tasks and their paths, and empty or non-paragraph tasks.** | 2 | 1 | 1 | 2 | 3 | code health by 3 | 19.6 |
@@ -69,23 +67,6 @@
## Details
### 33. Emit a line in time linear in its mark runs, in `adfToLosslessMarkdown` and `adfToPortableMarkdown`.
`adfToLosslessMarkdown` spends 28 s on one paragraph of 2000 × `un` plus `**-r**`: each run its
flanking cannot spell re-emits the whole line before riding the carry, quadratic in the runs, and
the portable reduction's `spellableLine` drops one mark per re-emit the same way. An inline node
whose attributes no spelling writes re-emits the line the same way before riding the carry. Make all
three linear.
### 109. Spell nested lists and quotes in time linear in the markdown.
Each level of a nested list or quote re-indents its whole text, so the work is the depth times the
text. `losslessMarkdownToAdf` on 3 MB of lazy lines in a list nested 490 levels inside
`!adf:blockquote` spends about 5 seconds re-spelling it. 2 MB of the same lines, split over two
items of a list nested 480 levels, took 11 minutes to refuse on Node 24: a deep subtree past the
length bound refuses only once every level below has re-indented its text. The writers do the same
on such a document. Found while shipping item 106, 2026-10-11.
### 150. Read `adfToHtml`'s HTML in `htmlToAdf` as a browser serializes it too — a raw `"` in text, `&nbsp;`, `\r\n` line endings — so a page's `innerHTML` or a form post reads back.
`htmlToAdf` refuses all three today. A browser's `innerHTML` writes text's `"` raw and a no-break