From 559ad4ec3c57c94103cb1ab6bfc1453d78fc8c34 Mon Sep 17 00:00:00 2001 From: Lilleman auf Larv Date: Fri, 9 Oct 2026 14:57:52 +0200 Subject: [PATCH] Answer architecture round 1: a * marker before a first line of * alone takes a backslash, the marker choice is a decision, the spec names which lists alternate, and the stale listBreak test goes --- AGENTS.md | 1 + .../block-nodes/adjacent-lists.json | 19 +++++++++++++++++++ .../round-trip/block-nodes/adjacent-lists.md | 2 ++ docs/decisions.md | 18 +++++++++++++++--- spec/flavour.md | 7 ++++--- src/markdown/emit/adf-to-markdown.test.ts | 3 +++ src/markdown/emit/adf-to-markdown.ts | 6 ++++-- src/markdown/parse/markdown-to-adf.test.ts | 4 ---- 8 files changed, 48 insertions(+), 12 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 0bbc619..cb7ba62 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -13,6 +13,7 @@ In `docs/decisions.md`: - Markdown in is a canonical fixpoint - Equality is deep - `!adf:textBreak{}` parts text CommonMark would join +- A list beside one of its kind takes the other marker - An empty key spells `empty` - `-0` is spelled `-0` - Empty markdown is a document of no blocks diff --git a/corpus/round-trip/block-nodes/adjacent-lists.json b/corpus/round-trip/block-nodes/adjacent-lists.json index 21453bc..985b258 100644 --- a/corpus/round-trip/block-nodes/adjacent-lists.json +++ b/corpus/round-trip/block-nodes/adjacent-lists.json @@ -175,6 +175,25 @@ } ], "type": "bulletList" + }, + { + "content": [ + { + "content": [ + { + "content": [ + { + "text": "**", + "type": "text" + } + ], + "type": "paragraph" + } + ], + "type": "listItem" + } + ], + "type": "bulletList" } ], "type": "doc", diff --git a/corpus/round-trip/block-nodes/adjacent-lists.md b/corpus/round-trip/block-nodes/adjacent-lists.md index a04f222..9c808ad 100644 --- a/corpus/round-trip/block-nodes/adjacent-lists.md +++ b/corpus/round-trip/block-nodes/adjacent-lists.md @@ -14,3 +14,5 @@ - - Head * Tail + +* \** diff --git a/docs/decisions.md b/docs/decisions.md index 0693f92..a0333c8 100644 --- a/docs/decisions.md +++ b/docs/decisions.md @@ -71,6 +71,16 @@ Two adjacent text nodes CommonMark would read back as one are parted by the rese `!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 + +2026-10-04, the maintainer. Goals 4 and 5. Valid while CommonMark starts a new list where the +bullet character or the ordered delimiter changes. + +Every reader parts lists where the marker changes, as CommonMark does. Both writers give a bullet or +task list right after a bullet or task list `* `, and an ordered list right after an ordered one +`1)`; the list after that takes `- ` or `1.` again. CommonMark already spells two adjacent lists, so +no directive parts them. The grammar: `spec/flavour.md` §Canonical form. + ## An empty key spells `empty` 2026-10-03, a writer panel and the maintainer. Goals 1 and 6. Valid while no attribute value @@ -278,7 +288,9 @@ renderer confirming each shape. Reader panels settled `error` as an error panel, (3 of 3) and a Han, Hangul, kana, Thai, Lao, Khmer or Myanmar character on either side bounding a delimiter, so `は==日本語==で` (3 of 3), `==한국어==에서만` and `iPhone==専用==` (6 of 7) highlight, the external image's two forms (6 of 7), a rule opening a list item dropping and the omission -notes (3 of 3), and a list's numbering overflowing into bullets (3 of 3, 5 of 7). +notes (3 of 3), and a list's numbering overflowing into bullets (3 of 3). A 5 of 7 verdict joining +adjacent ordered lists whose numbering breaks into one such list lapsed on 2026-10-09, when a +delimiter change began parting them. An omission note reads as the converter's, never as the author's. Reading takes other tools' spellings, since it reads their output and writes none of them. Rejected: `~sub~` and `^sup^` (`~2~` is a strike on GitHub, so `subsup` drops), underline and colour @@ -689,8 +701,8 @@ two cannot disagree — which is what makes the kept value a memo rather than a 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. `text` and `spelling` carry -no depth and `headroom` is affine in it, so a read at or above the depth that filled the entry +`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. diff --git a/spec/flavour.md b/spec/flavour.md index 8d8a212..d6b9637 100644 --- a/spec/flavour.md +++ b/spec/flavour.md @@ -19,9 +19,10 @@ normalizes to it through the round-trip. single tilde or a run of three or more is literal text — and block structure resolves before inline, so a `~~~` line opens a CommonMark tilde code fence. - Bullet lists `- `; ordered lists incrementing `1.` `2.` `3.`, the first number the node's - `order` attribute. A list right after a list of its kind takes the other marker, `* ` or `1)`, - since CommonMark starts a new list where the bullet character or the delimiter changes; the - list after that takes `- ` or `1.` again. Continuation lines align with the first character + `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 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 diff --git a/src/markdown/emit/adf-to-markdown.test.ts b/src/markdown/emit/adf-to-markdown.test.ts index 13e56a1..49a413a 100644 --- a/src/markdown/emit/adf-to-markdown.test.ts +++ b/src/markdown/emit/adf-to-markdown.test.ts @@ -177,6 +177,9 @@ test('alternates the marker between adjacent lists of a kind, the marker change assert.equal(markdown(adfToLosslessMarkdown(document(nested))), '- - x\n * x\n') const stars: AdfNode = { content: [{ content: [paragraph({ text: '* *', type: 'text' })], type: 'listItem' }], type: 'bulletList' } assert.equal(markdown(adfToLosslessMarkdown(document(list, stars))), '- x\n\n* \\* *\n') + const strong: AdfNode = { content: [{ content: [paragraph({ text: '**', type: 'text' }), paragraph({ text: 'y', type: 'text' })], type: 'listItem' }], type: 'bulletList' } + assert.equal(markdown(adfToLosslessMarkdown(document(strong))), '- **\n\n y\n') + assert.equal(markdown(adfToLosslessMarkdown(document(list, strong))), '- x\n\n* \\**\n\n y\n') const carried: AdfNode = { ...list, attrs: { unknown: 'x' } } assert.ok(markdown(adfToLosslessMarkdown(document(carried, carried))).includes('```\n\n```adf:bulletList\n')) assert.ok(markdown(adfToLosslessMarkdown(document(carried, list))).endsWith('```\n\n- x\n')) diff --git a/src/markdown/emit/adf-to-markdown.ts b/src/markdown/emit/adf-to-markdown.ts index a71a65a..4f138fc 100644 --- a/src/markdown/emit/adf-to-markdown.ts +++ b/src/markdown/emit/adf-to-markdown.ts @@ -364,8 +364,10 @@ function tryListItemLines(inner: string, marker: string): string | undefined { if (body.some((line) => line !== '' && isBlankLine(line))) return undefined const indent = ' '.repeat(marker.length) const lines = body.map((line, index) => (index === 0 ? `${marker}${line}` : line === '' ? '' : `${indent}${line}`)) - if (isThematicBreak(lines[0] ?? '')) return undefined - return lines.join('\n') + 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. + return inner.startsWith('*') ? [`${marker}\\${first.slice(marker.length)}`, ...lines.slice(1)].join('\n') : undefined } function tryParagraph(node: AdfNode, path: ConvertErrorPath, flavour: WrittenFlavour): Result | undefined { diff --git a/src/markdown/parse/markdown-to-adf.test.ts b/src/markdown/parse/markdown-to-adf.test.ts index 9a2ea39..0cda293 100644 --- a/src/markdown/parse/markdown-to-adf.test.ts +++ b/src/markdown/parse/markdown-to-adf.test.ts @@ -284,10 +284,6 @@ test('reads a change of bullet character or ordered delimiter as a second list, assert.deepEqual(path(losslessMarkdownToAdf('- a\n\n* b\n\n| x |\n')), ['content', 2]) }) -test('reads the retired list separator as a directive no node spells', () => { - assert.equal(content(losslessMarkdownToAdf('- a\n\n!adf:listBreak\n\n- b\n')), 'unknown-directive-name: the directive name listBreak reads back to no node; \\!adf: keeps the prefix literal') -}) - test('reads the image a pipe cell holds as its linked alt text', () => { assert.deepEqual(content(losslessMarkdownToAdf('| a |\n| --- |\n| ![x](/u) |\n')), [ { content: [{ content: [cell('tableHeader', text('a'))], type: 'tableRow' }, { content: [cell('tableCell', marked('x', link('/u')))], type: 'tableRow' }], type: 'table' },