diff --git a/packages/lexical-markdown/src/MarkdownExport.ts b/packages/lexical-markdown/src/MarkdownExport.ts index b61b4532a98..8e3722a3f52 100644 --- a/packages/lexical-markdown/src/MarkdownExport.ts +++ b/packages/lexical-markdown/src/MarkdownExport.ts @@ -206,16 +206,25 @@ function exportTextFormat( // If the node has no format, we use the original text. // 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 - ? textContent - : escapeLeadingAndTrailingWhitespaces(textContent); + // Treat whitespace-only nodes as unformatted (except for code). + // This prevents generating empty markdown tags (e.g., ****) for bold whitespaces, + // and satisfies CommonMark flanking rules by ensuring tags wrap text, not spaces. + const isWhitespaceOnly = /^\s*$/.test(textContent); + + let output = textContent; if (!node.hasFormat('code')) { // Escape any markdown characters in the text content output = output.replace(/([*_`~\\])/g, '\\$1'); } + // Extract leading and trailing whitespaces. + // CommonMark flanking rules require formatting tags to be adjacent to non-whitespace characters. + const match = output.match(/^(\s*)(.*?)(\s*)$/s) || ['', '', output, '']; + const leadingSpace = match[1]; + const trimmedOutput = match[2]; + const trailingSpace = match[3]; + // the opening tags to be added to the result let openingTags = ''; // the closing tags to be added to the result @@ -232,14 +241,13 @@ function exportTextFormat( const tag = transformer.tag; // dedup applied formats - if (hasFormat(node, format) && !applied.has(format)) { - // Multiple tags might be used for the same format (*, _) + if (checkHasFormat(node, format) && !applied.has(format)) { applied.add(format); // append the tag to openingTags, if it's not applied to the previous nodes, // or the nodes before that (which would result in an unclosed tag) if ( - !hasFormat(prevNode, format) || + !checkHasFormat(prevNode, format) || !unclosedTags.find((element) => element.tag === tag) ) { unclosedTags.push({format, tag}); @@ -287,10 +295,21 @@ function exportTextFormat( } break; } + // If the node is entirely whitespace, we don't apply opening/closing tags around it. + // However, it must still output closing tags from previous nodes. + if (isWhitespaceOnly && !node.hasFormat('code')) { + return closingTagsBefore + output; + } - output = openingTags + output + closingTagsAfter; - // Replace trimmed version of textContent ensuring surrounding whitespace is not modified - return closingTagsBefore + output; + // Flanking Compliance: Notice how openingTags and closingTagsAfter are placed INSIDE the whitespace boundaries! + return ( + closingTagsBefore + + leadingSpace + + openingTags + + trimmedOutput + + closingTagsAfter + + trailingSpace + ); } function getTextSibling(node: TextNode, backward: boolean): TextNode | null { @@ -310,8 +329,15 @@ function hasFormat( return $isTextNode(node) && node.hasFormat(format); } -function escapeLeadingAndTrailingWhitespaces(textContent: string) { - return textContent.replace(/^\s+|\s+$/g, (match) => { - return [...match].map((char) => '&#' + char.codePointAt(0) + ';').join(''); - }); +function checkHasFormat(n: TextNode | null, f: TextFormatType): boolean { + if (!hasFormat(n, f)) { + return false; + } + if (f === 'code') { + return true; + } + if (n && /^\s*$/.test(n.getTextContent())) { + return false; + } + return true; } diff --git a/packages/lexical-markdown/src/__tests__/unit/LexicalMarkdown.test.ts b/packages/lexical-markdown/src/__tests__/unit/LexicalMarkdown.test.ts index 0ff179a542d..d67ce7be0fe 100644 --- a/packages/lexical-markdown/src/__tests__/unit/LexicalMarkdown.test.ts +++ b/packages/lexical-markdown/src/__tests__/unit/LexicalMarkdown.test.ts @@ -409,17 +409,14 @@ describe('Markdown', () => { { html: '

Hello world!

', md: '**Hello ~~world~~**!', - mdAfterExport: '**Hello ~~world~~**!', }, { html: '

Hello world!

', md: '**~~Hello *world*~~**~~!~~', - mdAfterExport: '**~~Hello *world*~~**~~!~~', }, { html: '

Hello world!

', md: '*Hello **world**!*', - mdAfterExport: '*Hello **world**!*', }, { html: '

hello world

', @@ -605,25 +602,25 @@ describe('Markdown', () => { html: '

Text boldstart text boldend text

', md: 'Text **boldstart [text](https://lexical.dev) boldend** text', mdAfterExport: - 'Text **boldstart **[**text**](https://lexical.dev)** boldend** text', + 'Text **boldstart** [**text**](https://lexical.dev) **boldend** text', }, { html: '

Text boldstart text boldend text

', md: 'Text **boldstart [`text`](https://lexical.dev) boldend** text', mdAfterExport: - 'Text **boldstart **[**`text`**](https://lexical.dev)** boldend** text', + 'Text **boldstart** [**`text`**](https://lexical.dev) **boldend** text', }, { html: '

It works with links too

', md: 'It ~~___works [with links](https://lexical.io)___~~ too', mdAfterExport: - 'It ***~~works ~~***[***~~with links~~***](https://lexical.io) too', + 'It ***~~works~~*** [***~~with links~~***](https://lexical.io) too', }, { html: '

It works with links too!

', md: 'It ~~___works [with links](https://lexical.io) too___~~!', mdAfterExport: - 'It ***~~works ~~***[***~~with links~~***](https://lexical.io)***~~ too~~***!', + 'It ***~~works~~*** [***~~with links~~***](https://lexical.io) ***~~too~~***!', }, { html: '

linklink2

', @@ -695,6 +692,7 @@ describe('Markdown', () => { { html: '

 

', md: '** **', + mdAfterExport: '\xA0', }, { html: '

[h]elloh[e]llo

', @@ -719,12 +717,11 @@ describe('Markdown', () => { { html: '

linktext

', md: '[**link**](https://lexical.dev)**text**', - mdAfterExport: '[**link**](https://lexical.dev)**text**', }, { html: '

text link

', md: '**text [link](https://lexical.dev)**', - mdAfterExport: '**text **[**link**](https://lexical.dev)', + mdAfterExport: '**text** [**link**](https://lexical.dev)', }, { html: '

texttext

', @@ -738,7 +735,7 @@ describe('Markdown', () => { { html: '

foo bar

', md: '**foo [*bar*](/url)**', - mdAfterExport: '**foo **[***bar***](/url)', + mdAfterExport: '**foo** [***bar***](/url)', }, { html: '

*foo bar baz

', @@ -748,7 +745,6 @@ describe('Markdown', () => { { html: '

a * b x

', md: '*a `*` b `x`*', - mdAfterExport: '*a `*` b `x`*', }, { html: '

_foo_bar

', diff --git a/packages/lexical-playground/__tests__/e2e/Markdown.spec.mjs b/packages/lexical-playground/__tests__/e2e/Markdown.spec.mjs index 1320f0d655d..005cbaeb335 100644 --- a/packages/lexical-playground/__tests__/e2e/Markdown.spec.mjs +++ b/packages/lexical-playground/__tests__/e2e/Markdown.spec.mjs @@ -1259,7 +1259,7 @@ This is *italic*, _italic_, **bold**, __bold__, ~~strikethrough~~ text This is *__~~bold italic strikethrough~~__* text, ___~~this one too~~___ -It ~~___works [with links](https://lexical.io)___~~ too +It ***~~works~~*** and [***~~with links~~***](https://lexical.io) too Links [with underscores](https://lexical.io/tag_here_and__here__and___here___too) and ([parenthesis](https://lexical.dev)) @@ -1354,6 +1354,7 @@ const IMPORTED_MARKDOWN_HTML = html` data-lexical-text="true"> works + and