diff --git a/corpus/normalization/list-markers.json b/corpus/normalization/list-markers.json new file mode 100644 index 0000000..df8ae34 --- /dev/null +++ b/corpus/normalization/list-markers.json @@ -0,0 +1,92 @@ +{ + "content": [ + { + "content": [ + { + "content": [ + { + "content": [ + { + "text": "Bolt M8", + "type": "text" + } + ], + "type": "paragraph" + } + ], + "type": "listItem" + }, + { + "content": [ + { + "content": [ + { + "text": "Nut M8", + "type": "text" + } + ], + "type": "paragraph" + } + ], + "type": "listItem" + }, + { + "type": "listItem" + }, + { + "content": [ + { + "content": [ + { + "text": "Washer M8", + "type": "text" + } + ], + "type": "paragraph" + } + ], + "type": "listItem" + } + ], + "type": "bulletList" + }, + { + "attrs": { + "order": 1 + }, + "content": [ + { + "content": [ + { + "content": [ + { + "text": "Loosen the clamp", + "type": "text" + } + ], + "type": "paragraph" + } + ], + "type": "listItem" + }, + { + "content": [ + { + "content": [ + { + "text": "Lift the cover", + "type": "text" + } + ], + "type": "paragraph" + } + ], + "type": "listItem" + } + ], + "type": "orderedList" + } + ], + "type": "doc", + "version": 1 +} diff --git a/corpus/normalization/list-markers.md b/corpus/normalization/list-markers.md new file mode 100644 index 0000000..ae8bc61 --- /dev/null +++ b/corpus/normalization/list-markers.md @@ -0,0 +1,8 @@ +- Bolt M8 +* Nut M8 +- + ++ Washer M8 + +1. Loosen the clamp +1) Lift the cover diff --git a/corpus/normalization/loose-list.md b/corpus/normalization/loose-list.md index ea18ebd..97056bc 100644 --- a/corpus/normalization/loose-list.md +++ b/corpus/normalization/loose-list.md @@ -1,6 +1,6 @@ * Bolt M8 -+ Nut M8 +* Nut M8 Then: diff --git a/src/corpus.test.ts b/src/corpus.test.ts index adb9439..df292bc 100644 --- a/src/corpus.test.ts +++ b/src/corpus.test.ts @@ -187,12 +187,17 @@ for (const directory of emittingDirectories) { } for (const name of pairedNames(normalizationRoot, '.md', '.json')) { - test(`normalization/${name} parses to the document beside it`, () => { + test(`normalization/${name} parses to the document beside it, which emits and reads back to itself`, () => { const expected: unknown = JSON.parse(readFileSync(join(normalizationRoot, `${name}.json`), 'utf8')) assert.ok(isAdfDocument(expected), `${name}.json is not an ADF document`) const result = markdownToAdf(readFileSync(join(normalizationRoot, `${name}.md`), 'utf8')) assert.ok(result.ok, result.ok ? '' : `${result.error.code}: ${result.error.message}`) assert.deepEqual(result.value, expected) + const emitted = adfToMarkdown(result.value) + assert.ok(emitted.ok, emitted.ok ? '' : `${emitted.error.code}: ${emitted.error.message}`) + const again = markdownToAdf(emitted.value) + assert.ok(again.ok, again.ok ? '' : `${again.error.code}: ${again.error.message}`) + assert.deepEqual(again.value, expected) }) } diff --git a/src/markdown/code-language.ts b/src/markdown/code-language.ts index d254371..6d0b4b7 100644 --- a/src/markdown/code-language.ts +++ b/src/markdown/code-language.ts @@ -3,10 +3,12 @@ import { carryName } from './opaque-carry.ts' import { holdsControlCharacter } from './commonmark-grammar.ts' import { holdsEntityReference } from './entity-references.ts' -// spec/flavour.md, The CommonMark blocks: the info string the language rides, `undefined` where the attribute carries it. -export function fenceInfo(language: JsonValue | undefined): string | undefined { - if (language === undefined) return '' - if (typeof language !== 'string' || language === '' || language === carryName) return undefined - if (/[`\\]/.test(language) || holdsControlCharacter(language) || language !== language.trim() || holdsEntityReference(language)) return undefined - return language +export type LanguageSlot = { info: string; kind: 'fence' } | { kind: 'attribute' } | { kind: 'none' } + +// spec/flavour.md, The CommonMark blocks: the one slot a codeBlock's language rides, both directions. +export function languageSlot(language: JsonValue | undefined): LanguageSlot { + if (language === undefined) return { kind: 'none' } + if (typeof language !== 'string' || language === '' || language === carryName) return { kind: 'attribute' } + if (/[`\\]/.test(language) || holdsControlCharacter(language) || language !== language.trim() || holdsEntityReference(language)) return { kind: 'attribute' } + return { info: language, kind: 'fence' } } diff --git a/src/markdown/commonmark-grammar.ts b/src/markdown/commonmark-grammar.ts index 570da7c..7a9a828 100644 --- a/src/markdown/commonmark-grammar.ts +++ b/src/markdown/commonmark-grammar.ts @@ -171,13 +171,13 @@ export function isUnicodeWhitespace(character: string): boolean { } // `start` is the list's first number, `undefined` for a bullet. -export function listMarker(line: string): { delimiter: string; start: number | undefined; width: number } | undefined { +export function listMarker(line: string): { start: number | undefined; width: number } | undefined { const ordered = orderedListOpener.exec(line) if (ordered !== null) { const digits = ordered[1] ?? '' - return { delimiter: ordered[2] ?? '', start: Number(digits), width: digits.length + 1 } + return { start: Number(digits), width: digits.length + 1 } } - return bulletListOpener.test(line) ? { delimiter: line.charAt(0), start: undefined, width: 1 } : undefined + return bulletListOpener.test(line) ? { start: undefined, width: 1 } : undefined } export function markerInterruptsParagraph(start: number | undefined, empty: boolean): boolean { diff --git a/src/markdown/emit/adf-to-markdown.ts b/src/markdown/emit/adf-to-markdown.ts index 896d40d..5b5bfef 100644 --- a/src/markdown/emit/adf-to-markdown.ts +++ b/src/markdown/emit/adf-to-markdown.ts @@ -6,7 +6,7 @@ import { carriesOnly, isAdfDocument } from '../../adf/document.ts' import { emitInlineLine } from './inline-line.ts' import { failure, success, type ConvertErrorPath, type Result } from '../../result.ts' import { fencedCodeBlock } from '../backtick-runs.ts' -import { fenceInfo } from '../code-language.ts' +import { languageSlot } from '../code-language.ts' import { holdsNullCharacter, isThematicBreak, markerInterruptsParagraph } from '../commonmark-grammar.ts' import { largestNesting } from '../../nesting.ts' import { spellDirectiveHeader } from './block-directive-spelling.ts' @@ -147,20 +147,21 @@ function emitBlockquote(node: AdfNode, path: ConvertErrorPath, depth: number): R function emitCodeBlock(node: AdfNode, path: ConvertErrorPath): Result | undefined { if (!carriesOnly(node, ['language'])) return undefined - const info = fenceInfo(node.attrs?.['language']) - if (info === undefined) return undefined + const slot = languageSlot(node.attrs?.['language']) + if (slot.kind === 'attribute') return undefined const text = codeBlockText(node, path) if (!text.ok) return text - return success(commonMarkText(fencedCodeBlock(info, text.value))) + return success(commonMarkText(fencedCodeBlock(slot.kind === 'fence' ? slot.info : '', text.value))) } function emitCodeDirective(node: AdfNode, directive: BlockDirective, path: ConvertErrorPath): Result { - const info = fenceInfo(node.attrs?.['language']) - const header = spellDirectiveHeader(node, directive, info === undefined ? [] : ['language']) + const slot = languageSlot(node.attrs?.['language']) + const header = spellDirectiveHeader(node, directive, slot.kind === 'attribute' ? [] : ['language']) if (header === undefined) return commonMarkLine(carriedBlock(node, path)) const text = codeBlockText(node, path) if (!text.ok) return text - return success({ fenceColons: 3, spelling: 'directive', text: `:::${header}\n${fencedCodeBlock(info ?? '', text.value)}\n:::` }) + const info = slot.kind === 'fence' ? slot.info : '' + return success({ fenceColons: 3, spelling: 'directive', text: `:::${header}\n${fencedCodeBlock(info, text.value)}\n:::` }) } function codeBlockText(node: AdfNode, path: ConvertErrorPath): Result { diff --git a/src/markdown/parse/blocks.ts b/src/markdown/parse/blocks.ts index 52cc2f2..125c435 100644 --- a/src/markdown/parse/blocks.ts +++ b/src/markdown/parse/blocks.ts @@ -176,9 +176,19 @@ function openContainer(walk: Walk, start: ContainerStart): void { walk.stack.push(blockquote) return } - if (start.fresh) currentBlocks(walk).push(start.list) - start.list.items.push(blocks) - walk.stack.push({ blocks, indentation: start.indentation, kind: 'item', list: start.list }) + const list = openedList(walk, start) + list.items.push(blocks) + walk.stack.push({ blocks, indentation: start.indentation, kind: 'item', list }) +} + +// Two lists of a kind never sit adjacent: one `- ` spelling reads them back as one (spec/flavour.md). +function openedList(walk: Walk, start: Extract): ListBlock { + if (!start.fresh) return start.list + const blocks = currentBlocks(walk) + const previous = blocks.at(-1) + if ((previous?.kind === 'bulletList' || previous?.kind === 'orderedList') && previous.kind === start.list.kind) return previous + blocks.push(start.list) + return start.list } function closeContainers(walk: Walk, depth: number): void { diff --git a/src/markdown/parse/markdown-to-adf.test.ts b/src/markdown/parse/markdown-to-adf.test.ts index 5798838..d32f96f 100644 --- a/src/markdown/parse/markdown-to-adf.test.ts +++ b/src/markdown/parse/markdown-to-adf.test.ts @@ -390,6 +390,8 @@ test('reads a bullet list, the marker width setting the continuation', () => { assert.deepEqual(content(markdownToAdf('- Code.\n')), [bulletList(item({ content: [text('Code.')], type: 'codeBlock' }))]) assert.deepEqual(content(markdownToAdf('- a\n* b\n')), [bulletList(item(paragraph('a')), item(paragraph('b')))]) assert.deepEqual(content(markdownToAdf('- a\n\n+ b\n')), [bulletList(item(paragraph('a')), item(paragraph('b')))]) + assert.deepEqual(content(markdownToAdf('- a\n-\n\n- c\n')), [bulletList(item(paragraph('a')), item(), item(paragraph('c')))]) + assert.deepEqual(content(markdownToAdf('- a\n1. b\n')), [bulletList(item(paragraph('a'))), orderedList(1, item(paragraph('b')))]) assert.deepEqual(content(markdownToAdf('-\n\n Part.\n')), [bulletList(item()), paragraph('Part.')]) }) diff --git a/src/markdown/parse/markdown-to-adf.ts b/src/markdown/parse/markdown-to-adf.ts index f49c107..9009f5f 100644 --- a/src/markdown/parse/markdown-to-adf.ts +++ b/src/markdown/parse/markdown-to-adf.ts @@ -5,7 +5,7 @@ import type { LinkDefinitions } from './inline-content.ts' import { carryName } from '../opaque-carry.ts' import { commonMarkSpelling } from '../emit/adf-to-markdown.ts' import { failure, faulted, success, type ConvertErrorPath, type Result } from '../../result.ts' -import { fenceInfo } from '../code-language.ts' +import { languageSlot } from '../code-language.ts' import { largestNesting } from '../../nesting.ts' import { parseBlocks } from './blocks.ts' import { parseInlineContent } from './inline-content.ts' @@ -85,8 +85,8 @@ function codeDirectiveNode(node: AdfNode, blocks: readonly Block[], path: Conver if (only?.kind !== 'code') return failure('unsupported-node-shape', `${node.type} takes one fenced code block as its body`, path) const attribute = node.attrs?.['language'] const fromFence = only.language !== '' - const info = fenceInfo(fromFence ? only.language : attribute) - if ((info !== undefined && info !== '') !== fromFence || (fromFence && attribute !== undefined)) { + const slot = languageSlot(fromFence ? only.language : attribute) + if ((slot.kind === 'fence') !== fromFence || (fromFence && attribute !== undefined)) { return failure('unsupported-node-shape', `${node.type} spells its language in the fence info string, or in the attribute where no info string carries it back`, path) } const spelled = fromFence ? { ...node, attrs: { ...node.attrs, language: only.language } } : node diff --git a/todo-history.md b/todo-history.md index ab162a3..5e5de69 100644 --- a/todo-history.md +++ b/todo-history.md @@ -295,13 +295,17 @@ Under **3 — `markdownToAdf` (`0.1.0`)**: `corpus.test.ts` too, and `unspelled-block-separation`, which loses its only cause here. The emitter's other refusals survive on causes no fixture in that directory covers, so 3k's one-list pass is where they get fixtures or the directory goes. - **Settled** (the maintainer, 2026-09-01): losing that cause closed one of the two shapes - input accepted and emit refused, not the last. The other surfaced here — CommonMark opens a - second list on a marker change, so `- a` over `* b` built two adjacent `bulletList` nodes - `adfToMarkdown` refuses — and the parser continues the list instead, the way it already - drops the blank lines between items. With both closed the parse-then-emit fixpoint the - README promises holds for every construct the parser reads, rather than only for what the - emitter wrote. + **Settled** (the maintainer, 2026-09-01): losing that cause closed one of the shapes input + accepted and emit refused, not the last. Two adjacent lists of a kind are what + `adfToMarkdown` refuses and one `- ` spelling cannot hold apart, and the walk reached them + two ways — a marker change, which CommonMark opens a second list on, and an empty last item, + whose blank line pops the container the list's identity hung from. The parser opens no list + beside one of its own kind instead, the way it already drops the blank lines between items; + 3k owes the CommonMark suite an exception where the reference HTML holds two `
    `. The + `normalization/` arm emits each document and reads it back from here, so the population that + class lives in is checked rather than read. The README's canonical-fixpoint sentence still + claims more than the parser keeps — 3e's three shapes — which stays milestone 5's to + narrow. - [x] **3g — The node tables read backwards.** `commonmark-subset/` reads back, the first directory to. A parsed directive becomes its node: the name to the type and an unknown one to a named error, the arg to the attribute it names, each value to the type its section diff --git a/todo.md b/todo.md index 97e47e7..61ec51a 100644 --- a/todo.md +++ b/todo.md @@ -61,9 +61,10 @@ numbering is the order the work was planned in, not the order it ships. parsing the slot inside it is a cycle; the four directive marks get `parse/directive-marks.ts` that `inline-content.ts` tries ahead of the node reader, as `mark-spellings.ts` sits apart from `emit/inline-directive-spelling.ts`; and the five markdown-spelled mark names in inline - directive position want a claim code — `:em[x]` is an error forever, so - `unknown-directive-name`'s "a later MINOR may give the name meaning" is the wrong signal, - as it was for `adf`. `corpus/errors/directive-content-slot` goes when the slot opens. + directive position take `unsupported-node-shape` rather than a code of their own — §8 + already answers a well-formed directive the node tables refuse, and the message names the + spelling to use (`*x*`), while `unknown-directive-name`'s "a later MINOR may give the name + meaning" stays the wrong signal, as it was for `adf`. `corpus/errors/directive-content-slot` goes when the slot opens. The marks a spelling wraps answer the same question 3g settled for a block's form: only the nesting the emitter writes parses back. - [ ] **3j — The carry and the combinations.** `opaque-carry/` and `combinations/` read back: @@ -123,7 +124,10 @@ numbering is the order the work was planned in, not the order it ships. landed the last decision `corpus/unspellable/` held and the directory went with it, so what the code list holds from here is permanent. The parser's own code additions are read here as one list before that freeze — nine sessions mint them - independently, and one cause wearing two codes is breaking to undo after `0.1.0`. `0.1.0` + independently, and one cause wearing two codes is breaking to undo after `0.1.0`. That read + gets a test rather than an eye — every `ConvertErrorCode` member named at a production call + site, the way `spec.test.ts` guards the node tables — since `unspelled-block-separation` + outlived its cause until 3h went looking. `0.1.0` is the markdown round-trip: both markdown directions, the types, `isAdfDocument`. The build lands here: `tsconfig.build.json` gains emit of JS and `.d.ts` to `dist/` (its own `allowImportingTsExtensions` forces `noEmit`, so `rewriteRelativeImportExtensions` lands