From fb1cef719a0f7f88351c22e8366ff0db64aa01b6 Mon Sep 17 00:00:00 2001 From: Lilleman auf Larv Date: Mon, 14 Sep 2026 21:10:10 +0200 Subject: [PATCH] 4.2: a list whose item holds a whitespace-only line takes the directive form --- .../list-item-whitespace-line.json | 57 +++++++++++++++++++ .../combinations/list-item-whitespace-line.md | 20 +++++++ spec/flavour.md | 3 +- src/markdown/commonmark-grammar.ts | 4 ++ src/markdown/emit/adf-to-markdown.ts | 6 +- src/markdown/parse/blocks.ts | 14 ++--- 6 files changed, 94 insertions(+), 10 deletions(-) create mode 100644 corpus/round-trip/combinations/list-item-whitespace-line.json create mode 100644 corpus/round-trip/combinations/list-item-whitespace-line.md diff --git a/corpus/round-trip/combinations/list-item-whitespace-line.json b/corpus/round-trip/combinations/list-item-whitespace-line.json new file mode 100644 index 0000000..e1ffdb8 --- /dev/null +++ b/corpus/round-trip/combinations/list-item-whitespace-line.json @@ -0,0 +1,57 @@ +{ + "content": [ + { + "content": [ + { + "content": [ + { + "content": [ + { + "text": " \na", + "type": "text" + } + ], + "type": "codeBlock" + } + ], + "type": "listItem" + } + ], + "type": "bulletList" + }, + { + "attrs": { + "order": 1 + }, + "content": [ + { + "content": [ + { + "content": [ + { + "content": [ + { + "content": [ + { + "text": "\t", + "type": "text" + } + ], + "type": "codeBlock" + } + ], + "type": "listItem" + } + ], + "type": "bulletList" + } + ], + "type": "listItem" + } + ], + "type": "orderedList" + } + ], + "type": "doc", + "version": 1 +} diff --git a/corpus/round-trip/combinations/list-item-whitespace-line.md b/corpus/round-trip/combinations/list-item-whitespace-line.md new file mode 100644 index 0000000..f4b0d02 --- /dev/null +++ b/corpus/round-trip/combinations/list-item-whitespace-line.md @@ -0,0 +1,20 @@ +::::bulletList +:::listItem +``` + +a +``` +::: +:::: + +::::::orderedList {order=1} +:::::listItem +::::bulletList +:::listItem +``` + +``` +::: +:::: +::::: +:::::: diff --git a/spec/flavour.md b/spec/flavour.md index 98bfb8a..9f8a961 100644 --- a/spec/flavour.md +++ b/spec/flavour.md @@ -28,7 +28,8 @@ normalizes to it through the round-trip. adjacent lists of a kind back as one. The leaf `::listBreak` parts them, taking the separation any directive block takes where it sits. It builds no node, and it reads only between two adjacent lists of one type: elsewhere, or carrying an argument, `{attrs}` or a body, it is a - named error. + named error. A list whose item holds a line of spaces or tabs alone, which a list item reads + back empty, takes the directive form. - Blockquotes prefix lines with `> `; a blank line inside a blockquote is a bare `>`. - ATX headings (`#` … `######`); setext input normalizes to ATX. - Code fences ``` with the node's language as info string, the fence lengthened past any backtick diff --git a/src/markdown/commonmark-grammar.ts b/src/markdown/commonmark-grammar.ts index 0fbefe1..9ed129b 100644 --- a/src/markdown/commonmark-grammar.ts +++ b/src/markdown/commonmark-grammar.ts @@ -167,6 +167,10 @@ export function isAutolink(text: string): boolean { return autolink.test(text) } +export function isBlankLine(line: string): boolean { + return /^[ \t]*$/.test(line) +} + export function isThematicBreak(line: string): boolean { return thematicBreak.test(line) } diff --git a/src/markdown/emit/adf-to-markdown.ts b/src/markdown/emit/adf-to-markdown.ts index 7f861b8..dfd8827 100644 --- a/src/markdown/emit/adf-to-markdown.ts +++ b/src/markdown/emit/adf-to-markdown.ts @@ -6,7 +6,7 @@ import { carriedBlock } from '../opaque-carry.ts' import { emitInlineLine } from './inline-line.ts' import { failure, faulted, success, type ConvertErrorPath, type Result } from '../../result.ts' import { fencedCodeBlock } from '../backtick-runs.ts' -import { holdsNullCharacter, isThematicBreak, markerInterruptsParagraph } from '../commonmark-grammar.ts' +import { holdsNullCharacter, isBlankLine, isThematicBreak, markerInterruptsParagraph } from '../commonmark-grammar.ts' import { languageSlot } from '../code-language.ts' import { largestNesting } from '../../nesting.ts' import { listBreakSpelling } from '../list-break.ts' @@ -225,8 +225,10 @@ function emitListItem(item: AdfNode, marker: string, path: ConvertErrorPath, dep const inner = emitBlocks(nodeContent(item), 'list-item', path, depth + 1) if (!inner.ok) return inner if (inner.value.text === '') return success({ fenceColons: 0, text: marker.trimEnd() }) + const body = inner.value.text.split('\n') + if (body.some((line) => line !== '' && isBlankLine(line))) return undefined const indent = ' '.repeat(marker.length) - const lines = inner.value.text.split('\n').map((line, index) => (index === 0 ? `${marker}${line}` : line === '' ? '' : `${indent}${line}`)) + const lines = body.map((line, index) => (index === 0 ? `${marker}${line}` : line === '' ? '' : `${indent}${line}`)) if (isThematicBreak(lines[0] ?? '')) return undefined return success({ fenceColons: inner.value.fenceColons, text: lines.join('\n') }) } diff --git a/src/markdown/parse/blocks.ts b/src/markdown/parse/blocks.ts index 866ff31..7527d38 100644 --- a/src/markdown/parse/blocks.ts +++ b/src/markdown/parse/blocks.ts @@ -7,6 +7,7 @@ import { claimsPipeLine, closingCodeFence, decodeTextEscapes, + isBlankLine, isThematicBreak, listMarker, markerInterruptsParagraph, @@ -61,7 +62,6 @@ type Line = { column: number; text: string } type Walk = ParsedBlocks & { leaf: OpenLeaf | undefined; position: SourcePosition; stack: OpenContainer[] } -const blankLine = /^[ \t]*$/ const indentedCodeColumns = 4 const largestOpenerIndentation = 3 const leafColons = 2 @@ -117,7 +117,7 @@ function continuesContainer(walk: Walk, container: OpenContainer, line: Line): L // A directive container has no continuation marker: only its own fence closes it. if (container.kind === 'directive') return line // A list item begins with at most one blank line: an empty one gives the second up. - if (blankLine.test(line.text)) { + if (isBlankLine(line.text)) { return container.blocks.length === 0 && walk.leaf === undefined ? undefined : { column: line.column, text: '' } } return leadingColumns(line) < container.indentation ? undefined : removeColumns(line, container.indentation) @@ -155,7 +155,7 @@ function itemStart(line: Line, opener: Line, paragraphOpen: boolean, enclosing: const marker = listMarker(opener.text) if (marker === undefined) return undefined const after: Line = { column: opener.column + marker.width, text: opener.text.slice(marker.width) } - const blank = blankLine.test(after.text) + const blank = isBlankLine(after.text) if (paragraphOpen && !markerInterruptsParagraph(marker.start, blank)) return undefined const spaces = leadingColumns(after) const padding = blank || spaces > indentedCodeColumns ? 1 : spaces @@ -268,7 +268,7 @@ function pushFault(walk: Walk, fault: ConvertFault): void { // A claimed line ends the lazy continuation CommonMark would fold it into (spec/flavour.md). function continuesLazily(walk: Walk, line: Line): boolean { - if (walk.leaf?.kind !== 'paragraph' || blankLine.test(line.text)) return false + if (walk.leaf?.kind !== 'paragraph' || isBlankLine(line.text)) return false if (leadingColumns(line) >= indentedCodeColumns) return true const opener = removeColumns(line, largestOpenerIndentation).text if (claimsDirectiveLine(opener) || claimsPipeLine(opener) || isThematicBreak(opener)) return false @@ -283,7 +283,7 @@ function readBlockLine(walk: Walk, line: Line): void { return } if (leaf?.kind === 'html') { - if (leaf.closer === undefined ? blankLine.test(line.text) : leaf.closer.test(line.text)) closeLeaf(walk) + if (leaf.closer === undefined ? isBlankLine(line.text) : leaf.closer.test(line.text)) closeLeaf(walk) return } if (leaf?.kind === 'pipe-table') { @@ -298,7 +298,7 @@ function readBlockLine(walk: Walk, line: Line): void { if (readIndentedCodeLine(leaf, line)) return closeLeaf(walk) } - if (blankLine.test(line.text)) { + if (isBlankLine(line.text)) { closeLeaf(walk) return } @@ -310,7 +310,7 @@ function readBlockLine(walk: Walk, line: Line): void { } function readIndentedCodeLine(leaf: Extract, line: Line): boolean { - if (blankLine.test(line.text)) { + if (isBlankLine(line.text)) { leaf.held.push(removeColumns(line, indentedCodeColumns).text) return true }