From 81422c8a1d8c71aeac05ab0d7057a61e48b31d60 Mon Sep 17 00:00:00 2001 From: ocavue Date: Wed, 21 Oct 2026 21:47:01 +1100 Subject: [PATCH 1/3] feat: keep list item gaps as written --- .changeset/list-blank-before.md | 6 ++ packages/core/src/converters/ast-to-pm.ts | 1 + packages/core/src/converters/md-to-pm.test.ts | 15 +++ packages/core/src/converters/pm-to-ast.ts | 1 + packages/core/src/converters/pm-to-md.test.ts | 9 ++ .../core/src/converters/roundtrip.test.ts | 8 +- .../core/src/converters/sample-content.ts | 5 + .../core/src/extensions/extension.test.ts | 3 + packages/core/src/extensions/list.ts | 18 ++++ .../markdown/src/ast/check-roundtrip.test.ts | 17 ++-- packages/markdown/src/ast/equal.ts | 3 +- packages/markdown/src/ast/gap.ts | 91 +++++++++++++++++++ packages/markdown/src/ast/parse.ts | 50 +++++++--- packages/markdown/src/ast/serialize.ts | 64 +++++-------- packages/markdown/src/ast/types.ts | 5 + 15 files changed, 232 insertions(+), 64 deletions(-) create mode 100644 .changeset/list-blank-before.md create mode 100644 packages/markdown/src/ast/gap.ts diff --git a/.changeset/list-blank-before.md b/.changeset/list-blank-before.md new file mode 100644 index 00000000..80440b91 --- /dev/null +++ b/.changeset/list-blank-before.md @@ -0,0 +1,6 @@ +--- +'@meowdown/markdown': minor +'@meowdown/core': minor +--- + +Keep the blank lines between list items and around nested lists as written. diff --git a/packages/core/src/converters/ast-to-pm.ts b/packages/core/src/converters/ast-to-pm.ts index da865f56..6b020513 100644 --- a/packages/core/src/converters/ast-to-pm.ts +++ b/packages/core/src/converters/ast-to-pm.ts @@ -40,6 +40,7 @@ export function astToDoc( marker: node.marker ?? null, taskMarker: node.taskMarker, markerGap: node.markerGap, + blankBefore: node.blankBefore ?? null, }, children, ) diff --git a/packages/core/src/converters/md-to-pm.test.ts b/packages/core/src/converters/md-to-pm.test.ts index 8618af2a..3c55ca56 100644 --- a/packages/core/src/converters/md-to-pm.test.ts +++ b/packages/core/src/converters/md-to-pm.test.ts @@ -212,6 +212,7 @@ describe('markdownToDoc', () => { marker: '-', taskMarker: null, markerGap: 1, + blankBefore: null, }, content: [{ type: 'paragraph', content: [{ type: 'text', text: 'one' }] }], }, @@ -225,6 +226,7 @@ describe('markdownToDoc', () => { marker: '-', taskMarker: null, markerGap: 1, + blankBefore: false, }, content: [{ type: 'paragraph', content: [{ type: 'text', text: 'two' }] }], }, @@ -276,6 +278,7 @@ describe('markdownToDoc', () => { marker: '.', taskMarker: null, markerGap: 1, + blankBefore: null, }, content: [{ type: 'paragraph', content: [{ type: 'text', text: 'five' }] }], }, @@ -289,6 +292,7 @@ describe('markdownToDoc', () => { marker: '.', taskMarker: null, markerGap: 1, + blankBefore: false, }, content: [{ type: 'paragraph', content: [{ type: 'text', text: 'six' }] }], }, @@ -311,6 +315,7 @@ describe('markdownToDoc', () => { marker: '-', taskMarker: null, markerGap: 1, + blankBefore: null, }, content: [{ type: 'paragraph', content: [{ type: 'text', text: 'todo' }] }], }, @@ -333,6 +338,7 @@ describe('markdownToDoc', () => { marker: '-', taskMarker: 'x', markerGap: 1, + blankBefore: null, }, content: [{ type: 'paragraph', content: [{ type: 'text', text: 'done' }] }], }, @@ -356,6 +362,7 @@ describe('markdownToDoc', () => { marker: '-', taskMarker: 'x', markerGap: 1, + blankBefore: null, }, content: [{ type: 'paragraph', content: [{ type: 'text', text: 'Square Task' }] }], }, @@ -369,6 +376,7 @@ describe('markdownToDoc', () => { marker: '+', taskMarker: null, markerGap: 1, + blankBefore: true, }, content: [{ type: 'paragraph', content: [{ type: 'text', text: 'Circle Task' }] }], }, @@ -405,6 +413,7 @@ describe('markdownToDoc', () => { marker: '.', taskMarker: null, markerGap: 1, + blankBefore: null, }, content: [{ type: 'paragraph', content: [{ type: 'text', text: '[x] done' }] }], }, @@ -458,6 +467,7 @@ describe('markdownToDoc', () => { marker: '-', taskMarker: null, markerGap: 1, + blankBefore: null, }, content: [ { type: 'paragraph', content: [{ type: 'text', text: 'x' }] }, @@ -685,6 +695,7 @@ describe('markdownToDoc', () => { marker: '*', taskMarker: null, markerGap: 1, + blankBefore: null, }, content: [{ type: 'paragraph', content: [{ type: 'text', text: 'star' }] }], }, @@ -707,6 +718,7 @@ describe('markdownToDoc', () => { marker: ')', taskMarker: null, markerGap: 1, + blankBefore: null, }, content: [{ type: 'paragraph', content: [{ type: 'text', text: 'paren' }] }], }, @@ -729,6 +741,7 @@ describe('markdownToDoc', () => { marker: '.', taskMarker: null, markerGap: 1, + blankBefore: null, }, content: [{ type: 'paragraph', content: [{ type: 'text', text: 'zero' }] }], }, @@ -751,6 +764,7 @@ describe('markdownToDoc', () => { marker: '-', taskMarker: 'X', markerGap: 1, + blankBefore: null, }, content: [{ type: 'paragraph', content: [{ type: 'text', text: 'upper' }] }], }, @@ -989,6 +1003,7 @@ describe('markdownToDoc', () => { marker: '-', taskMarker: null, markerGap: 1, + blankBefore: null, }, content: [ { type: 'paragraph', content: [{ type: 'text', text: 'x' }] }, diff --git a/packages/core/src/converters/pm-to-ast.ts b/packages/core/src/converters/pm-to-ast.ts index 181b78f0..f4560517 100644 --- a/packages/core/src/converters/pm-to-ast.ts +++ b/packages/core/src/converters/pm-to-ast.ts @@ -72,6 +72,7 @@ function readBlock(node: ProseMirrorNode): MarkdownBlock { marker: attrs.marker ?? undefined, taskMarker: attrs.taskMarker ?? undefined, markerGap: attrs.markerGap, + blankBefore: attrs.blankBefore ?? undefined, children: readBlocks(node), } } diff --git a/packages/core/src/converters/pm-to-md.test.ts b/packages/core/src/converters/pm-to-md.test.ts index b4e96761..8aad5bcd 100644 --- a/packages/core/src/converters/pm-to-md.test.ts +++ b/packages/core/src/converters/pm-to-md.test.ts @@ -197,6 +197,15 @@ describe('docToMarkdown', () => { expect(markdown).toBe('- first\n\n second\n\n- third\n') }) + it('continues the gap of the item above it', () => { + const doc = n.doc( + n.list({ kind: 'bullet' }, n.paragraph('a')), + n.list({ kind: 'bullet', blankBefore: true }, n.paragraph('b')), + n.list({ kind: 'bullet' }, n.paragraph('c')), + ) + expect(docToMarkdown(doc)).toBe('- a\n\n- b\n\n- c\n') + }) + it('keeps a blank line after a list', () => { const doc = n.doc(n.list({ kind: 'bullet' }, n.paragraph('item')), n.paragraph('after')) const markdown = docToMarkdown(doc) diff --git a/packages/core/src/converters/roundtrip.test.ts b/packages/core/src/converters/roundtrip.test.ts index a44f46b9..e6a7f179 100644 --- a/packages/core/src/converters/roundtrip.test.ts +++ b/packages/core/src/converters/roundtrip.test.ts @@ -453,7 +453,7 @@ describe('bullet lists', () => { expect(roundtrip('- a\n - deep')).toBe('- a\n - deep\n') }) - it.fails('keeps a loose list', () => { + it('keeps a loose list', () => { const md = ['- a', '', '- b'].join('\n') expect(roundtrip(md)).toBe(md + '\n') }) @@ -466,12 +466,12 @@ describe('bullet lists', () => { expect(roundtrip('- a\n- \n- b')).toBe('- a\n-\n- b\n') }) - it.fails('keeps a tight sibling after a two-block item', () => { + it('keeps a tight sibling after a two-block item', () => { const md = ['- a', '', ' b', '- c'].join('\n') expect(roundtrip(md)).toBe(md + '\n') }) - it.fails('keeps a nested list right under its paragraph in a loose list', () => { + it('keeps a nested list right under its paragraph in a loose list', () => { const md = ['- a', '', ' b', '- c', ' - d'].join('\n') expect(roundtrip(md)).toBe(md + '\n') }) @@ -1071,7 +1071,7 @@ describe('mixed structures', () => { expect(roundtrip('# h1\n## h2\n### h3')).toBe('# h1\n## h2\n### h3\n') }) - it.fails('keeps a list right under a paragraph', () => { + it('keeps a list right under a paragraph', () => { expect(roundtrip('para\n- a')).toBe('para\n- a\n') }) diff --git a/packages/core/src/converters/sample-content.ts b/packages/core/src/converters/sample-content.ts index 59fc30b4..86d1ab52 100644 --- a/packages/core/src/converters/sample-content.ts +++ b/packages/core/src/converters/sample-content.ts @@ -71,6 +71,7 @@ export const sampleContent: NodeJSON = { marker: '-', taskMarker: null, markerGap: 1, + blankBefore: true, }, content: [ { @@ -89,6 +90,7 @@ export const sampleContent: NodeJSON = { marker: '-', taskMarker: null, markerGap: 1, + blankBefore: false, }, content: [ { @@ -105,6 +107,7 @@ export const sampleContent: NodeJSON = { marker: '-', taskMarker: null, markerGap: 1, + blankBefore: false, }, content: [ { @@ -130,6 +133,7 @@ export const sampleContent: NodeJSON = { marker: '.', taskMarker: null, markerGap: 1, + blankBefore: true, }, content: [ { @@ -148,6 +152,7 @@ export const sampleContent: NodeJSON = { marker: '.', taskMarker: null, markerGap: 1, + blankBefore: false, }, content: [ { diff --git a/packages/core/src/extensions/extension.test.ts b/packages/core/src/extensions/extension.test.ts index c3360073..a2368a6d 100644 --- a/packages/core/src/extensions/extension.test.ts +++ b/packages/core/src/extensions/extension.test.ts @@ -71,6 +71,7 @@ describe('defineEditorExtension', () => { }, { "attrs": { + "blankBefore": null, "checked": false, "collapsed": false, "kind": "bullet", @@ -94,6 +95,7 @@ describe('defineEditorExtension', () => { }, { "attrs": { + "blankBefore": null, "checked": false, "collapsed": false, "kind": "ordered", @@ -117,6 +119,7 @@ describe('defineEditorExtension', () => { }, { "attrs": { + "blankBefore": null, "checked": false, "collapsed": false, "kind": "task", diff --git a/packages/core/src/extensions/list.ts b/packages/core/src/extensions/list.ts index 5a32e9b6..84975c53 100644 --- a/packages/core/src/extensions/list.ts +++ b/packages/core/src/extensions/list.ts @@ -77,6 +77,7 @@ export interface MeowdownListAttrs extends ListAttrs { marker?: ListMarker | undefined taskMarker?: TaskMarker | undefined markerGap?: number | undefined + blankBefore?: boolean | null | undefined } // Each attr persists only its non-canonical values (`.`, `-`, `x`, and a gap @@ -165,6 +166,22 @@ function defineListMarkerGapAttr(): ListMarkerGapExtension { }) } +type ListBlankBeforeExtension = Extension<{ + Nodes: { list: { blankBefore?: boolean | null | undefined } } +}> + +/** + * Whether one blank line separated the item from the sibling before it in the + * Markdown source. `null` leaves the gap to the serializer. + */ +function defineListBlankBeforeAttr(): ListBlankBeforeExtension { + return defineNodeAttr<'list', 'blankBefore', boolean | null>({ + type: 'list' satisfies NodeName, + attr: 'blankBefore', + default: null, + }) +} + function getListClipboardAttributes(node: ProseMirrorNode): Record { const attrs = node.attrs as MeowdownListAttrs return { @@ -529,6 +546,7 @@ export function defineMeowdownList() { defineListMarkerAttr(), defineListTaskMarkerAttr(), defineListMarkerGapAttr(), + defineListBlankBeforeAttr(), defineMeowdownListCommands(), ) } diff --git a/packages/markdown/src/ast/check-roundtrip.test.ts b/packages/markdown/src/ast/check-roundtrip.test.ts index 84680eb0..9d1854d4 100644 --- a/packages/markdown/src/ast/check-roundtrip.test.ts +++ b/packages/markdown/src/ast/check-roundtrip.test.ts @@ -121,20 +121,23 @@ const EXACT_CASES: string[] = [ // a no-break space is heading text, not the gap around a closing `#` run '# #\u{A0}', -] -const NORMALIZING_CASES: string[] = [ - // a loose list serializes tight + // a loose list stays loose, and a tight list after a two-block item tight '- a\n\n- b', + '1. a\n\n2. b', + '- a\n\n b\n- c', + + // a list starts right under a paragraph, in a blockquote too + 'para\n- a', + '> text\n> - item', + '> a\n> - x\n> - y', +] +const NORMALIZING_CASES: string[] = [ // the items of an ordered list are numbered in sequence '1. a\n1. b\n1. c', '3) a\n7) b', - // a blockquote gains an empty `>` line before a following list - '> text\n> - item', - '> a\n> - x\n> - y', - // a lazy continuation gains the canonical item indent '- [ ] todo\neen voorlopig idee', '- item\nlazy line', diff --git a/packages/markdown/src/ast/equal.ts b/packages/markdown/src/ast/equal.ts index 4bcdb511..7bedba52 100644 --- a/packages/markdown/src/ast/equal.ts +++ b/packages/markdown/src/ast/equal.ts @@ -57,7 +57,8 @@ function isListItemEqual(a: MarkdownListItem, b: MarkdownListItem): boolean { a.collapsed === b.collapsed && a.marker === b.marker && a.taskMarker === b.taskMarker && - a.markerGap === b.markerGap + a.markerGap === b.markerGap && + a.blankBefore === b.blankBefore ) } diff --git a/packages/markdown/src/ast/gap.ts b/packages/markdown/src/ast/gap.ts new file mode 100644 index 00000000..56ed6741 --- /dev/null +++ b/packages/markdown/src/ast/gap.ts @@ -0,0 +1,91 @@ +import type { MarkdownBlock, MarkdownInline, MarkdownListItem } from './types.ts' + +/** + * Whether `next` can start on the line right under `previous` and still read + * back as its own block. The parser records a tight gap only where this holds, + * so the serializer can always honor it. + */ +export function canJoinTight(previous: MarkdownBlock, next: MarkdownBlock): boolean { + if (isEmptyParagraph(previous) || isEmptyParagraph(next)) return false + switch (next.type) { + case 'listItem': + // Lezer reads a bare marker of another list under an item's paragraph as + // more of that paragraph, so only the same list carries on regardless. + return ( + (previous.type === 'listItem' && listMarkerChar(previous) === listMarkerChar(next)) || + endsClosed(getLastBlock(previous)) || + canInterruptParagraph(next) + ) + default: + return false + } +} + +/** + * Whether `list`'s marker line still opens an item when the line above it is a + * paragraph. CommonMark lets a list interrupt a paragraph only when the item + * carries content and, if ordered, is numbered 1; anything else reads as more of + * the paragraph, so the blank line before it is what keeps it a list at all. + */ +function canInterruptParagraph(list: MarkdownListItem): boolean { + const attrs = list + if (attrs.kind === 'ordered' && (attrs.order ?? 1) !== 1) return false + // An empty item writes a bare marker, and a bare marker interrupts nothing. + // A task always writes its `[ ]`, which is content enough. + return attrs.kind === 'task' || (list.children.length > 0 && hasBlockContent(list.children[0])) +} + +/** + * The character a list item's marker ends with: the bullet itself, or the + * delimiter after an ordered item's number. A markdown list runs for as long as + * this stays the same, and a different one opens a new list. + */ +export function listMarkerChar(node: MarkdownBlock): string { + if (node.type !== 'listItem') return '' + const { kind, marker, collapsed } = node + if (kind === 'ordered') return marker === ')' ? ')' : '.' + if (kind === 'task') return marker === '+' ? '+' : marker === '*' ? '*' : '-' + return collapsed ? '+' : marker === '*' ? '*' : '-' +} + +export function hasInlineContent(node: MarkdownInline): boolean { + return node.segments?.value === node.value ? node.segments.chunks.length > 0 : node.value !== '' +} + +function hasBlockContent(node: MarkdownBlock): boolean { + if (node.children) return node.children.length > 0 + if (node.type === 'ignored') return node.hasContent + if (node.type === 'paragraph' || node.type === 'heading') return hasInlineContent(node) + return node.type === 'codeBlock' && node.value !== '' +} + +/** + * Whether the line after `block` cannot continue it: no paragraph to extend + * lazily, no table to add a row to. + */ +function endsClosed(block: MarkdownBlock): boolean { + switch (block.type) { + case 'heading': + case 'horizontalRule': + case 'htmlComment': + return true + case 'codeBlock': + return block.fenceStyle !== 'indented' + default: + return false + } +} + +/** + * The innermost block that holds `block`'s last line. + */ +function getLastBlock(block: MarkdownBlock): MarkdownBlock { + while ((block.type === 'listItem' || block.type === 'blockquote') && block.children.length > 0) { + block = block.children[block.children.length - 1] + } + return block +} + +function isEmptyParagraph(block: MarkdownBlock): boolean { + return block.type === 'paragraph' && !hasInlineContent(block) +} diff --git a/packages/markdown/src/ast/parse.ts b/packages/markdown/src/ast/parse.ts index 53a9de03..b84acfc1 100644 --- a/packages/markdown/src/ast/parse.ts +++ b/packages/markdown/src/ast/parse.ts @@ -20,6 +20,7 @@ import { } from '../unicode.ts' import { canIndentCode, minFenceLength } from './code.ts' +import { canJoinTight } from './gap.ts' import { walkMarkdownAst } from './path.ts' import type { MarkdownBlock, @@ -147,9 +148,12 @@ function collectBlocks( appendBlankLineParagraphs(out, text, 0, cursor.from, afterFrontmatter ? 1 : 0, false) let previousTo: number | undefined do { - if (previousTo != null) appendGapParagraphs(out, text, previousTo, cursor.from) + const blankLines = + previousTo == null ? Infinity : appendGapParagraphs(out, text, previousTo, cursor.from) previousTo = cursor.to - appendBlocks(out, convertBlock(cursor, text, column)) + const blocks = convertBlock(cursor, text, column) + markBlankBefore(out.at(-1), blocks[0], blankLines) + appendBlocks(out, blocks) } while (cursor.nextSibling()) cursor.parent() // The last line's own terminator is not a blank line. @@ -189,8 +193,23 @@ function appendGapParagraphs( text: string, gapFrom: number, gapTo: number, +): number { + return appendBlankLineParagraphs(out, text, gapFrom, gapTo, 2, false) - 1 +} + +/** + * Record whether the one optional blank line was written before a list item. + * Two or more blank lines are empty paragraphs, which say it already. + */ +function markBlankBefore( + previous: MarkdownBlock | undefined, + block: MarkdownBlock | undefined, + blankLines: number, ): void { - appendBlankLineParagraphs(out, text, gapFrom, gapTo, 2, false) + if (block?.type !== 'listItem' || previous == null || blankLines > 1) return + // A tight gap the serializer cannot write goes out with its blank line, so + // record that blank line now and the tree reads back the same. + block.blankBefore = !(blankLines === 0 && canJoinTight(previous, block)) } /** @@ -208,7 +227,7 @@ function appendBlankLineParagraphs( to: number, skip: number, openLast: boolean, -): void { +): number { let count = 0 for (let index = from; index < to; index++) { if (text.charCodeAt(index) !== CHAR_LINE_FEED) continue @@ -216,6 +235,7 @@ function appendBlankLineParagraphs( if (count > skip) out.push(buildEmptyParagraph(index)) } if (openLast && count >= skip) out.push(buildEmptyParagraph(lineEndAt(text, to))) + return count } /** @@ -605,13 +625,16 @@ function convertBlockquote(cursor: TreeCursor, text: string): MarkdownBlock { if (cursor.firstChild()) { do { if (cursor.type.id === LEZER_NODE_IDS.QuoteMark) continue + let blankLines = Infinity if (previousTo == null) { appendBlankLineParagraphs(content, text, from, cursor.from, 0, false) } else { - appendGapParagraphs(content, text, previousTo, cursor.from) + blankLines = appendGapParagraphs(content, text, previousTo, cursor.from) } previousTo = cursor.to - appendBlocks(content, convertBlock(cursor, text, 0)) + const blocks = convertBlock(cursor, text, 0) + markBlankBefore(content.at(-1), blocks[0], blankLines) + appendBlocks(content, blocks) } while (cursor.nextSibling()) cursor.parent() } @@ -639,10 +662,12 @@ function convertList( let previousContentEnd: number | undefined do { if (cursor.type.id !== LEZER_NODE_IDS.ListItem) continue - if (previousContentEnd != null) { - appendGapParagraphs(out, text, previousContentEnd, cursor.from) - } + const blankLines = + previousContentEnd == null + ? Infinity + : appendGapParagraphs(out, text, previousContentEnd, cursor.from) const [item, contentEnd] = convertListItem(cursor, text, column, kind) + markBlankBefore(out.at(-1), item, blankLines) out.push(item) previousContentEnd = contentEnd } while (cursor.nextSibling()) @@ -779,11 +804,12 @@ function convertListItem( markEndColumn = measureContentColumn(text, cursor.to) continue } + let blankLines = Infinity if (previousTo == null) { markerGap = measureMarkerGap(text, cursor.from, markTo, markEndColumn) contentColumn = column + markWidth + markerGap } else { - appendGapParagraphs(content, text, previousTo, cursor.from) + blankLines = appendGapParagraphs(content, text, previousTo, cursor.from) } previousTo = cursor.to if (kind === 'bullet' && cursor.type.id === LEZER_NODE_IDS.Task) { @@ -793,7 +819,9 @@ function convertListItem( content.push(task.paragraph) continue } - appendBlocks(content, convertBlock(cursor, text, contentColumn)) + const blocks = convertBlock(cursor, text, contentColumn) + markBlankBefore(content.at(-1), blocks[0], blankLines) + appendBlocks(content, blocks) } while (cursor.nextSibling()) cursor.parent() } diff --git a/packages/markdown/src/ast/serialize.ts b/packages/markdown/src/ast/serialize.ts index eb4bae8e..de396e55 100644 --- a/packages/markdown/src/ast/serialize.ts +++ b/packages/markdown/src/ast/serialize.ts @@ -20,6 +20,7 @@ import { } from '../unicode.ts' import { canIndentCode, minFenceLength } from './code.ts' +import { canJoinTight, hasInlineContent, listMarkerChar } from './gap.ts' import type { MarkdownBlock, MarkdownCodeBlock, @@ -457,13 +458,8 @@ function emitBlockChildren(children: MarkdownBlock[], out: MdOut, tightItem = fa for (let item = index; item < runEnd; item++) { const child = children[item] if (child.type !== 'listItem') continue - const isRunStart = item === index - const tight = isRunStart ? tightItem && index > 0 : tightRun - // An item that carries on the list above it needs no blank line. One that - // opens a new list does, unless its own marker can interrupt what it - // follows - which is a paragraph whenever the blank is being dropped. - const carriesOn = !isRunStart && listMarkerChar(children[item - 1]) === listMarkerChar(child) - if (tight && (carriesOn || canInterruptParagraph(child))) out.suppressBlank() + if (isTightBefore(children, item, index, tightItem, tightRun)) out.suppressBlank() + const carriesOn = item > index && listMarkerChar(children[item - 1]) === listMarkerChar(child) // Only the first item of an ordered list sets its number; the items // after it count up, as a Markdown renderer numbers them. order = carriesOn ? Math.min(order + 1, MAX_LIST_ORDER) : (child.order ?? 1) @@ -474,30 +470,27 @@ function emitBlockChildren(children: MarkdownBlock[], out: MdOut, tightItem = fa } /** - * Whether `list`'s marker line still opens an item when the line above it is a - * paragraph. CommonMark lets a list interrupt a paragraph only when the item - * carries content and, if ordered, is numbered 1; anything else reads as more of - * the paragraph, so the blank line before it is what keeps it a list at all. + * Whether the list item at `item` starts on the line right under its previous + * sibling. A recorded gap wins. An item the editor created has none, and takes + * the gap above it in the same run, or else the run's own tightness. */ -function canInterruptParagraph(list: MarkdownListItem): boolean { - const attrs = list - if (attrs.kind === 'ordered' && (attrs.order ?? 1) !== 1) return false - // An empty item writes a bare marker, and a bare marker interrupts nothing. - // A task always writes its `[ ]`, which is content enough. - return attrs.kind === 'task' || (list.children.length > 0 && hasBlockContent(list.children[0])) -} - -/** - * The character a list item's marker ends with: the bullet itself, or the - * delimiter after an ordered item's number. A markdown list runs for as long as - * this stays the same, and a different one opens a new list. - */ -function listMarkerChar(node: MarkdownBlock): string { - if (node.type !== 'listItem') return '' - const { kind, marker, collapsed } = node - if (kind === 'ordered') return marker === ')' ? ')' : '.' - if (kind === 'task') return marker === '+' ? '+' : marker === '*' ? '*' : '-' - return collapsed ? '+' : marker === '*' ? '*' : '-' +function isTightBefore( + children: MarkdownBlock[], + item: number, + runStart: number, + tightItem: boolean, + tightRun: boolean, +): boolean { + if (item === 0) return false + const child = children[item] + const previous = children[item - 1] + if (child.type !== 'listItem') return false + let blankBefore = child.blankBefore + if (blankBefore == null && item - 1 > runStart && previous.type === 'listItem') { + blankBefore = previous.blankBefore + } + const tight = blankBefore == null ? (item === runStart ? tightItem : tightRun) : !blankBefore + return tight && canJoinTight(previous, child) } /** @@ -764,17 +757,6 @@ function emitInlineChildren(node: MarkdownInline, out: MdOut): void { } } -function hasInlineContent(node: MarkdownInline): boolean { - return node.segments?.value === node.value ? node.segments.chunks.length > 0 : node.value !== '' -} - -function hasBlockContent(node: MarkdownBlock): boolean { - if (node.children) return node.children.length > 0 - if (node.type === 'ignored') return node.hasContent - if (node.type === 'paragraph' || node.type === 'heading') return hasInlineContent(node) - return node.type === 'codeBlock' && node.value !== '' -} - // ───────────────────────────────────────────────────────────────────── // List // ───────────────────────────────────────────────────────────────────── diff --git a/packages/markdown/src/ast/types.ts b/packages/markdown/src/ast/types.ts index b4d7e40c..410dd63f 100644 --- a/packages/markdown/src/ast/types.ts +++ b/packages/markdown/src/ast/types.ts @@ -104,6 +104,11 @@ export interface MarkdownListItem extends MarkdownPositioned { * Spaces after the list marker, clamped to 1–4 when serialized. */ markerGap?: number | undefined + /** + * Whether one blank line separates this item from the sibling before it. + * Undefined leaves the choice to the serializer. + */ + blankBefore?: boolean | undefined /** * Never empty: an item with no content holds one empty paragraph. */ From 58fff7110b1a20a307b8bd04aac0d08a4f03800f Mon Sep 17 00:00:00 2001 From: ocavue Date: Wed, 21 Oct 2026 21:48:12 +1100 Subject: [PATCH 2/3] refactor: mark list item gaps in `appendBlocks` --- packages/markdown/src/ast/parse.ts | 22 +++++++++------------- 1 file changed, 9 insertions(+), 13 deletions(-) diff --git a/packages/markdown/src/ast/parse.ts b/packages/markdown/src/ast/parse.ts index b84acfc1..0e5aae9f 100644 --- a/packages/markdown/src/ast/parse.ts +++ b/packages/markdown/src/ast/parse.ts @@ -151,9 +151,7 @@ function collectBlocks( const blankLines = previousTo == null ? Infinity : appendGapParagraphs(out, text, previousTo, cursor.from) previousTo = cursor.to - const blocks = convertBlock(cursor, text, column) - markBlankBefore(out.at(-1), blocks[0], blankLines) - appendBlocks(out, blocks) + appendBlocks(out, convertBlock(cursor, text, column), blankLines) } while (cursor.nextSibling()) cursor.parent() // The last line's own terminator is not a blank line. @@ -166,8 +164,11 @@ function collectBlocks( * Append `blocks`, dropping the indented spelling from a code block that lands * right after a list: four columns of indentation would put it inside the item * above instead of after it, so it can only be written as a fence. + * `blankLines` counts the blank lines between `out`'s last block and the first + * of `blocks`. */ -function appendBlocks(out: MarkdownBlock[], blocks: MarkdownBlock[]): void { +function appendBlocks(out: MarkdownBlock[], blocks: MarkdownBlock[], blankLines: number): void { + markBlankBefore(out.at(-1), blocks[0], blankLines) for (const block of blocks) { if ( block.type === 'codeBlock' && @@ -632,9 +633,7 @@ function convertBlockquote(cursor: TreeCursor, text: string): MarkdownBlock { blankLines = appendGapParagraphs(content, text, previousTo, cursor.from) } previousTo = cursor.to - const blocks = convertBlock(cursor, text, 0) - markBlankBefore(content.at(-1), blocks[0], blankLines) - appendBlocks(content, blocks) + appendBlocks(content, convertBlock(cursor, text, 0), blankLines) } while (cursor.nextSibling()) cursor.parent() } @@ -804,12 +803,11 @@ function convertListItem( markEndColumn = measureContentColumn(text, cursor.to) continue } - let blankLines = Infinity + const blankLines = + previousTo == null ? Infinity : appendGapParagraphs(content, text, previousTo, cursor.from) if (previousTo == null) { markerGap = measureMarkerGap(text, cursor.from, markTo, markEndColumn) contentColumn = column + markWidth + markerGap - } else { - blankLines = appendGapParagraphs(content, text, previousTo, cursor.from) } previousTo = cursor.to if (kind === 'bullet' && cursor.type.id === LEZER_NODE_IDS.Task) { @@ -819,9 +817,7 @@ function convertListItem( content.push(task.paragraph) continue } - const blocks = convertBlock(cursor, text, contentColumn) - markBlankBefore(content.at(-1), blocks[0], blankLines) - appendBlocks(content, blocks) + appendBlocks(content, convertBlock(cursor, text, contentColumn), blankLines) } while (cursor.nextSibling()) cursor.parent() } From bc6191bb7e9214744fb0f723d9faf6eb814df433 Mon Sep 17 00:00:00 2001 From: ocavue Date: Wed, 21 Oct 2026 22:53:43 +1100 Subject: [PATCH 3/3] fix: keep a moved list item from joining the paragraph above --- .../src/extensions/wililink-fuzz.test.tsx | 6 -- packages/markdown/src/ast/gap.ts | 60 ++++++++++++++++++- packages/markdown/src/ast/serialize.ts | 33 +--------- 3 files changed, 60 insertions(+), 39 deletions(-) diff --git a/packages/core/src/extensions/wililink-fuzz.test.tsx b/packages/core/src/extensions/wililink-fuzz.test.tsx index b178e0eb..f734eb38 100644 --- a/packages/core/src/extensions/wililink-fuzz.test.tsx +++ b/packages/core/src/extensions/wililink-fuzz.test.tsx @@ -129,7 +129,6 @@ describe('caret fuzz over a wikilink outline in focus mode', () => { - [[bar]] ----- markdown after ------------- top - - [[foo]] - [[bar]] ----- selection before/after ----- @@ -204,7 +203,6 @@ describe('caret fuzz over a wikilink outline in focus mode', () => { - top [[foo]] - - [[bar]] ----- selection before/after ----- top @@ -359,7 +357,6 @@ describe('caret fuzz over a wikilink outline in focus mode', () => { - [[bar]] ----- markdown after ------------- - top - - [[foo]] [[bar]] @@ -1330,7 +1327,6 @@ describe('caret fuzz over a wikilink outline in hide mode', () => { - [[bar]] ----- markdown after ------------- top - - [[foo]] - [[bar]] ----- selection before/after ----- @@ -1405,7 +1401,6 @@ describe('caret fuzz over a wikilink outline in hide mode', () => { - top [[foo]] - - [[bar]] ----- selection before/after ----- top @@ -1560,7 +1555,6 @@ describe('caret fuzz over a wikilink outline in hide mode', () => { - [[bar]] ----- markdown after ------------- - top - - [[foo]] [[bar]] diff --git a/packages/markdown/src/ast/gap.ts b/packages/markdown/src/ast/gap.ts index 56ed6741..e8fde757 100644 --- a/packages/markdown/src/ast/gap.ts +++ b/packages/markdown/src/ast/gap.ts @@ -1,3 +1,5 @@ +import { CHAR_LESS_THAN } from '../unicode.ts' + import type { MarkdownBlock, MarkdownInline, MarkdownListItem } from './types.ts' /** @@ -7,6 +9,8 @@ import type { MarkdownBlock, MarkdownInline, MarkdownListItem } from './types.ts */ export function canJoinTight(previous: MarkdownBlock, next: MarkdownBlock): boolean { if (isEmptyParagraph(previous) || isEmptyParagraph(next)) return false + // A paragraph that opens an HTML block runs on to the next blank line. + if (previous.type === 'paragraph' && opensHTMLBlock(previous.value)) return false switch (next.type) { case 'listItem': // Lezer reads a bare marker of another list under an item's paragraph as @@ -14,7 +18,7 @@ export function canJoinTight(previous: MarkdownBlock, next: MarkdownBlock): bool return ( (previous.type === 'listItem' && listMarkerChar(previous) === listMarkerChar(next)) || endsClosed(getLastBlock(previous)) || - canInterruptParagraph(next) + (canInterruptParagraph(next) && startsWithText(next)) ) default: return false @@ -86,6 +90,60 @@ function getLastBlock(block: MarkdownBlock): MarkdownBlock { return block } +/** + * Whether `block`'s first line carries text that reads as neither blank nor a + * table delimiter row, either of which the paragraph above would take in. + */ +function startsWithText(block: MarkdownBlock): boolean { + switch (block.type) { + case 'listItem': { + const first = block.children[0] + return block.kind === 'task' || (first != null && startsWithText(first)) + } + case 'blockquote': + return true + case 'paragraph': + case 'heading': { + const lineEnd = block.value.indexOf('\n') + const line = lineEnd < 0 ? block.value : block.value.slice(0, lineEnd) + return !/^[\s|:-]*$/.test(line) + } + default: + return false + } +} + +/** + * The line patterns that open an HTML block, the openers of `HTMLBlockStyle` + * in `@lezer/markdown`: script/pre/style, a comment, a processing instruction, + * a declaration, CDATA, a known block-level tag, and a complete tag alone on + * its line. Leading whitespace is stripped before matching. + */ +const HTML_BLOCK_OPEN = [ + /^<(?:script|pre|style)(?:\s|>|$)/i, + /^