diff --git a/src/index.ts b/src/index.ts index ff76013..9fa5272 100644 --- a/src/index.ts +++ b/src/index.ts @@ -5,5 +5,5 @@ export type { JsonValue } from './json-value.ts' export { adfToLosslessMarkdown } from './markdown/emit/adf-to-markdown.ts' export { adfToPortableMarkdown } from './markdown/portable/adf-to-portable-markdown.ts' export { commonMarkToAdf, losslessMarkdownToAdf } from './markdown/parse/markdown-to-adf.ts' -export { portableMarkdownToAdf } from './markdown/portable/portable-markdown-to-adf.ts' export { isAdfDocument } from './adf/document.ts' +export { portableMarkdownToAdf } from './markdown/portable/portable-markdown-to-adf.ts' diff --git a/src/markdown/emit/adf-to-markdown.test.ts b/src/markdown/emit/adf-to-markdown.test.ts index 431c515..7f53031 100644 --- a/src/markdown/emit/adf-to-markdown.test.ts +++ b/src/markdown/emit/adf-to-markdown.test.ts @@ -203,6 +203,7 @@ test('alternates the marker between adjacent lists of a kind, the marker change const ordered: AdfNode = { attrs: { order: 9 }, content: [{ content: [paragraph({ text: 'x', type: 'text' })], type: 'listItem' }, { content: [paragraph({ text: 'y', type: 'text' }), paragraph({ text: 'z', type: 'text' })], type: 'listItem' }], type: 'orderedList' } assert.equal(markdown(adfToLosslessMarkdown(document(ordered, ordered))), '9. x\n10. y\n\n z\n\n9) x\n10) y\n\n z\n') assert.equal(markdown(adfToLosslessMarkdown(document(list, ordered, list))), '- x\n\n9. x\n10. y\n\n z\n\n- x\n') + assert.equal(markdown(adfToLosslessMarkdown(document(list, paragraph({ text: 'y', type: 'text' }), list))), '- x\n\ny\n\n- x\n') assert.equal(markdown(adfToLosslessMarkdown(document({ attrs: { panelType: 'info' }, content: [list, list], type: 'panel' }))), '!adf:panel info\n- x\n\n* x\n!adf:/panel\n') const nested: AdfNode = { content: [{ content: [list, list], type: 'listItem' }], type: 'bulletList' } assert.equal(markdown(adfToLosslessMarkdown(document(nested))), '- - x\n * x\n') diff --git a/src/markdown/emit/adf-to-markdown.ts b/src/markdown/emit/adf-to-markdown.ts index 13cc248..c9f51f3 100644 --- a/src/markdown/emit/adf-to-markdown.ts +++ b/src/markdown/emit/adf-to-markdown.ts @@ -141,7 +141,7 @@ function readableBlock(node: AdfNode, path: ConvertErrorPath, depth: number, wri function spellReadableBlock(node: AdfNode, path: ConvertErrorPath, depth: number, writing: Writing): Result | undefined { const own = writing.flavour === 'portable' ? writing.blocks.spell(node, path, depth, writing) : undefined if (own !== undefined) return own - if (node.type === 'blockquote') return tryBlockquote(node, path, depth, writing) + if (node.type === 'blockquote') return holdsOnlyAttributes(node, []) ? quotedBlocks(node, path, depth, writing, (body) => alertEscaped(body, writing)) : undefined if (node.type === 'bulletList' || node.type === 'orderedList') return tryList(node, path, depth, writing) if (node.type === 'codeBlock') return readableText(tryCodeBlock(node, writing.flavour)) if (node.type === 'heading') return tryHeading(node, path, writing.flavour) @@ -198,15 +198,15 @@ function emitDirectiveBody(node: AdfNode, model: BlockNodeModel, opener: string, return success(directivePair(node, opener, joinBlocks(walk.value.blocks, 'directive'), walk.value.headroom)) } -function tryBlockquote(node: AdfNode, path: ConvertErrorPath, depth: number, writing: Writing): Result | undefined { - if (!holdsOnlyAttributes(node, [])) return undefined - return quotedBlocks(node, path, depth, writing, (body) => (writing.flavour === 'portable' && writing.blocks.claimsQuote(body) ? `\\${body}` : body)) +function alertEscaped(body: string, writing: Writing): string { + return writing.flavour === 'portable' && writing.blocks.claimsQuote(body) ? `\\${body}` : body } -export function quotedBlocks(node: AdfNode, path: ConvertErrorPath, depth: number, writing: Writing, text: (body: string) => string): Result { +// Each quote speller calls it directly, not through a wrapper, so a quote level spends one frame between walks (docs/decisions.md §Nothing recurses unbounded). +export function quotedBlocks(node: AdfNode, path: ConvertErrorPath, depth: number, writing: Writing, bodyText: (body: string) => string): Result { const inner = walkBlocks(nodeContent(node), path, depth + 1, writing) if (!inner.ok) return inner - return success(commonMarkText(quoted(text(joinBlocks(inner.value.blocks, 'document'))), inner.value.headroom)) + return success(commonMarkText(quoted(bodyText(joinBlocks(inner.value.blocks, 'document'))), inner.value.headroom)) } function tryCodeBlock(node: AdfNode, flavour: WrittenFlavour): string | undefined { diff --git a/src/markdown/emit/inline-line.ts b/src/markdown/emit/inline-line.ts index 221ff83..d28a12d 100644 --- a/src/markdown/emit/inline-line.ts +++ b/src/markdown/emit/inline-line.ts @@ -9,7 +9,7 @@ import { commonMarkLink, linkHref, markSpelling, spellMarkAttributes } from '../ import { escapeUnbalanced, spellDestination, spellLinkTarget } from '../commonmark/link-syntax.ts' import { failure, success, type ConvertErrorPath, type Result } from '../../result.ts' import { flavourClaims } from '../flavour.ts' -import { highlightDelimiter } from '../highlight-syntax.ts' +import { highlightDelimiter } from '../inline-tokens.ts' import { holdsNullCharacter, trimTrailingSpace } from '../commonmark/grammar.ts' import { identicalMark, isSpellableText, nodeAttrs, nodeContent, nodeMarks } from '../../adf/document.ts' import { inlineNodeModel, takenMarkTypes } from '../../adf/inline-nodes.ts' diff --git a/src/markdown/emit/line-escaping.ts b/src/markdown/emit/line-escaping.ts index 090071c..4b256c4 100644 --- a/src/markdown/emit/line-escaping.ts +++ b/src/markdown/emit/line-escaping.ts @@ -2,10 +2,9 @@ import type { Claims, WrittenFlavour } from '../flavour.ts' import type { InlineSource, InlineToken } from '../inline-tokens.ts' import type { LineContainer } from '../line-container.ts' import { delimiterFlags, isWordCharacter, matchEmphasis, runLength } from '../commonmark/emphasis-matching.ts' -import { delimiterRunToken, inlineSource, readInlineToken } from '../inline-tokens.ts' +import { delimiterRunToken, highlightFlanking, inlineSource, readInlineToken } from '../inline-tokens.ts' import { escapesLineClaim, type LinePosition } from '../commonmark/grammar.ts' import { flavourClaims } from '../flavour.ts' -import { highlightFlanking } from '../highlight-syntax.ts' import { isBareDelimiterRow } from '../pipe-table-syntax.ts' import { opensLinkDefinition } from '../commonmark/link-reference-definitions.ts' diff --git a/src/markdown/flavour.ts b/src/markdown/flavour.ts index 458eb97..8ca367d 100644 --- a/src/markdown/flavour.ts +++ b/src/markdown/flavour.ts @@ -2,7 +2,7 @@ export type Flavour = 'commonmark' | 'lossless' | 'portable' export type WrittenFlavour = Exclude -// What a reader reads past CommonMark, and so what its writer escapes (docs/decisions.md §Each call names its flavour). +// What a reader reads past CommonMark, and so what its writer escapes, but for the portable callouts and task lists that `FlavourBlockReaders` and `FlavourBlockWriters` carry (docs/decisions.md §Each call names its flavour). export type Claims = { carryFence: boolean directives: boolean diff --git a/src/markdown/highlight-syntax.ts b/src/markdown/highlight-syntax.ts deleted file mode 100644 index 68e4800..0000000 --- a/src/markdown/highlight-syntax.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { isWordCharacter } from './commonmark/emphasis-matching.ts' - -export const highlightDelimiter = '==' - -// ー, ー and the kana voicing marks sit outside the kana scripts; Script_Extensions would also take Latin combining marks. -const boundingScript = /^[゙゚ーー゙゚\p{Script=Han}\p{Script=Hangul}\p{Script=Hiragana}\p{Script=Katakana}\p{Script=Khmer}\p{Script=Lao}\p{Script=Myanmar}\p{Script=Thai}]$/u - -// A delimiter is bounded outside by the code point beyond it, and flanks by the character inside it. -export function highlightFlanking(source: string, index: number): { closes: boolean; opens: boolean } { - const end = index + highlightDelimiter.length - const before = Array.from(source.slice(Math.max(0, index - 2), index)).at(-1) ?? '' - const after = Array.from(source.slice(end, end + 2))[0] ?? '' - return { closes: flanks(before) && bounds(after, before), opens: flanks(after) && bounds(before, after) } -} - -function bounds(outside: string, inside: string): boolean { - return !isWordCharacter(outside) || boundingScript.test(outside) || boundingScript.test(inside) -} - -function flanks(character: string): boolean { - return character !== '' && !/\s/.test(character) -} diff --git a/src/markdown/inline-tokens.ts b/src/markdown/inline-tokens.ts index 4001774..87fbb4f 100644 --- a/src/markdown/inline-tokens.ts +++ b/src/markdown/inline-tokens.ts @@ -4,8 +4,7 @@ import type { CodeSpanCloser } from './commonmark/backtick-runs.ts' import { backslashEscape, inlineHtmlConstruct, readBracketedAutolink, readEmailAutolink } from './commonmark/grammar.ts' import { backtickCloser, backtickRun } from './commonmark/backtick-runs.ts' import { claimDirectivePrefix } from './directive/syntax.ts' -import { delimiterFlags, runLength } from './commonmark/emphasis-matching.ts' -import { highlightDelimiter, highlightFlanking } from './highlight-syntax.ts' +import { delimiterFlags, isWordCharacter, runLength } from './commonmark/emphasis-matching.ts' import { readEntityReference } from './commonmark/entity-references.ts' // `directive` and `html` carry no `width`: a directive reads by its own grammar, and the reader refuses HTML. @@ -27,6 +26,11 @@ export type InlineSource = { readonly text: string } +// ー, ー and the kana voicing marks sit outside the kana scripts; Script_Extensions would also take Latin combining marks. +const boundingScript = /^[\u3099\u309a\u30fc\uff70\uff9e\uff9f\p{Script=Han}\p{Script=Hangul}\p{Script=Hiragana}\p{Script=Katakana}\p{Script=Khmer}\p{Script=Lao}\p{Script=Myanmar}\p{Script=Thai}]$/u + +export const highlightDelimiter = '==' + export function inlineSource(text: string): InlineSource { return { codeSpanCloser: backtickCloser(text), terminators: new Map(), text } } @@ -102,3 +106,19 @@ function lastStart(inline: InlineSource, terminator: string): number { function text(width: number): InlineToken { return { kind: 'text', width } } + +// A delimiter is bounded outside by the code point beyond it, and flanks by the character inside it. +export function highlightFlanking(source: string, index: number): { closes: boolean; opens: boolean } { + const end = index + highlightDelimiter.length + const before = Array.from(source.slice(Math.max(0, index - 2), index)).at(-1) ?? '' + const after = Array.from(source.slice(end, end + 2))[0] ?? '' + return { closes: flanks(before) && bounds(after, before), opens: flanks(after) && bounds(before, after) } +} + +function bounds(outside: string, inside: string): boolean { + return !isWordCharacter(outside) || boundingScript.test(outside) || boundingScript.test(inside) +} + +function flanks(character: string): boolean { + return character !== '' && !/\s/.test(character) +} diff --git a/src/markdown/parse/inline-content.ts b/src/markdown/parse/inline-content.ts index 3d391b3..ab1b10f 100644 --- a/src/markdown/parse/inline-content.ts +++ b/src/markdown/parse/inline-content.ts @@ -10,10 +10,9 @@ import { commonMarkLink, linkHref, linkMark } from '../mark-spellings.ts' import { decodeTextEscapes, trimTrailingSpace } from '../commonmark/grammar.ts' import { failure, faulted, success, type ConvertErrorPath, type Result } from '../../result.ts' import { flavourClaims } from '../flavour.ts' -import { highlightDelimiter } from '../highlight-syntax.ts' +import { highlightDelimiter, inlineSource, readInlineToken } from '../inline-tokens.ts' import { identicalMarks, mergeAdjacentText, nodeAttrs, nodeMarks } from '../../adf/document.ts' import { inlineNodeModel, takesMark } from '../../adf/inline-nodes.ts' -import { inlineSource, readInlineToken } from '../inline-tokens.ts' import { joinsWhenRead, textBreakName, textBreakSpelling } from '../adjacent-text.ts' import { matchEmphasis } from '../commonmark/emphasis-matching.ts' import { normalizeLabel, readInlineTarget, readLabel } from '../commonmark/link-syntax.ts' diff --git a/src/markdown/parse/markdown-to-adf.ts b/src/markdown/parse/markdown-to-adf.ts index cad6610..be9fd89 100644 --- a/src/markdown/parse/markdown-to-adf.ts +++ b/src/markdown/parse/markdown-to-adf.ts @@ -19,9 +19,9 @@ import { spellStringAttribute, unsupportedNodeShape } from '../directive/syntax. import { spellsEmpty } from '../directive/empty-keys.ts' import { withinEngineLimits } from '../length-bound.ts' -// A flavour's own reading of a quote and of a bullet list, which may read to several blocks. +// A flavour's own reading of a quote and of a bullet list, which may read to several blocks; `path` is the container's, `index` the list's among its blocks. export type FlavourBlockReaders = { - bulletList: (items: readonly Block[][], reading: FlavourReading, path: ConvertErrorPath, depth: number, listIndex: number) => Result + bulletList: (items: readonly Block[][], reading: FlavourReading, path: ConvertErrorPath, depth: number, index: number) => Result quote: (blocks: readonly Block[], reading: FlavourReading, path: ConvertErrorPath, depth: number) => Result } @@ -77,7 +77,7 @@ export function readBlocks(blocks: readonly Block[], reading: Reading, path: Con return positioned(faulted(fault, nodePath), block.position) } if (block.kind === 'bulletList' && reading.flavour === 'portable') { - const listed = positioned(reading.blocks.bulletList(block.items, reading, nodePath, depth, content.length), block.position) + const listed = positioned(reading.blocks.bulletList(block.items, reading, path, depth, content.length), block.position) if (!listed.ok) return listed for (const node of listed.value) content.push(node) continue diff --git a/src/markdown/portable/block-spelling.ts b/src/markdown/portable/block-spelling.ts index 14e3e37..d2867ca 100644 --- a/src/markdown/portable/block-spelling.ts +++ b/src/markdown/portable/block-spelling.ts @@ -20,17 +20,17 @@ export const portableWriting: WrittenAs = { blocks: portableBlockWriters, flavou // The portable flavour's nodes, in the shapes the portable reduction leaves them. function spellPortableBlock(node: AdfNode, path: ConvertErrorPath, depth: number, writing: Writing): Result | undefined { - if (node.type === 'panel') return quotedUnder(alertMarker(nodeAttrs(node)['panelType']), node, path, depth, writing) + if (node.type === 'panel') return quotedBlocks(node, path, depth, writing, headed(alertMarker(nodeAttrs(node)['panelType']))) if (node.type === 'taskList') return tryTaskList(node, path, depth, writing) if (node.type !== 'expand' && node.type !== 'nestedExpand') return undefined const title = nodeAttrs(node)['title'] - if (typeof title !== 'string') return quotedUnder(foldedAlertMarker, node, path, depth, writing) + if (typeof title !== 'string') return quotedBlocks(node, path, depth, writing, headed(foldedAlertMarker)) const line = emitInlineLine([{ text: title, type: 'text' }], 'paragraph', path, writing.flavour) - return line.ok ? quotedUnder(`${foldedAlertMarker} ${line.value}`, node, path, depth, writing) : line + return line.ok ? quotedBlocks(node, path, depth, writing, headed(`${foldedAlertMarker} ${line.value}`)) : line } -function quotedUnder(head: string, node: AdfNode, path: ConvertErrorPath, depth: number, writing: Writing): Result { - return quotedBlocks(node, path, depth, writing, (body) => (body === '' ? head : `${head}\n\n${body}`)) +function headed(head: string): (body: string) => string { + return (body) => (body === '' ? head : `${head}\n\n${body}`) } function tryTaskList(node: AdfNode, path: ConvertErrorPath, depth: number, writing: Writing): Result { diff --git a/src/markdown/portable/portable-markdown-to-adf.ts b/src/markdown/portable/portable-markdown-to-adf.ts index f95a684..e8c2b1e 100644 --- a/src/markdown/portable/portable-markdown-to-adf.ts +++ b/src/markdown/portable/portable-markdown-to-adf.ts @@ -1,7 +1,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, Reading } from '../parse/markdown-to-adf.ts' +import type { FlavourBlockReaders, FlavourReading } from '../parse/markdown-to-adf.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' @@ -9,24 +9,25 @@ import { mintTaskIds } from './task-ids.ts' import { parseInlineContent } from '../parse/inline-content.ts' import { pathTo, splitTaskList } from './task-list.ts' import { positioned, success } from '../../result.ts' +import { withinEngineLimits } from '../length-bound.ts' type Paragraph = Extract // The paragraph a task marker opens: a task item holds no image, so a lone one there reads as its linked alt text. type TaskParagraph = { kind: 'taskParagraph'; position: SourcePosition; text: string } -// inExpand: whether an expand holds the blocks, which makes a folded callout a nestedExpand. -function blockReaders(inExpand: boolean): FlavourBlockReaders { - return { bulletList: bulletBlocks, quote: (blocks, reading, path, depth) => quoteNode(blocks, reading, path, depth, inExpand) } -} - -const expandBlockReaders = blockReaders(true) -const portableBlockReaders = blockReaders(false) +// Inside an expand, a folded callout reads as a nestedExpand. +const expandBlockReaders: FlavourBlockReaders = { bulletList: bulletBlocks, quote: quoteReader('nestedExpand') } +const portableBlockReaders: FlavourBlockReaders = { bulletList: bulletBlocks, quote: quoteReader('expand') } export function portableMarkdownToAdf(markdown: string): Result { const read = readDocument(markdown, { blocks: portableBlockReaders, flavour: 'portable' }) - if (read.ok) mintTaskIds(read.value, markdown) - return read + if (!read.ok) return read + const minted = withinEngineLimits(() => { + mintTaskIds(read.value, markdown) + return read + }) + return positioned(minted, { line: 1, offset: 0 }) } function markerLed(block: Block | undefined, read: (text: string) => T | undefined): { marker: T; position: SourcePosition; text: string } | undefined { @@ -47,18 +48,20 @@ function paragraphsOf(position: SourcePosition, ...texts: string[]): Paragraph[] return texts.filter((text) => text !== '').map((text) => ({ kind: 'paragraph', position, text })) } -function quoteNode(blocks: readonly Block[], reading: FlavourReading, path: ConvertErrorPath, depth: number, inExpand: boolean): Result { - const [first, ...body] = blocks - const led = markerLed(first, readAlertMarker) - if (led === undefined) return containerNode({ type: 'blockquote' }, blocks, reading, path, depth) - const { folded, panelType } = led.marker - const { line, rest } = markerLine(led.text) - if (!folded) return filledNode({ attrs: { panelType }, type: 'panel' }, readBlocks([...paragraphsOf(led.position, line, rest), ...body], reading, path, depth + 1)) - const title = parseInlineContent(line, reading.definitions, path, 'paragraph', reading.flavour) - if (!title.ok) return title - const text = titleText(title.value.nodes) - const type = inExpand ? 'nestedExpand' : 'expand' - return filledNode(text === '' ? { type } : { attrs: { title: text }, type }, readBlocks([...paragraphsOf(led.position, rest), ...body], { ...reading, blocks: expandBlockReaders }, path, depth + 1)) +// The closure is the reader rather than a wrapper around one, so a quote level spends one stack frame (docs/decisions.md §Nothing recurses unbounded). +function quoteReader(foldedType: 'expand' | 'nestedExpand'): FlavourBlockReaders['quote'] { + return (blocks, reading, path, depth) => { + const [first, ...body] = blocks + const led = markerLed(first, readAlertMarker) + if (led === undefined) return containerNode({ type: 'blockquote' }, blocks, reading, path, depth) + const { folded, panelType } = led.marker + const { line, rest } = markerLine(led.text) + if (!folded) return filledNode({ attrs: { panelType }, type: 'panel' }, readBlocks([...paragraphsOf(led.position, line, rest), ...body], reading, path, depth + 1)) + const title = parseInlineContent(line, reading.definitions, path, 'paragraph', reading.flavour) + if (!title.ok) return title + const text = titleText(title.value.nodes) + return filledNode(text === '' ? { type: foldedType } : { attrs: { title: text }, type: foldedType }, readBlocks([...paragraphsOf(led.position, rest), ...body], { ...reading, blocks: expandBlockReaders }, path, depth + 1)) + } } // docs/decisions.md, A callout title keeps its link targets. @@ -97,10 +100,11 @@ function taskItems(items: readonly Block[][]) { return led } -function bulletBlocks(items: readonly Block[][], reading: Reading, path: ConvertErrorPath, depth: number, listIndex: number): Result { - const tasks = taskBlocks(items, reading, path, depth) +function bulletBlocks(items: readonly Block[][], reading: FlavourReading, path: ConvertErrorPath, depth: number, index: number): Result { + const listPath = [...path, 'content', index] + const tasks = taskBlocks(items, reading, listPath, depth) if (tasks === undefined) { - const list = listNode({ type: 'bulletList' }, items, reading, path, depth) + const list = listNode({ type: 'bulletList' }, items, reading, listPath, depth) return list.ok ? success([list.value]) : list } if (!tasks.ok) return tasks @@ -110,7 +114,7 @@ function bulletBlocks(items: readonly Block[][], reading: Reading, path: Convert // task-list.test.ts pins that the split keeps the marker; were it lost, the error keeps its pre-split path, since nothing here throws. if (place === undefined) return { error: refusal.error, ok: false } const [blockIndex, ...steps] = place - return { error: { ...refusal.error, path: [...path.slice(0, -1), listIndex + blockIndex, ...steps.slice(0, -2), ...refusal.within] }, ok: false } + return { error: { ...refusal.error, path: [...path, 'content', index + blockIndex, ...steps.slice(0, -2), ...refusal.within] }, ok: false } } // A block a task list refused. Its placeholder holds `marker` as its one child and stands where the block's node would, so the one split that builds the list places it; `within` is the error's path below that node. @@ -119,7 +123,7 @@ type Refusal = { error: ConvertError; marker: AdfNode; within: ConvertErrorPath // Nodes read from task blocks and the first refusal among them; a list is read up to the task holding its refusal. type TaskNodes = { nodes: AdfNode[]; refusal: Refusal | undefined } -function taskBlocks(items: readonly Block[][], reading: Reading, path: ConvertErrorPath, depth: number): Result | undefined { +function taskBlocks(items: readonly Block[][], reading: FlavourReading, path: ConvertErrorPath, depth: number): Result | undefined { const led = taskItems(items) if (led === undefined) return undefined if (depth + 1 > largestNesting) return tooDeep([...path, 'content', 0]) @@ -141,18 +145,19 @@ function taskBlocks(items: readonly Block[][], reading: Reading, path: ConvertEr } // 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. -function readTaskBlock(block: Block | TaskParagraph, reading: Reading, path: ConvertErrorPath, depth: number): TaskNodes { +function readTaskBlock(block: Block | TaskParagraph, reading: FlavourReading, path: ConvertErrorPath, depth: number): TaskNodes { const nodePath = [...path, 'content', 0] const nested = block.kind === 'bulletList' ? taskBlocks(block.items, reading, nodePath, depth) : undefined if (nested?.ok) return nested.value - const read = positioned(nested ?? (block.kind === 'taskParagraph' ? taskParagraph(block, reading, nodePath) : readBlocks([block], reading, path, depth)), block.position) + const read = positioned(nested ?? readTaskChild(block, reading, path, depth), block.position) if (read.ok) return { nodes: read.value, refusal: undefined } const marker: AdfNode = { text: '', type: 'text' } const type = block.kind === 'taskParagraph' ? 'paragraph' : nested === undefined ? block.kind : 'taskList' return { nodes: [{ content: [marker], type }], refusal: { error: read.error, marker, within: read.error.path.slice(nodePath.length) } } } -function taskParagraph(paragraph: TaskParagraph, reading: Reading, path: ConvertErrorPath): Result { - const node = contentNode({ type: 'paragraph' }, paragraph.text, reading, path, 'paragraph') +function readTaskChild(block: Block | TaskParagraph, reading: FlavourReading, path: ConvertErrorPath, depth: number): Result { + if (block.kind !== 'taskParagraph') return readBlocks([block], reading, path, depth) + const node = contentNode({ type: 'paragraph' }, block.text, reading, [...path, 'content', 0], 'paragraph') return node.ok ? success([node.value]) : node } diff --git a/todo.md b/todo.md index 3e97939..ac3f460 100644 --- a/todo.md +++ b/todo.md @@ -6,7 +6,7 @@ `Bar = 0.1` -`Next ID = 128` +`Next ID = 129` ## Items @@ -21,6 +21,7 @@ | 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 | 95 | | **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 | | 73 | 0.3.0 | 33, 91, 95, 109, 115, 122, 123, 125 | | **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 | +| 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 | | 75 | 0.4.0 | | | **Make the generated-markdown property write block carries as `adf:` fences, canonical and hostile.** | 1 | 1 | 1 | 1 | 2 | code health by 2 | 17.4 | | 45 | 0.4.0 | | defect | **Replace `isAdfDocument` with a reader returning `Result`.** | 2 | 1 | 3 | 4 | 3 | 1 by 2 | 13.1 | | 68 | 0.4.0 | | defect | **Carry a non-text inline node holding `content` or `text`, a block holding `text`, and a leaf block holding `content`.** | 2 | 1 | 2 | 3 | 3 | 1 by 2 | 13.1 | @@ -139,6 +140,14 @@ its versions and its date in step with the output. We draft each post for the pl maintainer posts them once `0.3.0` is published (`docs/decisions.md` §Posts announcing a release wait for its publish). Found while planning discoverability, 2026-10-04. +### 128. 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. + +Mutation testing of item 62's move left about 40 mutants alive in code it moved verbatim, so the +gaps predate it: `tryTaskList` and `taskBlocks` in `src/markdown/portable/block-spelling.ts`, and +`markerLine`, `quoteReader`, `titleText`, `taskBlocks` and `readTaskBlock` in +`src/markdown/portable/portable-markdown-to-adf.ts`. Each test takes its expected value from +`spec/flavour.md` or `docs/decisions.md`, never from the code. + ### 75. Make the generated-markdown property write block carries as `adf:` fences, canonical and hostile. `blockCarry` and `hostileBlockCarry` (`src/conformance/markdown-property.test.ts`) write a fence