From 773a17708b8942a8cf7f81ee72fe8180a0596129 Mon Sep 17 00:00:00 2001 From: ocavue Date: Wed, 21 Oct 2026 22:52:33 +1100 Subject: [PATCH] feat: keep block gaps as written --- .changeset/block-blank-before.md | 6 + packages/core/src/converters/ast-to-pm.ts | 16 +- packages/core/src/converters/md-to-pm.test.ts | 308 ++++++++++++++---- packages/core/src/converters/pm-to-ast.ts | 8 +- .../core/src/converters/roundtrip.test.ts | 17 +- .../core/src/converters/sample-content.ts | 36 +- packages/core/src/extensions/blank-before.ts | 33 ++ .../extensions/clipboard/plain-paste.test.tsx | 1 - .../core/src/extensions/extension.test.ts | 34 ++ packages/core/src/extensions/extension.ts | 2 + .../src/ast/check-roundtrip-fuzz.test.ts | 38 ++- .../markdown/src/ast/check-roundtrip.test.ts | 21 ++ packages/markdown/src/ast/equal.ts | 10 +- packages/markdown/src/ast/gap.ts | 28 ++ packages/markdown/src/ast/parse.ts | 12 +- packages/markdown/src/ast/serialize.ts | 8 +- packages/markdown/src/ast/types.ts | 35 +- 17 files changed, 497 insertions(+), 116 deletions(-) create mode 100644 .changeset/block-blank-before.md create mode 100644 packages/core/src/extensions/blank-before.ts diff --git a/.changeset/block-blank-before.md b/.changeset/block-blank-before.md new file mode 100644 index 000000000..5e5d0092a --- /dev/null +++ b/.changeset/block-blank-before.md @@ -0,0 +1,6 @@ +--- +'@meowdown/markdown': minor +'@meowdown/core': minor +--- + +Keep blocks that the source writes without a blank line between them on adjacent lines. diff --git a/packages/core/src/converters/ast-to-pm.ts b/packages/core/src/converters/ast-to-pm.ts index 6b020513b..ff5a9445e 100644 --- a/packages/core/src/converters/ast-to-pm.ts +++ b/packages/core/src/converters/ast-to-pm.ts @@ -11,6 +11,10 @@ export function astToDoc( nodes: TypedNodeBuilders = getNodeBuilders(), ): ProseMirrorNode { const children = node.children?.map((child) => astToDoc(child, nodes)) || [] + const blankBefore = + node.type === 'document' || node.type === 'tableRow' || node.type === 'tableCell' + ? null + : (node.blankBefore ?? null) switch (node.type) { case 'document': return nodes.doc( @@ -18,18 +22,19 @@ export function astToDoc( children, ) case 'paragraph': - return nodes.paragraph(node.value) + return nodes.paragraph({ blankBefore }, node.value) case 'heading': return nodes.heading( { level: node.level, setextUnderline: node.setextUnderline ?? null, closingHashes: node.closingHashes ?? null, + blankBefore, }, node.value, ) case 'blockquote': - return nodes.blockquote(children) + return nodes.blockquote({ blankBefore }, children) case 'listItem': return nodes.list( { @@ -40,7 +45,7 @@ export function astToDoc( marker: node.marker ?? null, taskMarker: node.taskMarker, markerGap: node.markerGap, - blankBefore: node.blankBefore ?? null, + blankBefore, }, children, ) @@ -50,13 +55,14 @@ export function astToDoc( language: node.language, fenceStyle: node.fenceStyle ?? null, fenceLength: node.fenceLength ?? null, + blankBefore, }, node.value, ) case 'horizontalRule': - return nodes.horizontalRule({ marker: node.marker ?? null }) + return nodes.horizontalRule({ marker: node.marker ?? null, blankBefore }) case 'htmlComment': - return nodes.htmlComment({ content: node.value }) + return nodes.htmlComment({ content: node.value, blankBefore }) case 'table': return nodes.table(children) case 'tableRow': diff --git a/packages/core/src/converters/md-to-pm.test.ts b/packages/core/src/converters/md-to-pm.test.ts index 3c55ca56c..e2bd4ad98 100644 --- a/packages/core/src/converters/md-to-pm.test.ts +++ b/packages/core/src/converters/md-to-pm.test.ts @@ -42,10 +42,10 @@ describe('markdownToDoc', () => { type: 'doc', attrs: { frontmatter: null, frontmatterTight: false }, content: [ - { type: 'paragraph', content: [{ type: 'text', text: 'a' }] }, - { type: 'paragraph' }, - { type: 'paragraph' }, - { type: 'paragraph', content: [{ type: 'text', text: 'b' }] }, + { type: 'paragraph', attrs: { blankBefore: null }, content: [{ type: 'text', text: 'a' }] }, + { type: 'paragraph', attrs: { blankBefore: null } }, + { type: 'paragraph', attrs: { blankBefore: null } }, + { type: 'paragraph', attrs: { blankBefore: null }, content: [{ type: 'text', text: 'b' }] }, ], }) }) @@ -55,11 +55,11 @@ describe('markdownToDoc', () => { type: 'doc', attrs: { frontmatter: null, frontmatterTight: false }, content: [ - { type: 'paragraph' }, - { type: 'paragraph' }, - { type: 'paragraph', content: [{ type: 'text', text: 'a' }] }, - { type: 'paragraph' }, - { type: 'paragraph' }, + { type: 'paragraph', attrs: { blankBefore: null } }, + { type: 'paragraph', attrs: { blankBefore: null } }, + { type: 'paragraph', attrs: { blankBefore: null }, content: [{ type: 'text', text: 'a' }] }, + { type: 'paragraph', attrs: { blankBefore: null } }, + { type: 'paragraph', attrs: { blankBefore: null } }, ], }) }) @@ -68,19 +68,26 @@ describe('markdownToDoc', () => { const empty = { type: 'doc', attrs: { frontmatter: null, frontmatterTight: false }, - content: [{ type: 'paragraph' }], + content: [{ type: 'paragraph', attrs: { blankBefore: null } }], } expect(markdownToDoc('').toJSON()).toEqual(empty) expect(markdownToDoc('\n').toJSON()).toEqual(empty) expect(markdownToDoc('\n\n').toJSON()).toEqual({ type: 'doc', attrs: { frontmatter: null, frontmatterTight: false }, - content: [{ type: 'paragraph' }, { type: 'paragraph' }], + content: [ + { type: 'paragraph', attrs: { blankBefore: null } }, + { type: 'paragraph', attrs: { blankBefore: null } }, + ], }) }) it('reads the blank line after frontmatter as a separator', () => { - const paragraph = { type: 'paragraph', content: [{ type: 'text', text: 'a' }] } + const paragraph = { + type: 'paragraph', + attrs: { blankBefore: null }, + content: [{ type: 'text', text: 'a' }], + } expect(markdownToDoc('---\nt: x\n---\n\na', { frontmatter: true }).toJSON()).toEqual({ type: 'doc', attrs: { frontmatter: 't: x', frontmatterTight: false }, @@ -89,7 +96,11 @@ describe('markdownToDoc', () => { expect(markdownToDoc('---\nt: x\n---\n\n\n\na', { frontmatter: true }).toJSON()).toEqual({ type: 'doc', attrs: { frontmatter: 't: x', frontmatterTight: false }, - content: [{ type: 'paragraph' }, { type: 'paragraph' }, paragraph], + content: [ + { type: 'paragraph', attrs: { blankBefore: null } }, + { type: 'paragraph', attrs: { blankBefore: null } }, + paragraph, + ], }) }) @@ -97,7 +108,9 @@ describe('markdownToDoc', () => { expect(markdownToDoc('---\nt: x\n---\na', { frontmatter: true }).toJSON()).toEqual({ type: 'doc', attrs: { frontmatter: 't: x', frontmatterTight: true }, - content: [{ type: 'paragraph', content: [{ type: 'text', text: 'a' }] }], + content: [ + { type: 'paragraph', attrs: { blankBefore: null }, content: [{ type: 'text', text: 'a' }] }, + ], }) }) @@ -107,10 +120,10 @@ describe('markdownToDoc', () => { expect(doc.child(0).toJSON()).toMatchObject({ type: 'list', content: [ - { type: 'paragraph', content: [{ type: 'text', text: 'a' }] }, - { type: 'paragraph' }, - { type: 'paragraph' }, - { type: 'paragraph', content: [{ type: 'text', text: 'b' }] }, + { type: 'paragraph', attrs: { blankBefore: null }, content: [{ type: 'text', text: 'a' }] }, + { type: 'paragraph', attrs: { blankBefore: null } }, + { type: 'paragraph', attrs: { blankBefore: null } }, + { type: 'paragraph', attrs: { blankBefore: null }, content: [{ type: 'text', text: 'b' }] }, ], }) }) @@ -119,7 +132,7 @@ describe('markdownToDoc', () => { const doc = markdownToDoc('- a\n\n\n- b') expect(doc.childCount).toBe(3) expect(doc.child(0).type.name).toBe('list') - expect(doc.child(1).toJSON()).toEqual({ type: 'paragraph' }) + expect(doc.child(1).toJSON()).toEqual({ type: 'paragraph', attrs: { blankBefore: null } }) expect(doc.child(2).type.name).toBe('list') }) @@ -137,11 +150,16 @@ describe('markdownToDoc', () => { content: [ { type: 'blockquote', + attrs: { blankBefore: null }, content: [ - { type: 'paragraph' }, - { type: 'paragraph' }, - { type: 'paragraph', content: [{ type: 'text', text: 'a' }] }, - { type: 'paragraph' }, + { type: 'paragraph', attrs: { blankBefore: null } }, + { type: 'paragraph', attrs: { blankBefore: null } }, + { + type: 'paragraph', + attrs: { blankBefore: null }, + content: [{ type: 'text', text: 'a' }], + }, + { type: 'paragraph', attrs: { blankBefore: null } }, ], }, ], @@ -155,7 +173,7 @@ describe('markdownToDoc', () => { content: [ { type: 'heading', - attrs: { level: 1, setextUnderline: null, closingHashes: null }, + attrs: { level: 1, setextUnderline: null, closingHashes: null, blankBefore: null }, content: [{ type: 'text', text: 'Hello' }], }, ], @@ -169,6 +187,7 @@ describe('markdownToDoc', () => { content: [ { type: 'paragraph', + attrs: { blankBefore: null }, content: [{ type: 'text', text: 'hello world' }], }, ], @@ -182,9 +201,11 @@ describe('markdownToDoc', () => { content: [ { type: 'blockquote', + attrs: { blankBefore: null }, content: [ { type: 'paragraph', + attrs: { blankBefore: null }, content: [{ type: 'text', text: 'quoted text' }], }, ], @@ -214,7 +235,13 @@ describe('markdownToDoc', () => { markerGap: 1, blankBefore: null, }, - content: [{ type: 'paragraph', content: [{ type: 'text', text: 'one' }] }], + content: [ + { + type: 'paragraph', + attrs: { blankBefore: null }, + content: [{ type: 'text', text: 'one' }], + }, + ], }, { type: 'list', @@ -228,7 +255,13 @@ describe('markdownToDoc', () => { markerGap: 1, blankBefore: false, }, - content: [{ type: 'paragraph', content: [{ type: 'text', text: 'two' }] }], + content: [ + { + type: 'paragraph', + attrs: { blankBefore: null }, + content: [{ type: 'text', text: 'two' }], + }, + ], }, ], }) @@ -280,7 +313,13 @@ describe('markdownToDoc', () => { markerGap: 1, blankBefore: null, }, - content: [{ type: 'paragraph', content: [{ type: 'text', text: 'five' }] }], + content: [ + { + type: 'paragraph', + attrs: { blankBefore: null }, + content: [{ type: 'text', text: 'five' }], + }, + ], }, { type: 'list', @@ -294,7 +333,13 @@ describe('markdownToDoc', () => { markerGap: 1, blankBefore: false, }, - content: [{ type: 'paragraph', content: [{ type: 'text', text: 'six' }] }], + content: [ + { + type: 'paragraph', + attrs: { blankBefore: null }, + content: [{ type: 'text', text: 'six' }], + }, + ], }, ], }) @@ -317,7 +362,13 @@ describe('markdownToDoc', () => { markerGap: 1, blankBefore: null, }, - content: [{ type: 'paragraph', content: [{ type: 'text', text: 'todo' }] }], + content: [ + { + type: 'paragraph', + attrs: { blankBefore: null }, + content: [{ type: 'text', text: 'todo' }], + }, + ], }, ], }) @@ -340,7 +391,13 @@ describe('markdownToDoc', () => { markerGap: 1, blankBefore: null, }, - content: [{ type: 'paragraph', content: [{ type: 'text', text: 'done' }] }], + content: [ + { + type: 'paragraph', + attrs: { blankBefore: null }, + content: [{ type: 'text', text: 'done' }], + }, + ], }, ], }) @@ -364,7 +421,13 @@ describe('markdownToDoc', () => { markerGap: 1, blankBefore: null, }, - content: [{ type: 'paragraph', content: [{ type: 'text', text: 'Square Task' }] }], + content: [ + { + type: 'paragraph', + attrs: { blankBefore: null }, + content: [{ type: 'text', text: 'Square Task' }], + }, + ], }, { type: 'list', @@ -378,7 +441,13 @@ describe('markdownToDoc', () => { markerGap: 1, blankBefore: true, }, - content: [{ type: 'paragraph', content: [{ type: 'text', text: 'Circle Task' }] }], + content: [ + { + type: 'paragraph', + attrs: { blankBefore: null }, + content: [{ type: 'text', text: 'Circle Task' }], + }, + ], }, ], }) @@ -415,7 +484,13 @@ describe('markdownToDoc', () => { markerGap: 1, blankBefore: null, }, - content: [{ type: 'paragraph', content: [{ type: 'text', text: '[x] done' }] }], + content: [ + { + type: 'paragraph', + attrs: { blankBefore: null }, + content: [{ type: 'text', text: '[x] done' }], + }, + ], }, ], }) @@ -435,7 +510,7 @@ describe('markdownToDoc', () => { content: [ { type: 'codeBlock', - attrs: { language: 'js', fenceStyle: null, fenceLength: null }, + attrs: { language: 'js', fenceStyle: null, fenceLength: null, blankBefore: null }, content: [{ type: 'text', text: 'console.log(1)' }], }, ], @@ -470,10 +545,14 @@ describe('markdownToDoc', () => { blankBefore: null, }, content: [ - { type: 'paragraph', content: [{ type: 'text', text: 'x' }] }, + { + type: 'paragraph', + attrs: { blankBefore: null }, + content: [{ type: 'text', text: 'x' }], + }, { type: 'codeBlock', - attrs: { language: '', fenceStyle: null, fenceLength: null }, + attrs: { language: '', fenceStyle: null, fenceLength: null, blankBefore: null }, content: [{ type: 'text', text: 'line1\nline2\nline3' }], }, ], @@ -486,7 +565,7 @@ describe('markdownToDoc', () => { expect(markdownToDoc('---').toJSON()).toEqual({ type: 'doc', attrs: { frontmatter: null, frontmatterTight: false }, - content: [{ type: 'horizontalRule', attrs: { marker: null } }], + content: [{ type: 'horizontalRule', attrs: { marker: null, blankBefore: null } }], }) }) @@ -527,6 +606,7 @@ describe('markdownToDoc', () => { content: [ { type: 'paragraph', + attrs: { blankBefore: null }, content: [{ type: 'text', text: 'a' }], }, ], @@ -537,6 +617,7 @@ describe('markdownToDoc', () => { content: [ { type: 'paragraph', + attrs: { blankBefore: null }, content: [{ type: 'text', text: 'b' }], }, ], @@ -552,6 +633,7 @@ describe('markdownToDoc', () => { content: [ { type: 'paragraph', + attrs: { blankBefore: null }, content: [{ type: 'text', text: '1' }], }, ], @@ -562,6 +644,7 @@ describe('markdownToDoc', () => { content: [ { type: 'paragraph', + attrs: { blankBefore: null }, content: [{ type: 'text', text: '2' }], }, ], @@ -697,7 +780,13 @@ describe('markdownToDoc', () => { markerGap: 1, blankBefore: null, }, - content: [{ type: 'paragraph', content: [{ type: 'text', text: 'star' }] }], + content: [ + { + type: 'paragraph', + attrs: { blankBefore: null }, + content: [{ type: 'text', text: 'star' }], + }, + ], }, ], }) @@ -720,7 +809,13 @@ describe('markdownToDoc', () => { markerGap: 1, blankBefore: null, }, - content: [{ type: 'paragraph', content: [{ type: 'text', text: 'paren' }] }], + content: [ + { + type: 'paragraph', + attrs: { blankBefore: null }, + content: [{ type: 'text', text: 'paren' }], + }, + ], }, ], }) @@ -743,7 +838,13 @@ describe('markdownToDoc', () => { markerGap: 1, blankBefore: null, }, - content: [{ type: 'paragraph', content: [{ type: 'text', text: 'zero' }] }], + content: [ + { + type: 'paragraph', + attrs: { blankBefore: null }, + content: [{ type: 'text', text: 'zero' }], + }, + ], }, ], }) @@ -766,7 +867,13 @@ describe('markdownToDoc', () => { markerGap: 1, blankBefore: null, }, - content: [{ type: 'paragraph', content: [{ type: 'text', text: 'upper' }] }], + content: [ + { + type: 'paragraph', + attrs: { blankBefore: null }, + content: [{ type: 'text', text: 'upper' }], + }, + ], }, ], }) @@ -779,7 +886,7 @@ describe('markdownToDoc', () => { content: [ { type: 'codeBlock', - attrs: { language: '', fenceStyle: 'indented', fenceLength: null }, + attrs: { language: '', fenceStyle: 'indented', fenceLength: null, blankBefore: null }, content: [{ type: 'text', text: 'indented' }], }, ], @@ -793,7 +900,7 @@ describe('markdownToDoc', () => { content: [ { type: 'codeBlock', - attrs: { language: 'math', fenceStyle: 'dollar', fenceLength: null }, + attrs: { language: 'math', fenceStyle: 'dollar', fenceLength: null, blankBefore: null }, content: [{ type: 'text', text: 'E=mc^2' }], }, ], @@ -807,7 +914,7 @@ describe('markdownToDoc', () => { content: [ { type: 'codeBlock', - attrs: { language: 'math', fenceStyle: null, fenceLength: null }, + attrs: { language: 'math', fenceStyle: null, fenceLength: null, blankBefore: null }, content: [{ type: 'text', text: 'E=mc^2' }], }, ], @@ -821,7 +928,7 @@ describe('markdownToDoc', () => { content: [ { type: 'codeBlock', - attrs: { language: 'math', fenceStyle: 'dollar', fenceLength: null }, + attrs: { language: 'math', fenceStyle: 'dollar', fenceLength: null, blankBefore: null }, content: [{ type: 'text', text: 'E=mc^2' }], }, ], @@ -835,7 +942,7 @@ describe('markdownToDoc', () => { content: [ { type: 'codeBlock', - attrs: { language: '', fenceStyle: 'tilde', fenceLength: null }, + attrs: { language: '', fenceStyle: 'tilde', fenceLength: null, blankBefore: null }, content: [{ type: 'text', text: 'tilde' }], }, ], @@ -849,7 +956,7 @@ describe('markdownToDoc', () => { content: [ { type: 'codeBlock', - attrs: { language: '', fenceStyle: null, fenceLength: 4 }, + attrs: { language: '', fenceStyle: null, fenceLength: 4, blankBefore: null }, content: [{ type: 'text', text: 'code' }], }, ], @@ -860,7 +967,13 @@ describe('markdownToDoc', () => { expect(markdownToDoc('#nospace').toJSON()).toEqual({ type: 'doc', attrs: { frontmatter: null, frontmatterTight: false }, - content: [{ type: 'paragraph', content: [{ type: 'text', text: '#nospace' }] }], + content: [ + { + type: 'paragraph', + attrs: { blankBefore: null }, + content: [{ type: 'text', text: '#nospace' }], + }, + ], }) }) @@ -868,7 +981,13 @@ describe('markdownToDoc', () => { expect(markdownToDoc('####### seven').toJSON()).toEqual({ type: 'doc', attrs: { frontmatter: null, frontmatterTight: false }, - content: [{ type: 'paragraph', content: [{ type: 'text', text: '####### seven' }] }], + content: [ + { + type: 'paragraph', + attrs: { blankBefore: null }, + content: [{ type: 'text', text: '####### seven' }], + }, + ], }) }) @@ -877,7 +996,10 @@ describe('markdownToDoc', () => { type: 'doc', attrs: { frontmatter: null, frontmatterTight: false }, content: [ - { type: 'heading', attrs: { level: 1, setextUnderline: null, closingHashes: null } }, + { + type: 'heading', + attrs: { level: 1, setextUnderline: null, closingHashes: null, blankBefore: null }, + }, ], }) }) @@ -889,11 +1011,23 @@ describe('markdownToDoc', () => { content: [ { type: 'blockquote', + attrs: { blankBefore: null }, content: [ - { type: 'paragraph', content: [{ type: 'text', text: 'a' }] }, + { + type: 'paragraph', + attrs: { blankBefore: null }, + content: [{ type: 'text', text: 'a' }], + }, { type: 'blockquote', - content: [{ type: 'paragraph', content: [{ type: 'text', text: 'b' }] }], + attrs: { blankBefore: null }, + content: [ + { + type: 'paragraph', + attrs: { blankBefore: null }, + content: [{ type: 'text', text: 'b' }], + }, + ], }, ], }, @@ -908,7 +1042,7 @@ describe('markdownToDoc', () => { content: [ { type: 'heading', - attrs: { level: 1, setextUnderline: 3, closingHashes: null }, + attrs: { level: 1, setextUnderline: 3, closingHashes: null, blankBefore: null }, content: [{ type: 'text', text: 'Setext1' }], }, ], @@ -922,7 +1056,7 @@ describe('markdownToDoc', () => { content: [ { type: 'heading', - attrs: { level: 2, setextUnderline: 3, closingHashes: null }, + attrs: { level: 2, setextUnderline: 3, closingHashes: null, blankBefore: null }, content: [{ type: 'text', text: 'Setext2' }], }, ], @@ -933,7 +1067,13 @@ describe('markdownToDoc', () => { expect(markdownToDoc('
html
').toJSON()).toEqual({ type: 'doc', attrs: { frontmatter: null, frontmatterTight: false }, - content: [{ type: 'paragraph', content: [{ type: 'text', text: '
html
' }] }], + content: [ + { + type: 'paragraph', + attrs: { blankBefore: null }, + content: [{ type: 'text', text: '
html
' }], + }, + ], }) }) @@ -941,7 +1081,13 @@ describe('markdownToDoc', () => { expect(markdownToDoc('').toJSON()).toEqual({ type: 'doc', attrs: { frontmatter: null, frontmatterTight: false }, - content: [{ type: 'paragraph', content: [{ type: 'text', text: '' }] }], + content: [ + { + type: 'paragraph', + attrs: { blankBefore: null }, + content: [{ type: 'text', text: '' }], + }, + ], }) }) @@ -949,7 +1095,9 @@ describe('markdownToDoc', () => { expect(markdownToDoc('').toJSON()).toEqual({ type: 'doc', attrs: { frontmatter: null, frontmatterTight: false }, - content: [{ type: 'htmlComment', attrs: { content: '' } }], + content: [ + { type: 'htmlComment', attrs: { content: '', blankBefore: null } }, + ], }) }) @@ -957,7 +1105,12 @@ describe('markdownToDoc', () => { expect(markdownToDoc('').toJSON()).toEqual({ type: 'doc', attrs: { frontmatter: null, frontmatterTight: false }, - content: [{ type: 'htmlComment', attrs: { content: '' } }], + content: [ + { + type: 'htmlComment', + attrs: { content: '', blankBefore: null }, + }, + ], }) }) @@ -968,9 +1121,13 @@ describe('markdownToDoc', () => { type: 'doc', attrs: { frontmatter: null, frontmatterTight: false }, content: [ - { type: 'htmlComment', attrs: { content: '' } }, - { type: 'paragraph', content: [{ type: 'text', text: 'body text' }] }, - { type: 'htmlComment', attrs: { content: '' } }, + { type: 'htmlComment', attrs: { content: '', blankBefore: null } }, + { + type: 'paragraph', + attrs: { blankBefore: false }, + content: [{ type: 'text', text: 'body text' }], + }, + { type: 'htmlComment', attrs: { content: '', blankBefore: false } }, ], }) }) @@ -982,7 +1139,14 @@ describe('markdownToDoc', () => { content: [ { type: 'blockquote', - content: [{ type: 'paragraph', content: [{ type: 'text', text: 'l1\nl2' }] }], + attrs: { blankBefore: null }, + content: [ + { + type: 'paragraph', + attrs: { blankBefore: null }, + content: [{ type: 'text', text: 'l1\nl2' }], + }, + ], }, ], }) @@ -1006,8 +1170,16 @@ describe('markdownToDoc', () => { blankBefore: null, }, content: [ - { type: 'paragraph', content: [{ type: 'text', text: 'x' }] }, - { type: 'paragraph', content: [{ type: 'text', text: 'line one\nline two' }] }, + { + type: 'paragraph', + attrs: { blankBefore: null }, + content: [{ type: 'text', text: 'x' }], + }, + { + type: 'paragraph', + attrs: { blankBefore: null }, + content: [{ type: 'text', text: 'line one\nline two' }], + }, ], }, ], @@ -1020,7 +1192,7 @@ describe('markdownToDoc', () => { expect(markdownToDoc('---\ntitle: x\n---', { frontmatter: true }).toJSON()).toEqual({ type: 'doc', attrs: { frontmatter: 'title: x', frontmatterTight: false }, - content: [{ type: 'paragraph' }], + content: [{ type: 'paragraph', attrs: { blankBefore: null } }], }) }) @@ -1032,7 +1204,7 @@ describe('markdownToDoc', () => { content: [ { type: 'heading', - attrs: { level: 1, setextUnderline: null, closingHashes: null }, + attrs: { level: 1, setextUnderline: null, closingHashes: null, blankBefore: null }, content: [{ type: 'text', text: 'heading' }], }, ], @@ -1043,7 +1215,7 @@ describe('markdownToDoc', () => { expect(markdownToDoc('---\n---', { frontmatter: true }).toJSON()).toEqual({ type: 'doc', attrs: { frontmatter: '', frontmatterTight: false }, - content: [{ type: 'paragraph' }], + content: [{ type: 'paragraph', attrs: { blankBefore: null } }], }) }) @@ -1051,7 +1223,7 @@ describe('markdownToDoc', () => { expect(markdownToDoc('---', { frontmatter: true }).toJSON()).toEqual({ type: 'doc', attrs: { frontmatter: null, frontmatterTight: false }, - content: [{ type: 'horizontalRule', attrs: { marker: null } }], + content: [{ type: 'horizontalRule', attrs: { marker: null, blankBefore: null } }], }) }) diff --git a/packages/core/src/converters/pm-to-ast.ts b/packages/core/src/converters/pm-to-ast.ts index f45605175..687aaadcd 100644 --- a/packages/core/src/converters/pm-to-ast.ts +++ b/packages/core/src/converters/pm-to-ast.ts @@ -42,6 +42,13 @@ function readBlocks(node: ProseMirrorNode): MarkdownBlock[] { } function readBlock(node: ProseMirrorNode): MarkdownBlock { + const block = readBlockFields(node) + const blankBefore = node.attrs.blankBefore as boolean | null | undefined + if (blankBefore != null) block.blankBefore = blankBefore + return block +} + +function readBlockFields(node: ProseMirrorNode): MarkdownBlock { switch (node.type.name as NodeName) { case 'paragraph': { const inline = readInline(node) @@ -72,7 +79,6 @@ 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/roundtrip.test.ts b/packages/core/src/converters/roundtrip.test.ts index e6a7f1790..b4caf9675 100644 --- a/packages/core/src/converters/roundtrip.test.ts +++ b/packages/core/src/converters/roundtrip.test.ts @@ -213,10 +213,9 @@ describe('inline', () => { }) it('keeps sentinel comments around body text', () => { - // The serializer separates a run of blocks with blank lines (as it does for - // any adjacent blocks, comment or not); both sentinels and the body survive. + // The sentinels stay on the lines right around the body, as written. expect(roundtrip('\nbody text\n')).toBe( - '\n\nbody text\n\n\n', + '\nbody text\n\n', ) }) @@ -355,8 +354,8 @@ describe('blockquotes', () => { expect(roundtrip('> l1\n> l2')).toBe('> l1\n> l2\n') }) - it.fails('keeps a lazily-nested quote', () => { - expect(roundtrip('> a\n>> b')).toBe('> a\n>> b\n') + it('respaces a lazily-nested quote', () => { + expect(roundtrip('> a\n>> b')).toBe('> a\n>\n> > b\n') }) it('keeps a nested list in a quote', () => { @@ -842,7 +841,7 @@ describe('soft breaks', () => { // backslash would become part of the text. it('reads a continuation line that opens a block back as that block', () => { const doc1 = n.doc(n.paragraph('foo\n# bar')) - const doc2 = n.doc(n.paragraph('foo'), n.heading({ level: 1 }, 'bar')) + const doc2 = n.doc(n.paragraph('foo'), n.heading({ level: 1, blankBefore: false }, 'bar')) const markdown = docToMarkdown(doc1) expect(markdown).toBe('foo\n# bar\n') expect(markdownToDoc(markdown, { nodes: n }).eq(doc2)).toBe(true) @@ -985,7 +984,7 @@ describe('escapes and whitespace', () => { const reparsed = markdownToDoc(markdown) expect(reparsed.childCount).toBe(2) expect(reparsed.child(0).type.name).toBe('list') - expect(reparsed.child(1).toJSON()).toEqual({ type: 'paragraph' }) + expect(reparsed.child(1).toJSON()).toEqual({ type: 'paragraph', attrs: { blankBefore: null } }) expect(docToMarkdown(reparsed)).toBe(markdown) }) @@ -1067,7 +1066,7 @@ describe('mixed structures', () => { expect(roundtrip(md)).toBe(md + '\n') }) - it.fails('keeps stacked headings tight', () => { + it('keeps stacked headings tight', () => { expect(roundtrip('# h1\n## h2\n### h3')).toBe('# h1\n## h2\n### h3\n') }) @@ -1075,7 +1074,7 @@ describe('mixed structures', () => { expect(roundtrip('para\n- a')).toBe('para\n- a\n') }) - it.fails('keeps a paragraph right under a heading', () => { + it('keeps a paragraph right under a heading', () => { expect(roundtrip('## Text\ncontent')).toBe('## Text\ncontent\n') }) diff --git a/packages/core/src/converters/sample-content.ts b/packages/core/src/converters/sample-content.ts index 86d1ab52b..d7d3b69c5 100644 --- a/packages/core/src/converters/sample-content.ts +++ b/packages/core/src/converters/sample-content.ts @@ -6,11 +6,12 @@ export const sampleContent: NodeJSON = { content: [ { type: 'heading', - attrs: { level: 1, setextUnderline: null, closingHashes: null }, + attrs: { level: 1, setextUnderline: null, closingHashes: null, blankBefore: null }, content: [{ type: 'text', text: 'Meowdown' }], }, { type: 'paragraph', + attrs: { blankBefore: null }, content: [ { type: 'text', @@ -20,6 +21,7 @@ export const sampleContent: NodeJSON = { }, { type: 'paragraph', + attrs: { blankBefore: null }, content: [ { type: 'text', @@ -29,24 +31,26 @@ export const sampleContent: NodeJSON = { }, { type: 'heading', - attrs: { level: 2, setextUnderline: null, closingHashes: null }, + attrs: { level: 2, setextUnderline: null, closingHashes: null, blankBefore: null }, content: [{ type: 'text', text: 'Headings' }], }, { type: 'heading', - attrs: { level: 3, setextUnderline: null, closingHashes: null }, + attrs: { level: 3, setextUnderline: null, closingHashes: null, blankBefore: null }, content: [{ type: 'text', text: 'This is an H3' }], }, { type: 'heading', - attrs: { level: 2, setextUnderline: null, closingHashes: null }, + attrs: { level: 2, setextUnderline: null, closingHashes: null, blankBefore: null }, content: [{ type: 'text', text: 'Blockquote' }], }, { type: 'blockquote', + attrs: { blankBefore: null }, content: [ { type: 'paragraph', + attrs: { blankBefore: null }, content: [ { type: 'text', @@ -58,7 +62,7 @@ export const sampleContent: NodeJSON = { }, { type: 'heading', - attrs: { level: 2, setextUnderline: null, closingHashes: null }, + attrs: { level: 2, setextUnderline: null, closingHashes: null, blankBefore: null }, content: [{ type: 'text', text: 'Bullet list' }], }, { @@ -76,6 +80,7 @@ export const sampleContent: NodeJSON = { content: [ { type: 'paragraph', + attrs: { blankBefore: null }, content: [{ type: 'text', text: 'First item' }], }, ], @@ -95,6 +100,7 @@ export const sampleContent: NodeJSON = { content: [ { type: 'paragraph', + attrs: { blankBefore: null }, content: [{ type: 'text', text: 'Second item' }], }, { @@ -112,6 +118,7 @@ export const sampleContent: NodeJSON = { content: [ { type: 'paragraph', + attrs: { blankBefore: null }, content: [{ type: 'text', text: 'A nested bullet' }], }, ], @@ -120,7 +127,7 @@ export const sampleContent: NodeJSON = { }, { type: 'heading', - attrs: { level: 2, setextUnderline: null, closingHashes: null }, + attrs: { level: 2, setextUnderline: null, closingHashes: null, blankBefore: null }, content: [{ type: 'text', text: 'Ordered list' }], }, { @@ -138,6 +145,7 @@ export const sampleContent: NodeJSON = { content: [ { type: 'paragraph', + attrs: { blankBefore: null }, content: [{ type: 'text', text: 'Step one' }], }, ], @@ -157,18 +165,19 @@ export const sampleContent: NodeJSON = { content: [ { type: 'paragraph', + attrs: { blankBefore: null }, content: [{ type: 'text', text: 'Step two' }], }, ], }, { type: 'heading', - attrs: { level: 2, setextUnderline: null, closingHashes: null }, + attrs: { level: 2, setextUnderline: null, closingHashes: null, blankBefore: null }, content: [{ type: 'text', text: 'Code block' }], }, { type: 'codeBlock', - attrs: { language: 'ts', fenceStyle: null, fenceLength: null }, + attrs: { language: 'ts', fenceStyle: null, fenceLength: null, blankBefore: null }, content: [ { type: 'text', @@ -178,7 +187,7 @@ export const sampleContent: NodeJSON = { }, { type: 'heading', - attrs: { level: 2, setextUnderline: null, closingHashes: null }, + attrs: { level: 2, setextUnderline: null, closingHashes: null, blankBefore: null }, content: [{ type: 'text', text: 'Table' }], }, { @@ -193,6 +202,7 @@ export const sampleContent: NodeJSON = { content: [ { type: 'paragraph', + attrs: { blankBefore: null }, content: [{ type: 'text', text: 'Syntax' }], }, ], @@ -203,6 +213,7 @@ export const sampleContent: NodeJSON = { content: [ { type: 'paragraph', + attrs: { blankBefore: null }, content: [{ type: 'text', text: 'Description' }], }, ], @@ -218,6 +229,7 @@ export const sampleContent: NodeJSON = { content: [ { type: 'paragraph', + attrs: { blankBefore: null }, content: [{ type: 'text', text: '# Heading' }], }, ], @@ -228,6 +240,7 @@ export const sampleContent: NodeJSON = { content: [ { type: 'paragraph', + attrs: { blankBefore: null }, content: [{ type: 'text', text: 'Creates a heading' }], }, ], @@ -243,6 +256,7 @@ export const sampleContent: NodeJSON = { content: [ { type: 'paragraph', + attrs: { blankBefore: null }, content: [{ type: 'text', text: '- item' }], }, ], @@ -253,6 +267,7 @@ export const sampleContent: NodeJSON = { content: [ { type: 'paragraph', + attrs: { blankBefore: null }, content: [{ type: 'text', text: 'Creates a bullet list' }], }, ], @@ -263,10 +278,11 @@ export const sampleContent: NodeJSON = { }, { type: 'horizontalRule', - attrs: { marker: null }, + attrs: { marker: null, blankBefore: null }, }, { type: 'paragraph', + attrs: { blankBefore: null }, content: [ { type: 'text', diff --git a/packages/core/src/extensions/blank-before.ts b/packages/core/src/extensions/blank-before.ts new file mode 100644 index 000000000..b171af3ff --- /dev/null +++ b/packages/core/src/extensions/blank-before.ts @@ -0,0 +1,33 @@ +import { defineNodeAttr, union, type Extension } from '@prosekit/core' + +import type { NodeName } from './node-names.ts' + +type BlankBeforeExtension = Extension<{ + Nodes: { [Key in Type]: { blankBefore?: boolean | null | undefined } } +}> + +function defineBlankBeforeAttr(type: Type): BlankBeforeExtension { + return defineNodeAttr({ + type, + attr: 'blankBefore', + default: null, + }) +} + +/** + * Stores whether the Markdown source wrote a block right under the sibling + * before it, with no blank line between them. `null` leaves the gap to the + * serializer. List items keep their own attr in `list.ts`. A table has none: + * ProseKit's attr wrapper reads the `tbody` in its `toDOM` output as an attrs + * object. + */ +export function defineBlankBeforeAttrs() { + return union( + defineBlankBeforeAttr('paragraph'), + defineBlankBeforeAttr('heading'), + defineBlankBeforeAttr('blockquote'), + defineBlankBeforeAttr('codeBlock'), + defineBlankBeforeAttr('horizontalRule'), + defineBlankBeforeAttr('htmlComment'), + ) +} diff --git a/packages/core/src/extensions/clipboard/plain-paste.test.tsx b/packages/core/src/extensions/clipboard/plain-paste.test.tsx index bc759194d..8fae06d1e 100644 --- a/packages/core/src/extensions/clipboard/plain-paste.test.tsx +++ b/packages/core/src/extensions/clipboard/plain-paste.test.tsx @@ -143,7 +143,6 @@ describe('plain text paste', () => { expect(await pastePlainText('# title\ntext')).toMatchInlineSnapshot(` """ # title - text """ diff --git a/packages/core/src/extensions/extension.test.ts b/packages/core/src/extensions/extension.test.ts index a2368a6d0..979c54181 100644 --- a/packages/core/src/extensions/extension.test.ts +++ b/packages/core/src/extensions/extension.test.ts @@ -34,6 +34,7 @@ describe('defineEditorExtension', () => { "content": [ { "attrs": { + "blankBefore": null, "closingHashes": null, "level": 1, "setextUnderline": null, @@ -47,6 +48,9 @@ describe('defineEditorExtension', () => { "type": "heading", }, { + "attrs": { + "blankBefore": null, + }, "content": [ { "text": "A paragraph.", @@ -56,8 +60,14 @@ describe('defineEditorExtension', () => { "type": "paragraph", }, { + "attrs": { + "blankBefore": null, + }, "content": [ { + "attrs": { + "blankBefore": null, + }, "content": [ { "text": "A quote.", @@ -82,6 +92,9 @@ describe('defineEditorExtension', () => { }, "content": [ { + "attrs": { + "blankBefore": null, + }, "content": [ { "text": "A bullet item.", @@ -106,6 +119,9 @@ describe('defineEditorExtension', () => { }, "content": [ { + "attrs": { + "blankBefore": null, + }, "content": [ { "text": "An ordered item.", @@ -130,6 +146,9 @@ describe('defineEditorExtension', () => { }, "content": [ { + "attrs": { + "blankBefore": null, + }, "content": [ { "text": "A task item.", @@ -143,6 +162,7 @@ describe('defineEditorExtension', () => { }, { "attrs": { + "blankBefore": null, "fenceLength": null, "fenceStyle": null, "language": "ts", @@ -168,6 +188,9 @@ describe('defineEditorExtension', () => { }, "content": [ { + "attrs": { + "blankBefore": null, + }, "content": [ { "text": "H1", @@ -188,6 +211,9 @@ describe('defineEditorExtension', () => { }, "content": [ { + "attrs": { + "blankBefore": null, + }, "content": [ { "text": "H2", @@ -213,6 +239,9 @@ describe('defineEditorExtension', () => { }, "content": [ { + "attrs": { + "blankBefore": null, + }, "content": [ { "text": "A1", @@ -233,6 +262,9 @@ describe('defineEditorExtension', () => { }, "content": [ { + "attrs": { + "blankBefore": null, + }, "content": [ { "text": "B1", @@ -252,12 +284,14 @@ describe('defineEditorExtension', () => { }, { "attrs": { + "blankBefore": null, "marker": null, }, "type": "horizontalRule", }, { "attrs": { + "blankBefore": null, "content": "", }, "type": "htmlComment", diff --git a/packages/core/src/extensions/extension.ts b/packages/core/src/extensions/extension.ts index 1f4f17b8a..e4437903d 100644 --- a/packages/core/src/extensions/extension.ts +++ b/packages/core/src/extensions/extension.ts @@ -14,6 +14,7 @@ import { defineVirtualSelection } from '@prosekit/extensions/virtual-selection' import { defineAtomMarkNavigation } from './atom-mark-navigation.ts' import { defineBackspaceEmptyFirstBlock } from './backspace-empty-first-block.ts' +import { defineBlankBeforeAttrs } from './blank-before.ts' import { defineMeowdownBlockquote } from './blockquote.ts' import { defineBulletAfterHeading } from './bullet-after-heading.ts' import { defineClickBelow } from './click-below.ts' @@ -84,6 +85,7 @@ function defineEditorExtensionImpl(options: EditorExtensionOptions) { defineCodeBlock(), defineMeowdownHorizontalRule(), defineHTMLComment(), + defineBlankBeforeAttrs(), // marks defineInlineMarks(), diff --git a/packages/markdown/src/ast/check-roundtrip-fuzz.test.ts b/packages/markdown/src/ast/check-roundtrip-fuzz.test.ts index f2e99260b..f40dbabed 100644 --- a/packages/markdown/src/ast/check-roundtrip-fuzz.test.ts +++ b/packages/markdown/src/ast/check-roundtrip-fuzz.test.ts @@ -2,8 +2,11 @@ import { createStringPicker } from '@meowdown/vitest/random' import { it } from 'vitest' import { checkRoundTrip } from './check-roundtrip.ts' +import { isMarkdownAstEqual } from './equal.ts' import { parseMarkdownAst } from './parse.ts' -import type { MarkdownNode } from './types.ts' +import { walkMarkdownAst } from './path.ts' +import { serializeMarkdownAst } from './serialize.ts' +import type { MarkdownDocument, MarkdownNode } from './types.ts' // Use a fixed seed from the environment variable for reproducibility, or fallback to a random seed const SEED = Number.parseInt(process.env.VITE_FUZZ_SEED || '') || Date.now() @@ -168,6 +171,33 @@ function findNestingError(node: MarkdownNode, from: number, to: number): string } } +/** + * Set every block's recorded gap to `value`. + */ +function setBlankBefore(document: MarkdownDocument, value: false | undefined): MarkdownDocument { + for (const { node } of walkMarkdownAst(document)) { + if (node.type === 'document' || node.type === 'tableRow' || node.type === 'tableCell') continue + node.blankBefore = value + } + return document +} + +/** + * The editor can move any block under any other and keep its recorded gap. + * Asking every block to sit tight must still read back as the same blocks. + */ +function findForcedTightError(input: string): string | undefined { + const document = setBlankBefore(parseMarkdownAst(input), undefined) + const expected = setBlankBefore(parseMarkdownAst(serializeMarkdownAst(document)), undefined) + // An input that does not read back the same with its gaps left to the + // serializer says nothing about the gaps. + if (!isMarkdownAstEqual(expected, document)) return + const output = serializeMarkdownAst(setBlankBefore(parseMarkdownAst(input), false)) + const actual = setBlankBefore(parseMarkdownAst(output), undefined) + if (!isMarkdownAstEqual(actual, expected)) + return `forced tight gaps change the blocks: ${JSON.stringify(output)}` +} + for (const [minLength, maxLength] of RANGES) { for (const { name, pool } of POOLS) { it( @@ -182,6 +212,12 @@ for (const [minLength, maxLength] of RANGES) { `lossy input (seed=${SEED}, sample=${sample}, input=${JSON.stringify(input)})`, ) } + const forcedTightError = findForcedTightError(input) + if (forcedTightError) { + throw new Error( + `${forcedTightError} (seed=${SEED}, sample=${sample}, input=${JSON.stringify(input)})`, + ) + } const positionError = findPositionError(input) if (positionError) { throw new Error( diff --git a/packages/markdown/src/ast/check-roundtrip.test.ts b/packages/markdown/src/ast/check-roundtrip.test.ts index 9d1854d47..583d16674 100644 --- a/packages/markdown/src/ast/check-roundtrip.test.ts +++ b/packages/markdown/src/ast/check-roundtrip.test.ts @@ -131,6 +131,18 @@ const EXACT_CASES: string[] = [ 'para\n- a', '> text\n> - item', '> a\n> - x\n> - y', + + // a block that interrupts the one above it stays on the next line + '# h1\n## h2\n### h3', + '## Text\ncontent', + 'a\n# h', + '```\nx\n```\na', + 'a\n```\nx\n```', + '***\na', + 'a\n***', + '\na', + 'a\n', + '> a\n> # h', ] const NORMALIZING_CASES: string[] = [ @@ -142,6 +154,15 @@ const NORMALIZING_CASES: string[] = [ '- [ ] todo\neen voorlopig idee', '- item\nlazy line', + // a blockquote under a paragraph, a list, or another quote gains a blank line + 'a\n> q', + '- a\n> b', + '> a\n> > b', + + // so does a block under a quote or list that ends in paragraph text + '> q\n# h', + '- a\n# h', + // a nested item's indent normalizes to the parent's content column '- a\n - deep', diff --git a/packages/markdown/src/ast/equal.ts b/packages/markdown/src/ast/equal.ts index 7bedba527..1f4ccbd0c 100644 --- a/packages/markdown/src/ast/equal.ts +++ b/packages/markdown/src/ast/equal.ts @@ -57,8 +57,7 @@ function isListItemEqual(a: MarkdownListItem, b: MarkdownListItem): boolean { a.collapsed === b.collapsed && a.marker === b.marker && a.taskMarker === b.taskMarker && - a.markerGap === b.markerGap && - a.blankBefore === b.blankBefore + a.markerGap === b.markerGap ) } @@ -71,10 +70,17 @@ function isCodeBlockEqual(a: MarkdownCodeBlock, b: MarkdownCodeBlock): boolean { ) } +function getBlankBefore(node: MarkdownNode): boolean | undefined { + return node.type === 'document' || node.type === 'tableRow' || node.type === 'tableCell' + ? undefined + : node.blankBefore +} + /** * Compare the fields of two nodes, without their children and positions. */ function isNodeEqual(a: MarkdownNode, b: MarkdownNode): boolean { + if (getBlankBefore(a) !== getBlankBefore(b)) return false switch (a.type) { case 'document': return ( diff --git a/packages/markdown/src/ast/gap.ts b/packages/markdown/src/ast/gap.ts index e8fde7572..a66a2b19d 100644 --- a/packages/markdown/src/ast/gap.ts +++ b/packages/markdown/src/ast/gap.ts @@ -12,6 +12,23 @@ export function canJoinTight(previous: MarkdownBlock, next: MarkdownBlock): bool // 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 'paragraph': + case 'blockquote': + case 'table': + return endsClosed(previous) + case 'heading': + return next.setextUnderline == null ? !endsInLazyText(previous) : endsClosed(previous) + case 'codeBlock': + return next.fenceStyle == null || next.fenceStyle === 'tilde' + ? !endsInLazyText(previous) + : next.fenceStyle === 'dollar' && endsClosed(previous) + case 'horizontalRule': + // A dash rule under paragraph text is a setext underline. + return (next.marker ?? '---').startsWith('-') + ? endsClosed(previous) + : !endsInLazyText(previous) + case 'htmlComment': + return !endsInLazyText(previous) 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. @@ -80,6 +97,17 @@ function endsClosed(block: MarkdownBlock): boolean { } } +/** + * Whether `block` is a container whose last line is paragraph text. The + * serializer can write such a line lazily, and a lazy line can open a block + * that runs on into whatever follows. + */ +function endsInLazyText(block: MarkdownBlock): boolean { + return ( + (block.type === 'listItem' || block.type === 'blockquote') && !endsClosed(getLastBlock(block)) + ) +} + /** * The innermost block that holds `block`'s last line. */ diff --git a/packages/markdown/src/ast/parse.ts b/packages/markdown/src/ast/parse.ts index 0e5aae9f1..1d0737a60 100644 --- a/packages/markdown/src/ast/parse.ts +++ b/packages/markdown/src/ast/parse.ts @@ -199,18 +199,22 @@ function appendGapParagraphs( } /** - * 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. + * Record whether the one optional blank line was written before `block`. Two + * or more blank lines are empty paragraphs, which say it already. Outside a + * list a blank line is what the serializer writes anyway, so only its absence + * is recorded. */ function markBlankBefore( previous: MarkdownBlock | undefined, block: MarkdownBlock | undefined, blankLines: number, ): void { - if (block?.type !== 'listItem' || previous == null || blankLines > 1) return + if (block == null || 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)) + const tight = blankLines === 0 && canJoinTight(previous, block) + if (tight) block.blankBefore = false + else if (block.type === 'listItem') block.blankBefore = true } /** diff --git a/packages/markdown/src/ast/serialize.ts b/packages/markdown/src/ast/serialize.ts index 7824e55b9..53484e149 100644 --- a/packages/markdown/src/ast/serialize.ts +++ b/packages/markdown/src/ast/serialize.ts @@ -446,7 +446,13 @@ function emitBlockChildren(children: MarkdownBlock[], out: MdOut, tightItem = fa while (index < count) { const child = children[index] if (child.type !== 'listItem') { - if (tightItem && index > 0) out.suppressBlank() + const previous = children[index - 1] + if ( + previous != null && + (tightItem || (child.blankBefore === false && canJoinTight(previous, child))) + ) { + out.suppressBlank() + } emit(child, out) index++ continue diff --git a/packages/markdown/src/ast/types.ts b/packages/markdown/src/ast/types.ts index 410dd63fd..472ba11e2 100644 --- a/packages/markdown/src/ast/types.ts +++ b/packages/markdown/src/ast/types.ts @@ -34,10 +34,22 @@ export interface MarkdownPositioned { position?: MarkdownPosition | undefined } +/** + * Fields every block carries. + */ +interface MarkdownBlockBase extends MarkdownPositioned { + /** + * Whether one blank line separates this block from the sibling before it. + * Undefined leaves the choice to the serializer. The parser records `false` + * only where the serializer can write the two blocks on adjacent lines. + */ + blankBefore?: boolean | undefined +} + /** * Raw inline Markdown. Changing `value` invalidates an adapter's recorded segments. */ -export interface MarkdownInline extends MarkdownPositioned { +export interface MarkdownInline extends MarkdownBlockBase { value: string /** * Optional editor text boundaries. Undefined chunks are non-text inline nodes, @@ -75,7 +87,7 @@ export interface MarkdownHeading extends MarkdownInline { closingHashes?: number | undefined } -export interface MarkdownBlockquote extends MarkdownPositioned { +export interface MarkdownBlockquote extends MarkdownBlockBase { type: 'blockquote' children: MarkdownBlock[] } @@ -85,7 +97,7 @@ export interface MarkdownBlockquote extends MarkdownPositioned { * For `+ [X] **buy**`, kind is task, marker is '+', markerGap is 3, * checked is true, taskMarker is 'X', and the first paragraph's value is '**buy**'. */ -export interface MarkdownListItem extends MarkdownPositioned { +export interface MarkdownListItem extends MarkdownBlockBase { type: 'listItem' kind: 'bullet' | 'ordered' | 'task' /** @@ -104,18 +116,13 @@ 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. */ children: MarkdownBlock[] } -export interface MarkdownCodeBlock extends MarkdownPositioned { +export interface MarkdownCodeBlock extends MarkdownBlockBase { type: 'codeBlock' value: string language: string @@ -131,7 +138,7 @@ export interface MarkdownCodeBlock extends MarkdownPositioned { children?: undefined } -export interface MarkdownHorizontalRule extends MarkdownPositioned { +export interface MarkdownHorizontalRule extends MarkdownBlockBase { type: 'horizontalRule' /** * Original spelling, defaulting to '---'. @@ -141,7 +148,7 @@ export interface MarkdownHorizontalRule extends MarkdownPositioned { children?: undefined } -export interface MarkdownHTMLComment extends MarkdownPositioned { +export interface MarkdownHTMLComment extends MarkdownBlockBase { type: 'htmlComment' /** * Includes the comment delimiters. @@ -151,7 +158,7 @@ export interface MarkdownHTMLComment extends MarkdownPositioned { children?: undefined } -export interface MarkdownTable extends MarkdownPositioned { +export interface MarkdownTable extends MarkdownBlockBase { type: 'table' children: MarkdownTableRow[] } @@ -171,7 +178,7 @@ export interface MarkdownTableCell extends MarkdownPositioned { /** * An unsupported editor block: omitted from output, but separates list runs. */ -export interface MarkdownIgnored extends MarkdownPositioned { +export interface MarkdownIgnored extends MarkdownBlockBase { type: 'ignored' /** * Descendant text, used if the node occurs inside a table cell. @@ -188,7 +195,7 @@ export interface MarkdownIgnored extends MarkdownPositioned { /** * A standalone editor text node. Block parsers never produce this type. */ -export interface MarkdownText extends MarkdownPositioned { +export interface MarkdownText extends MarkdownBlockBase { type: 'text' value: string children?: undefined