diff --git a/docs/decisions.md b/docs/decisions.md index a0333c8..cc21b74 100644 --- a/docs/decisions.md +++ b/docs/decisions.md @@ -68,8 +68,9 @@ same portable markdown. 2026-10-03, the maintainer. Goal 1. Valid while CommonMark reads adjacent text as one run. Two adjacent text nodes CommonMark would read back as one are parted by the reserved inline leaf -`!adf:textBreak{}`: a leaf building no node keeps both nodes and asks nothing of the text around -it. A code span holds no directive, so the spans close and reopen around the leaf. The grammar: `spec/flavour.md` §Inline nodes, **Adjacent text nodes**. +`!adf:textBreak{}`: a leaf building no node keeps both nodes and asks nothing of the text around it. +A code span holds no directive, so the spans close and reopen around the leaf. The grammar: +`spec/flavour.md` §Inline nodes, **Adjacent text nodes**. ## A list beside one of its kind takes the other marker @@ -702,10 +703,11 @@ per level above it otherwise. The parse and the portable reduction keep each node's readable spelling in a memo, so the `commonMarkSpelling` ask stops spelling a node once per level above it. The spelled texts and -`spelling` carry no depth and `headroom` is affine in it, so a read at or above the depth that filled the entry -rebases; a read below re-spells, because a hit skips the depth guards the walk it replaces runs and -an ordered list past the marker cap gives way, spending two emitter levels where the parser spent -one. Only what succeeded is kept, so no path minted at another position is ever read. +`spelling` carry no depth and `headroom` is affine in it, so a read at or above the depth that +filled the entry rebases; a read below re-spells, because a hit skips the depth guards the walk it +replaces runs and an ordered list past the marker cap gives way, spending two emitter levels where +the parser spent one. Only what succeeded is kept, so no path minted at another position is ever +read. ## Cost fixes are measured, never timed diff --git a/spec/flavour.md b/spec/flavour.md index d6b9637..1b6e8cd 100644 --- a/spec/flavour.md +++ b/spec/flavour.md @@ -21,8 +21,9 @@ normalizes to it through the round-trip. - Bullet lists `- `; ordered lists incrementing `1.` `2.` `3.`, the first number the node's `order` attribute. A bullet or task list right after a bullet or task list takes `* `, and an ordered list right after an ordered one `1)`, since CommonMark starts a new list where the - bullet character or the delimiter changes; the list after that takes `- ` or `1.` again, and a - first line of `*` alone after `* ` opens with a backslash, `* \**`. Continuation lines align with the first character + bullet character or the delimiter changes; the list after that takes `- ` or `1.` again. An item + text of asterisks and spaces that would read as a rule after `* ` opens with a backslash, + `* \**`. Continuation lines align with the first character after the marker (two spaces for `- `, three for `1. `); blank lines inside an item are empty lines, none between a nested list and a CommonMark block above it — one wherever the nested list's own marker cannot interrupt a paragraph (an ordered list whose first number is not 1, or @@ -84,8 +85,8 @@ escaped, and erroring input gaining meaning later is MINOR, never a reparse (`do or an inline node written as a block directive, a block node written inline — is a different error, naming the spelling it takes. Three reserved names read back to no node: `carry` for the inline opaque carry, `doc` for a document holding no `content` key (Canonical form), and `textBreak` for -the leaf that parts two text nodes (Inline nodes). Every fence info string opening `adf:` is reserved for the block carry (The opaque -carry). +the leaf that parts two text nodes (Inline nodes). Every fence info string opening `adf:` is +reserved for the block carry (The opaque carry). **Claiming**: an unescaped `!adf:` claims wherever it stands. What follows picks the form: `/name` closes a container, and a name picks by what follows it in turn — a space or the line's end a block diff --git a/src/markdown/emit/adf-to-markdown.ts b/src/markdown/emit/adf-to-markdown.ts index 4f138fc..5f1e6be 100644 --- a/src/markdown/emit/adf-to-markdown.ts +++ b/src/markdown/emit/adf-to-markdown.ts @@ -20,13 +20,13 @@ import { tryPipeTable } from './pipe-table.ts' type BlockContainer = 'directive' | 'document' | 'list-item' // The usual delimiter, then the one a list beside another of its kind takes. type Delimiters = readonly [string, string] -// CommonMark starts a new list where the marker changes, so a list beside one of its kind takes `alternateText`. -type SpelledBlock = { spelling: 'commonmark' | 'directive'; text: string } | { alternateText: string; spelling: 'list'; text: string } // headroom: the nesting levels the deepest node below may still spend before `largestNesting` refuses it. type EmittedBlock = SpelledBlock & { headroom: number } type KeptSpelling = { block: EmittedBlock | undefined; depth: number } type PlacedBlock = SpelledBlock & { node: AdfNode } type PlacedBlocks = { blocks: readonly PlacedBlock[]; headroom: number } +// CommonMark starts a new list where the marker changes, so a list beside one of its kind takes `alternateText`. +type SpelledBlock = { spelling: 'commonmark' | 'directive'; text: string } | { alternateText: string; spelling: 'list'; text: string } // 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 } @@ -34,9 +34,9 @@ export type Writing = { flavour: WrittenFlavour; memo: SpellingMemo | undefined const bulletDelimiters: Delimiters = ['-', '*'] export const largestListMarker = 999999999 -const orderedDelimiters: Delimiters = ['.', ')'] // Bare because tryList admits no item carrying attributes, marks or text. const listItemOpener = spellDirectiveOpener('listItem', undefined, '') +const orderedDelimiters: Delimiters = ['.', ')'] export function adfToLosslessMarkdown(document: AdfDocument): Result { return writeMarkdown(document, 'lossless') @@ -76,14 +76,15 @@ function joinBlocks(blocks: readonly PlacedBlock[], container: BlockContainer): for (const [index, block] of blocks.entries()) { const previous = blocks[index - 1] if (previous !== undefined) text += separationBetween(previous, block, container) - alternated = !alternated && previous?.spelling === 'list' && block.spelling === 'list' && isOrdered(previous.node) === isOrdered(block.node) + alternated = !alternated && previous !== undefined && sharesMarker(previous, block) text += alternated && block.spelling === 'list' ? block.alternateText : block.text } return text } -function isOrdered(node: AdfNode): boolean { - return node.type === 'orderedList' +// A task list is spelled as a bullet list. +function sharesMarker(previous: PlacedBlock, next: PlacedBlock): boolean { + return previous.spelling === 'list' && next.spelling === 'list' && (previous.node.type === 'orderedList') === (next.node.type === 'orderedList') } function separationBetween(previous: PlacedBlock, next: PlacedBlock, container: BlockContainer): string { @@ -208,7 +209,7 @@ function taskBlocks(task: AdfNode, path: ConvertErrorPath, depth: number, writin 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.spelling !== 'list' ? [{ ...first, text: `${marker} ${first.text}` }, ...rest] : [markerBlock, ...walk.value.blocks] return success({ blocks, headroom: walk.value.headroom }) } @@ -366,7 +367,7 @@ function tryListItemLines(inner: string, marker: string): string | undefined { const lines = body.map((line, index) => (index === 0 ? `${marker}${line}` : line === '' ? '' : `${indent}${line}`)) const first = lines[0] ?? '' if (!isThematicBreak(first)) return lines.join('\n') - // A first line of `*` alone beside the `* ` marker is text, since a rule is spelled `---`: a backslash keeps it from reading as one. + // Only text opens an item with `*`, a rule being spelled `---`; where `* ` before it reads as a rule, a backslash keeps it text. return inner.startsWith('*') ? [`${marker}\\${first.slice(marker.length)}`, ...lines.slice(1)].join('\n') : undefined } diff --git a/src/markdown/portable/adf-to-portable-markdown.ts b/src/markdown/portable/adf-to-portable-markdown.ts index 50ccf8c..0f17918 100644 --- a/src/markdown/portable/adf-to-portable-markdown.ts +++ b/src/markdown/portable/adf-to-portable-markdown.ts @@ -116,16 +116,18 @@ function concatenated(results: readonly Result[]): Result // A list still taking the directive form gives way to its items' blocks. function portableSequence(blocks: readonly AdfNode[], reduction: Reduction): Result { - let sequence = blocks.filter((block) => block.type !== 'paragraph' || nodeContent(block).length > 0) - for (let index = 0; index < sequence.length; index += 1) { - const listed = sequence[index] - if (listed === undefined || (listed.type !== 'bulletList' && listed.type !== 'orderedList')) continue - const block = numberedPastMarkers(listed) - const spelled = block === listed && commonMarkSpelling(block, reduction.path, reduction.depth, { flavour: 'portable', memo: reduction.memo })?.ok === true - if (spelled) continue - sequence = [...sequence.slice(0, index), ...(block === listed ? nodeContent(block).flatMap(nodeContent) : [block]), ...sequence.slice(index + 1)] - // The replacement's first block takes this index, and may itself take the directive form. - index -= 1 + const pending = blocks.filter((block) => block.type !== 'paragraph' || nodeContent(block).length > 0).reverse() + const sequence: AdfNode[] = [] + for (let next = pending.pop(); next !== undefined; next = pending.pop()) { + const block = numberedPastMarkers(next) + const listed = block.type === 'bulletList' || block.type === 'orderedList' + if (!listed || (block === next && commonMarkSpelling(block, reduction.path, reduction.depth, { flavour: 'portable', memo: reduction.memo })?.ok === true)) { + sequence.push(block) + continue + } + // Read next, since a replacement may take the directive form itself. + const replacement = block === next ? nodeContent(block).flatMap(nodeContent) : [block] + for (const node of replacement.reverse()) pending.push(node) } return success(sequence) } diff --git a/todo.md b/todo.md index 83afdd9..1af7fac 100644 --- a/todo.md +++ b/todo.md @@ -6,7 +6,7 @@ `Bar = 9` -`Next ID = 94` +`Next ID = 95` | Goal | W | |---|---| @@ -26,6 +26,7 @@ | ID | Release | Exempt | Item | R | S | A | G | Goals | Score | |---|---|---|---|---|---|---|---|---|---| | 61 | 0.2.0 | decision | **Have the emitter ask the inline reader how a line reads back, in place of `line-escaping.ts` predicting it.** | 6 | 8 | 3 | 8 | 1 | 14.0 | +| 94 | 0.2.0 | defect | **Read two lists a link reference definition parts as two lists, in every reader.** | 3 | 3 | 4 | 7 | 4, 5 | 13.6 | | 93 | 0.2.0 | | **Report a refusal's `path` where the node would stand after `portableMarkdownToAdf` moves it out of a task item.** | 2 | 3 | 3 | 5 | 1 | 13.2 | | 86 | 0.2.0 | | **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`.** | 4 | 4 | 5 | 6 | 4, 6 | 13.0 | | 85 | 0.2.0 | defect | **Write an external image a link marks as `[![alt](url)](href)` in `adfToPortableMarkdown`, keeping the link target.** | 3 | 3 | 6 | 7 | 7 | 12.9 | @@ -76,6 +77,14 @@ only the property tests catch drift. It caps the panel's Locality score. The rep time linear in the line: today `mergesWithSyntax`, `touchesSyntax` and `closesHeading` rescan a run of one character from each of its characters. +### 94. Read two lists a link reference definition parts as two lists, in every reader. + +`openedList` in `src/markdown/parse/blocks.ts` joins a fresh list to the list just before it when +no block stands between, so `- a\n\n[r]: /u\n\n- b` reads as one list; CommonMark reads two, +the definition closing the first. The join exists for an empty item a blank line ends, which +continues its list. `markdown-to-adf.test.ts` asserts the one list. Breaking: `MIGRATION.md`'s +Readings table gains its row. + ### 93. Report a refusal's `path` where the node would stand after `portableMarkdownToAdf` moves it out of a task item. `bulletBlocks` fixes a block's path before `splitTaskList` moves it beside or after its task, so @@ -266,8 +275,8 @@ A writer panel chose it on 2026-10-03, 5 of 7, over today's `colwidth="[340,420] `link-syntax.ts` normalizes a label with `toLowerCase`, so `[ẞ]` misses its `[SS]` definition (spec example 540); lowercasing and then uppercasing folds it. Breaking: `MIGRATION.md`'s Readings table -gains its row. Its `pending` exceptions go, and its spelling leaves the README's "Seven -CommonMark spellings" bullet, which counts one fewer. +gains its row. Its `pending` exceptions go, and its spelling leaves the README's bullet counting +the CommonMark spellings, which counts one fewer. ### 53. Put a block's `marks` spelling to the writer panel and adopt its pick.