diff --git a/src/app/plugins/markdown/bidirectional.test.ts b/src/app/plugins/markdown/bidirectional.test.ts index a987ab15e..31ab6dd53 100644 --- a/src/app/plugins/markdown/bidirectional.test.ts +++ b/src/app/plugins/markdown/bidirectional.test.ts @@ -73,11 +73,16 @@ describe('bidirectional round-trip', () => { }); it('round-trips blockquotes', () => { - const markdown = '> Quote text'; - const html = markdownToHtml(markdown); - const injected = injectDataMd(html); - const result = htmlToMarkdown(injected); - expect(result).toContain('> Quote text'); + const roundtrip = (markdown: string) => { + const html = markdownToHtml(markdown); + const injected = injectDataMd(html); + return htmlToMarkdown(injected); + }; + expect(roundtrip('> Quote text')).toBe('> Quote text'); + expect(roundtrip('> line one\n> line two')).toBe('> line one\n> line two'); + expect(roundtrip('> test\ntest')).toBe('> test\ntest'); + expect(roundtrip('> test\n\n> test')).toBe('> test\n\n> test'); + expect((markdownToHtml('> test\n\n> test').match(/
{ diff --git a/src/app/plugins/markdown/expandBlockNewlines.test.ts b/src/app/plugins/markdown/expandBlockNewlines.test.ts new file mode 100644 index 000000000..700d743a7 --- /dev/null +++ b/src/app/plugins/markdown/expandBlockNewlines.test.ts @@ -0,0 +1,25 @@ +import { describe, expect, it } from 'vitest'; +import { expandBlockBoundariesAfterSingleNewlines } from './expandBlockNewlines'; +import { markdownToHtml } from './markdownToHtml'; + +describe('expandBlockBoundariesAfterSingleNewlines', () => { + it('does not expand between consecutive blockquote lines', () => { + const md = '> test\n> test\n> test'; + expect(expandBlockBoundariesAfterSingleNewlines(md)).toBe(md); + }); + + it('still expands before the first blockquote line', () => { + expect(expandBlockBoundariesAfterSingleNewlines('intro\n> quote')).toBe('intro\n\n> quote'); + }); + + it('still expands when a blockquote ends', () => { + expect(expandBlockBoundariesAfterSingleNewlines('> quote\nplain')).toBe('> quote\n\nplain'); + }); +}); + +describe('consecutive blockquotes', () => { + it('produces a single blockquote element', () => { + const html = markdownToHtml('> test\n> test\n> test'); + expect((html.match(/` lines belong to one blockquote, keep the single `\n` between them. + if (prevLineIsBlockquote(md, newlineIdx) && nextLineContinuesBlockquote(md, newlineIdx)) { + return false; + } if (nextLineIsBlockStarter(md, newlineIdx)) return true; // CommonMark lazy continuation keeps non-`>` lines inside blockquotes, close on single `\n`. if (prevLineIsBlockquote(md, newlineIdx) && !nextLineContinuesBlockquote(md, newlineIdx)) { diff --git a/src/app/plugins/markdown/htmlToMarkdown.test.ts b/src/app/plugins/markdown/htmlToMarkdown.test.ts index f98a50637..57e5e25bd 100644 --- a/src/app/plugins/markdown/htmlToMarkdown.test.ts +++ b/src/app/plugins/markdown/htmlToMarkdown.test.ts @@ -100,9 +100,17 @@ describe('htmlToMarkdown', () => { }); it('converts blockquotes', () => { - const result = htmlToMarkdown('Quote text'); - expect(result).toContain('>'); - expect(result).toContain('Quote text'); + expect(htmlToMarkdown('Quote text')).toBe('> Quote text'); + expect(htmlToMarkdown('test
test
')).toBe('> test\ntest'); + expect(htmlToMarkdown('')).toBe( + '> line one\n> line two' + ); + expect(htmlToMarkdown('line one
line two
')).toBe( + '> line one\n> line two' + ); + expect( + htmlToMarkdown('line one
line twofirst
') + ).toBe('> first\n\n> second'); }); it('converts unordered lists', () => { diff --git a/src/app/plugins/markdown/htmlToMarkdown.ts b/src/app/plugins/markdown/htmlToMarkdown.ts index 2ceb6d87e..cf00d0dd4 100644 --- a/src/app/plugins/markdown/htmlToMarkdown.ts +++ b/src/app/plugins/markdown/htmlToMarkdown.ts @@ -66,15 +66,15 @@ function processNodes(nodes: ChildNode[]): string { const prev = filtered[i - 1]; // Adjacentsecond
blocks must become \n\n in markdown so the editor gets separate Slate // paragraphs and marked emits
per block again on send (single \n would collapse). - if ( - i > 0 && - prev && - isTag(prev) && - isTag(cur) && - prev.name.toLowerCase() === 'p' && - cur.name.toLowerCase() === 'p' - ) { - parts.push('\n'); + if (i > 0 && prev && isTag(prev) && isTag(cur)) { + const prevTag = prev.name.toLowerCase(); + const curTag = cur.name.toLowerCase(); + if ( + (prevTag === 'p' && curTag === 'p') || + (prevTag === 'blockquote' && curTag === 'blockquote') + ) { + parts.push('\n'); + } } parts.push(processNode(cur)); } @@ -333,19 +333,57 @@ function processParagraph( return `${content}\n`; } +function collectBlockquoteBodyLines( + node: Element, + listDepth: number, + insideCode: boolean +): string[] { + const lines: string[] = []; + const pushLine = (line: string) => { + lines.push(line); + }; + const pushMultiline = (text: string) => { + for (const part of text.split('\n')) { + pushLine(part); + } + }; + + for (const child of node.children) { + if (isText(child)) { + if (/^\s*$/.test(child.data)) continue; + const text = insideCode ? child.data : escapeMarkdownInlineSequences(child.data); + pushMultiline(text); + continue; + } + + if (!isTag(child)) continue; + + const tag = child.name.toLowerCase(); + if (tag === 'p') { + pushMultiline(processChildren(child.children, listDepth, insideCode)); + } else if (tag === 'br') { + pushLine(''); + } else if (tag === 'blockquote') { + lines.push(...collectBlockquoteBodyLines(child, listDepth, insideCode)); + } else { + pushMultiline(processNode(child, listDepth, insideCode).trimEnd()); + } + } + + return lines; +} + function processBlockquote( node: Element, listDepth: number = 0, insideCode: boolean = false ): string { - const content = node.children - .map((child) => { - if (isTag(child) && child.name === 'br') return '\n'; - const text = processNode(child, listDepth, insideCode); - return text.replace(/\n/g, '\n> '); - }) - .join(''); - return `> ${content}\n`; + const marker = node.attribs['data-md'] ? `${node.attribs['data-md']} ` : '> '; + const lines = collectBlockquoteBodyLines(node, listDepth, insideCode); + const body = lines + .map((line) => (line.length === 0 ? marker.trimEnd() : `${marker}${line}`)) + .join('\n'); + return `${body}\n`; } /** diff --git a/src/app/plugins/markdown/markdownToHtml.test.ts b/src/app/plugins/markdown/markdownToHtml.test.ts index 6ed37ae0f..99af89689 100644 --- a/src/app/plugins/markdown/markdownToHtml.test.ts +++ b/src/app/plugins/markdown/markdownToHtml.test.ts @@ -163,6 +163,12 @@ describe('markdownToHtml', () => { expect(html).toContain('
'); expect(html).toContain('line one'); expect(html).toContain('line two'); + expect((html.match(/{ + const html = markdownToHtml('> test\n> test\n> test'); + expect((html.match(/{