Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
54 changes: 40 additions & 14 deletions packages/lexical-markdown/src/MarkdownExport.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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});
Expand Down Expand Up @@ -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 {
Expand All @@ -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;
}
Original file line number Diff line number Diff line change
Expand Up @@ -409,17 +409,14 @@ describe('Markdown', () => {
{
html: '<p><b><strong style="white-space: pre-wrap;">Hello </strong></b><s><b><strong style="white-space: pre-wrap;">world</strong></b></s><span style="white-space: pre-wrap;">!</span></p>',
md: '**Hello ~~world~~**!',
mdAfterExport: '**Hello&#32;~~world~~**!',
},
{
html: '<p><s><b><strong style="white-space: pre-wrap;">Hello </strong></b></s><s><i><b><strong style="white-space: pre-wrap;">world</strong></b></i></s><s><span style="white-space: pre-wrap;">!</span></s></p>',
md: '**~~Hello *world*~~**~~!~~',
mdAfterExport: '**~~Hello&#32;*world*~~**~~!~~',
},
{
html: '<p><i><em style="white-space: pre-wrap;">Hello </em></i><i><b><strong style="white-space: pre-wrap;">world</strong></b></i><i><em style="white-space: pre-wrap;">!</em></i></p>',
md: '*Hello **world**!*',
mdAfterExport: '*Hello&#32;**world**!*',
},
{
html: '<p><span style="white-space: pre-wrap;">hello world</span></p>',
Expand Down Expand Up @@ -605,25 +602,25 @@ describe('Markdown', () => {
html: '<p><span style="white-space: pre-wrap;">Text </span><b><strong style="white-space: pre-wrap;">boldstart </strong></b><a href="https://lexical.dev"><b><strong style="white-space: pre-wrap;">text</strong></b></a><b><strong style="white-space: pre-wrap;"> boldend</strong></b><span style="white-space: pre-wrap;"> text</span></p>',
md: 'Text **boldstart [text](https://lexical.dev) boldend** text',
mdAfterExport:
'Text **boldstart&#32;**[**text**](https://lexical.dev)**&#32;boldend** text',
'Text **boldstart** [**text**](https://lexical.dev) **boldend** text',
},
{
html: '<p><span style="white-space: pre-wrap;">Text </span><b><strong style="white-space: pre-wrap;">boldstart </strong></b><a href="https://lexical.dev"><b><code spellcheck="false" style="white-space: pre-wrap;"><strong>text</strong></code></b></a><b><strong style="white-space: pre-wrap;"> boldend</strong></b><span style="white-space: pre-wrap;"> text</span></p>',
md: 'Text **boldstart [`text`](https://lexical.dev) boldend** text',
mdAfterExport:
'Text **boldstart&#32;**[**`text`**](https://lexical.dev)**&#32;boldend** text',
'Text **boldstart** [**`text`**](https://lexical.dev) **boldend** text',
},
{
html: '<p><span style="white-space: pre-wrap;">It </span><s><i><b><strong style="white-space: pre-wrap;">works </strong></b></i></s><a href="https://lexical.io"><s><i><b><strong style="white-space: pre-wrap;">with links</strong></b></i></s></a><span style="white-space: pre-wrap;"> too</span></p>',
md: 'It ~~___works [with links](https://lexical.io)___~~ too',
mdAfterExport:
'It ***~~works&#32;~~***[***~~with links~~***](https://lexical.io) too',
'It ***~~works~~*** [***~~with links~~***](https://lexical.io) too',
},
{
html: '<p><span style="white-space: pre-wrap;">It </span><s><i><b><strong style="white-space: pre-wrap;">works </strong></b></i></s><a href="https://lexical.io"><s><i><b><strong style="white-space: pre-wrap;">with links</strong></b></i></s></a><s><i><b><strong style="white-space: pre-wrap;"> too</strong></b></i></s><span style="white-space: pre-wrap;">!</span></p>',
md: 'It ~~___works [with links](https://lexical.io) too___~~!',
mdAfterExport:
'It ***~~works&#32;~~***[***~~with links~~***](https://lexical.io)***~~&#32;too~~***!',
'It ***~~works~~*** [***~~with links~~***](https://lexical.io) ***~~too~~***!',
},
{
html: '<p><a href="https://lexical.dev"><span style="white-space: pre-wrap;">link</span></a><a href="https://lexical.dev"><span style="white-space: pre-wrap;">link2</span></a></p>',
Expand Down Expand Up @@ -695,6 +692,7 @@ describe('Markdown', () => {
{
html: '<p><b><strong style="white-space: pre-wrap;">&nbsp;</strong></b></p>',
md: '**&#160;**',
mdAfterExport: '\xA0',
},
{
html: '<p><a href="https://lexical.dev"><span style="white-space: pre-wrap;">[h]ello</span></a><a href="https://lexical.dev"><span style="white-space: pre-wrap;">h[e]llo</span></a></p>',
Expand All @@ -719,12 +717,11 @@ describe('Markdown', () => {
{
html: '<p><a href="https://lexical.dev"><b><strong style="white-space: pre-wrap;">link</strong></b></a><b><strong style="white-space: pre-wrap;">text</strong></b></p>',
md: '[**link**](https://lexical.dev)**text**',
mdAfterExport: '[**link**](https://lexical.dev)**text**',
},
{
html: '<p><b><strong style="white-space: pre-wrap;">text </strong></b><a href="https://lexical.dev"><b><strong style="white-space: pre-wrap;">link</strong></b></a></p>',
md: '**text [link](https://lexical.dev)**',
mdAfterExport: '**text&#32;**[**link**](https://lexical.dev)',
mdAfterExport: '**text** [**link**](https://lexical.dev)',
},
{
html: '<p><i><em style="white-space: pre-wrap;">text</em></i><i><b><strong style="white-space: pre-wrap;">text</strong></b></i></p>',
Expand All @@ -738,7 +735,7 @@ describe('Markdown', () => {
{
html: '<p><b><strong style="white-space: pre-wrap;">foo </strong></b><a href="/url"><i><b><strong style="white-space: pre-wrap;">bar</strong></b></i></a></p>',
md: '**foo [*bar*](/url)**',
mdAfterExport: '**foo&#32;**[***bar***](/url)',
mdAfterExport: '**foo** [***bar***](/url)',
},
{
html: '<p><span style="white-space: pre-wrap;">*foo </span><i><em style="white-space: pre-wrap;">bar baz</em></i></p>',
Expand All @@ -748,7 +745,6 @@ describe('Markdown', () => {
{
html: '<p><i><em style="white-space: pre-wrap;">a </em></i><i><code spellcheck="false" style="white-space: pre-wrap;"><em>*</em></code></i><i><em style="white-space: pre-wrap;"> b </em></i><i><code spellcheck="false" style="white-space: pre-wrap;"><em>x</em></code></i></p>',
md: '*a `*` b `x`*',
mdAfterExport: '*a&#32;`*`&#32;b&#32;`x`*',
},
{
html: '<p><span style="white-space: pre-wrap;">_foo_bar</span></p>',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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))

Expand Down Expand Up @@ -1354,6 +1354,7 @@ const IMPORTED_MARKDOWN_HTML = html`
data-lexical-text="true">
works
</strong>
<span data-lexical-text="true">and</span>
<a class="PlaygroundEditorTheme__link" href="https://lexical.io">
<strong
class="PlaygroundEditorTheme__textBold PlaygroundEditorTheme__textItalic PlaygroundEditorTheme__textStrikethrough"
Expand Down