From 40eaeb0be4911857f5c09c17c16ff1fc0a9dd00b Mon Sep 17 00:00:00 2001 From: Baptiste Jamin Date: Tue, 4 Nov 2025 18:24:28 +0100 Subject: [PATCH 1/4] [lexical-markdown] Option: $convertToMarkdownString with shouldPreserveWhitespaces --- packages/lexical-markdown/src/MarkdownExport.ts | 13 +++++++++++-- packages/lexical-markdown/src/index.ts | 2 ++ 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/packages/lexical-markdown/src/MarkdownExport.ts b/packages/lexical-markdown/src/MarkdownExport.ts index 59bc5e4ea64..b0fb580992c 100644 --- a/packages/lexical-markdown/src/MarkdownExport.ts +++ b/packages/lexical-markdown/src/MarkdownExport.ts @@ -31,6 +31,7 @@ import {isEmptyParagraph, transformersByType} from './utils'; export function createMarkdownExport( transformers: Array, shouldPreserveNewLines: boolean = false, + shouldPreserveWhitespaces: boolean = false, ): (node?: ElementNode) => string { const byType = transformersByType(transformers); const elementTransformers = [...byType.multilineElement, ...byType.element]; @@ -60,6 +61,7 @@ export function createMarkdownExport( elementTransformers, textFormatTransformers, byType.textMatch, + shouldPreserveWhitespaces, ); if (result != null) { @@ -85,6 +87,7 @@ function exportTopLevelElements( elementTransformers: Array, textTransformersIndex: Array, textMatchTransformers: Array, + shouldPreserveWhitespaces: boolean = false, ): string | null { for (const transformer of elementTransformers) { if (!transformer.export) { @@ -100,7 +103,7 @@ function exportTopLevelElements( } if ($isElementNode(node)) { - return exportChildren(node, textTransformersIndex, textMatchTransformers); + return exportChildren(node, textTransformersIndex, textMatchTransformers, undefined, undefined, shouldPreserveWhitespaces); } else if ($isDecoratorNode(node)) { return node.getTextContent(); } else { @@ -114,6 +117,7 @@ function exportChildren( textMatchTransformers: Array, unclosedTags?: Array<{format: TextFormatType; tag: string}>, unclosableTags?: Array<{format: TextFormatType; tag: string}>, + shouldPreserveWhitespaces: boolean = false, ): string { const output = []; const children = node.getChildren(); @@ -145,6 +149,7 @@ function exportChildren( // is invalid markdown, as the closing ** is inside the link. // [...unclosableTags, ...unclosedTags], + shouldPreserveWhitespaces, ), (textNode, textContent) => exportTextFormat( @@ -153,6 +158,7 @@ function exportChildren( textTransformersIndex, unclosedTags, unclosableTags, + shouldPreserveWhitespaces, ), ); @@ -172,6 +178,7 @@ function exportChildren( textTransformersIndex, unclosedTags, unclosableTags, + shouldPreserveWhitespaces, ), ); } else if ($isElementNode(child)) { @@ -183,6 +190,7 @@ function exportChildren( textMatchTransformers, unclosedTags, unclosableTags, + shouldPreserveWhitespaces, ), ); } else if ($isDecoratorNode(child)) { @@ -200,6 +208,7 @@ function exportTextFormat( // unclosed tags include the markdown tags that haven't been closed yet, and their associated formats unclosedTags: Array<{format: TextFormatType; tag: string}>, unclosableTags?: Array<{format: TextFormatType; tag: string}>, + shouldPreserveWhitespaces: boolean = false, ): string { // This function handles the case of a string looking like this: " foo " // Where it would be invalid markdown to generate: "** foo **" @@ -207,7 +216,7 @@ function exportTextFormat( // Otherwise, we escape leading and trailing whitespaces to their corresponding code points, // ensuring the returned string maintains its original formatting, e.g., "** foo **". let output = - node.getFormat() === 0 + node.getFormat() === 0 && !shouldPreserveWhitespaces ? textContent : escapeLeadingAndTrailingWhitespaces(textContent); diff --git a/packages/lexical-markdown/src/index.ts b/packages/lexical-markdown/src/index.ts index 3b9270d0e22..ed3c482ab6f 100644 --- a/packages/lexical-markdown/src/index.ts +++ b/packages/lexical-markdown/src/index.ts @@ -73,10 +73,12 @@ function $convertToMarkdownString( transformers: Array = TRANSFORMERS, node?: ElementNode, shouldPreserveNewLines: boolean = false, + shouldPreserveWhitespaces: boolean = false, ): string { const exportMarkdown = createMarkdownExport( transformers, shouldPreserveNewLines, + shouldPreserveWhitespaces ); return exportMarkdown(node); } From 9e45b1c3dcacd5eab5d11ede54789dcf37972d70 Mon Sep 17 00:00:00 2001 From: Baptiste Jamin Date: Wed, 5 Nov 2025 08:59:42 +0100 Subject: [PATCH 2/4] [lexical-markdown]: fix default behavior + lint pass --- packages/lexical-markdown/src/MarkdownExport.ts | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/packages/lexical-markdown/src/MarkdownExport.ts b/packages/lexical-markdown/src/MarkdownExport.ts index b0fb580992c..f1d8848474e 100644 --- a/packages/lexical-markdown/src/MarkdownExport.ts +++ b/packages/lexical-markdown/src/MarkdownExport.ts @@ -103,7 +103,14 @@ function exportTopLevelElements( } if ($isElementNode(node)) { - return exportChildren(node, textTransformersIndex, textMatchTransformers, undefined, undefined, shouldPreserveWhitespaces); + return exportChildren( + node, + textTransformersIndex, + textMatchTransformers, + undefined, + undefined, + shouldPreserveWhitespaces, + ); } else if ($isDecoratorNode(node)) { return node.getTextContent(); } else { @@ -216,7 +223,7 @@ function exportTextFormat( // Otherwise, we escape leading and trailing whitespaces to their corresponding code points, // ensuring the returned string maintains its original formatting, e.g., "** foo **". let output = - node.getFormat() === 0 && !shouldPreserveWhitespaces + node.getFormat() === 0 || shouldPreserveWhitespaces ? textContent : escapeLeadingAndTrailingWhitespaces(textContent); From 7a7b763f7116a3864b5749978cbd2b127ca5c742 Mon Sep 17 00:00:00 2001 From: Baptiste Jamin Date: Wed, 5 Nov 2025 09:25:09 +0100 Subject: [PATCH 3/4] [lexical-markdown] lint pass --- packages/lexical-markdown/src/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/lexical-markdown/src/index.ts b/packages/lexical-markdown/src/index.ts index ed3c482ab6f..67a4569729a 100644 --- a/packages/lexical-markdown/src/index.ts +++ b/packages/lexical-markdown/src/index.ts @@ -78,7 +78,7 @@ function $convertToMarkdownString( const exportMarkdown = createMarkdownExport( transformers, shouldPreserveNewLines, - shouldPreserveWhitespaces + shouldPreserveWhitespaces, ); return exportMarkdown(node); } From 97e1e2927cb53a2c3284543ea625f36fd05055bc Mon Sep 17 00:00:00 2001 From: Baptiste Jamin Date: Sun, 9 Nov 2025 12:06:53 +0100 Subject: [PATCH 4/4] [lexical-markdown] test case on preserve whitespace --- .../__tests__/unit/LexicalMarkdown.test.ts | 72 +++++++++++++++++++ 1 file changed, 72 insertions(+) diff --git a/packages/lexical-markdown/src/__tests__/unit/LexicalMarkdown.test.ts b/packages/lexical-markdown/src/__tests__/unit/LexicalMarkdown.test.ts index 719c8bff246..3627a38be78 100644 --- a/packages/lexical-markdown/src/__tests__/unit/LexicalMarkdown.test.ts +++ b/packages/lexical-markdown/src/__tests__/unit/LexicalMarkdown.test.ts @@ -1047,6 +1047,78 @@ describe('Markdown', () => { }); }); +describe('whitespace preservation in export', () => { + it('exports leading/trailing whitespaces as HTML code points by default', () => { + const editor = createHeadlessEditor({ + nodes: [ + HeadingNode, + ListNode, + ListItemNode, + QuoteNode, + CodeNode, + LinkNode, + ], + }); + + const html = + '

Hello

'; + + editor.update( + () => { + const parser = new DOMParser(); + const dom = parser.parseFromString(html, 'text/html'); + const nodes = $generateNodesFromDOM(editor, dom); + $getRoot().select(); + $insertNodes(nodes); + }, + {discrete: true}, + ); + + const exported = editor + .getEditorState() + .read(() => + $convertToMarkdownString([...TRANSFORMERS], undefined, false, false), + ); + + expect(exported).toBe('** Hello **'); + }); + + it('preserves leading/trailing whitespaces when shouldPreserveWhitespaces=true', () => { + const editor = createHeadlessEditor({ + nodes: [ + HeadingNode, + ListNode, + ListItemNode, + QuoteNode, + CodeNode, + LinkNode, + ], + }); + + const html = + '

Hello

'; + + editor.update( + () => { + const parser = new DOMParser(); + const dom = parser.parseFromString(html, 'text/html'); + const nodes = $generateNodesFromDOM(editor, dom); + $getRoot().select(); + $insertNodes(nodes); + }, + {discrete: true}, + ); + + const exported = editor + .getEditorState() + .read(() => + $convertToMarkdownString([...TRANSFORMERS], undefined, false, true), + ); + + expect(exported).toBe('** Hello **'); + }); +}); + describe('normalizeMarkdown - shouldMergeAdjacentLines = true', () => { it('should combine lines separated by a single \n unless they are in a codeblock', () => { const markdown = `