diff --git a/CHANGELOG.md b/CHANGELOG.md index 89ed0be..e62abf0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/src/markdown/emit/adf-to-markdown.ts b/src/markdown/emit/adf-to-markdown.ts index 5440e3e..ae9f74d 100644 --- a/src/markdown/emit/adf-to-markdown.ts +++ b/src/markdown/emit/adf-to-markdown.ts @@ -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 | 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 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 { @@ -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 { + return block.text.length > longestOutput ? tooLong() : success(block) +} + function tooDeep(path: ConvertErrorPath): Result { 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 { @@ -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 | 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 { 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): Result { @@ -184,26 +184,26 @@ function emitDirectiveBlock(node: AdfNode, model: BlockNodeModel, path: ConvertE } function emitDirectiveBody(node: AdfNode, model: BlockNodeModel, opener: string, path: ConvertErrorPath, walkBody: () => Result): Result { - 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 { +export function quotedBlocks(node: AdfNode, path: ConvertErrorPath, depth: number, writing: Writing, bodyText: (body: BlockText) => BlockText): Result { 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 | 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 | 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 { diff --git a/src/markdown/emit/block-text.ts b/src/markdown/emit/block-text.ts new file mode 100644 index 0000000..6e0ecb0 --- /dev/null +++ b/src/markdown/emit/block-text.ts @@ -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) +} diff --git a/src/markdown/portable/block-spelling.ts b/src/markdown/portable/block-spelling.ts index d2867ca..27cb8ce 100644 --- a/src/markdown/portable/block-spelling.ts +++ b/src/markdown/portable/block-spelling.ts @@ -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 { @@ -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 { 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 }) } diff --git a/todo.md b/todo.md index c859d02..c1d22c5 100644 --- a/todo.md +++ b/todo.md @@ -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, ` `, `\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 `` 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, ` `, `\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