Skip to content

Commit

Permalink
fix(richtext-lexical): formatted link markdown conversion not working (
Browse files Browse the repository at this point in the history
  • Loading branch information
AlessioGr authored Dec 30, 2024
1 parent 7a59e7d commit 885e966
Show file tree
Hide file tree
Showing 7 changed files with 483 additions and 186 deletions.
19 changes: 9 additions & 10 deletions packages/richtext-lexical/src/features/link/markdownTransformer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,21 +16,18 @@ import { $createLinkNode, $isLinkNode, LinkNode } from './nodes/LinkNode.js'
export const LinkMarkdownTransformer: TextMatchTransformer = {
type: 'text-match',
dependencies: [LinkNode],
export: (_node, exportChildren, exportFormat) => {
export: (_node, exportChildren) => {
if (!$isLinkNode(_node)) {
return null
}
const node: LinkNode = _node
const { url } = node.getFields()
const linkContent = `[${node.getTextContent()}](${url})`
const firstChild = node.getFirstChild()
// Add text styles only if link has single text node inside. If it's more
// then one we ignore it as markdown does not support nested styles for links
if (node.getChildrenSize() === 1 && $isTextNode(firstChild)) {
return exportFormat(firstChild, linkContent)
} else {
return linkContent
}

const textContent = exportChildren(node)

const linkContent = `[${textContent}](${url})`

return linkContent
},
importRegExp: /\[([^[]+)\]\(([^()\s]+)(?:\s"((?:[^"]*\\")*[^"]*)"\s*)?\)/,
regExp: /\[([^[]+)\]\(([^()\s]+)(?:\s"((?:[^"]*\\")*[^"]*)"\s*)?\)$/,
Expand All @@ -48,6 +45,8 @@ export const LinkMarkdownTransformer: TextMatchTransformer = {
linkTextNode.setFormat(textNode.getFormat())
linkNode.append(linkTextNode)
textNode.replace(linkNode)

return linkTextNode
},
trigger: ')',
}
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ export function createMarkdownExport(
)

return (node) => {
const output: string[] = []
const output = []
const children = (node || $getRoot()).getChildren()

for (let i = 0; i < children.length; i++) {
Expand Down Expand Up @@ -100,9 +100,18 @@ function exportChildren(
node: ElementNode,
textTransformersIndex: Array<TextFormatTransformer>,
textMatchTransformers: Array<TextMatchTransformer>,
unclosedTags?: Array<{ format: TextFormatType; tag: string }>,
unclosableTags?: Array<{ format: TextFormatType; tag: string }>,
): string {
const output: string[] = []
const output = []
const children = node.getChildren()
// keep track of unclosed tags from the very beginning
if (!unclosedTags) {
unclosedTags = []
}
if (!unclosableTags) {
unclosableTags = []
}

mainLoop: for (const child of children) {
for (const transformer of textMatchTransformers) {
Expand All @@ -112,8 +121,27 @@ function exportChildren(

const result = transformer.export(
child,
(parentNode) => exportChildren(parentNode, textTransformersIndex, textMatchTransformers),
(textNode, textContent) => exportTextFormat(textNode, textContent, textTransformersIndex),
(parentNode) =>
exportChildren(
parentNode,
textTransformersIndex,
textMatchTransformers,
unclosedTags,
// Add current unclosed tags to the list of unclosable tags - we don't want nested tags from
// textmatch transformers to close the outer ones, as that may result in invalid markdown.
// E.g. **text [text**](https://lexical.io)
// is invalid markdown, as the closing ** is inside the link.
//
[...unclosableTags, ...unclosedTags],
),
(textNode, textContent) =>
exportTextFormat(
textNode,
textContent,
textTransformersIndex,
unclosedTags,
unclosableTags,
),
)

if (result != null) {
Expand All @@ -125,10 +153,26 @@ function exportChildren(
if ($isLineBreakNode(child)) {
output.push('\n')
} else if ($isTextNode(child)) {
output.push(exportTextFormat(child, child.getTextContent(), textTransformersIndex))
output.push(
exportTextFormat(
child,
child.getTextContent(),
textTransformersIndex,
unclosedTags,
unclosableTags,
),
)
} else if ($isElementNode(child)) {
// empty paragraph returns ""
output.push(exportChildren(child, textTransformersIndex, textMatchTransformers))
output.push(
exportChildren(
child,
textTransformersIndex,
textMatchTransformers,
unclosedTags,
unclosableTags,
),
)
} else if ($isDecoratorNode(child)) {
output.push(child.getTextContent())
}
Expand All @@ -141,41 +185,88 @@ function exportTextFormat(
node: TextNode,
textContent: string,
textTransformers: Array<TextFormatTransformer>,
// 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 }>,
): string {
// This function handles the case of a string looking like this: " foo "
// Where it would be invalid markdown to generate: "** foo **"
// We instead want to trim the whitespace out, apply formatting, and then
// bring the whitespace back. So our returned string looks like this: " **foo** "
const frozenString = textContent.trim()
let output = frozenString
// the opening tags to be added to the result
let openingTags = ''
// the closing tags to be added to the result
let closingTagsBefore = ''
let closingTagsAfter = ''

const prevNode = getTextSibling(node, true)
const nextNode = getTextSibling(node, false)

const applied = new Set()

for (const transformer of textTransformers) {
const format = transformer.format[0]
const tag = transformer.tag

// dedup applied formats
if (hasFormat(node, format) && !applied.has(format)) {
// Multiple tags might be used for the same format (*, _)
applied.add(format)
// Prevent adding opening tag is already opened by the previous sibling
const previousNode = getTextSibling(node, true)

if (!hasFormat(previousNode, format)) {
output = tag + output
// append the tag to openningTags, 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) || !unclosedTags.find((element) => element.tag === tag)) {
unclosedTags.push({ format, tag })
openingTags += tag
}
}
}

// close any tags in the same order they were applied, if necessary
for (let i = 0; i < unclosedTags.length; i++) {
const nodeHasFormat = hasFormat(node, unclosedTags[i].format)
const nextNodeHasFormat = hasFormat(nextNode, unclosedTags[i].format)

// Prevent adding closing tag if next sibling will do it
const nextNode = getTextSibling(node, false)
// prevent adding closing tag if next sibling will do it
if (nodeHasFormat && nextNodeHasFormat) {
continue
}

const unhandledUnclosedTags = [...unclosedTags] // Shallow copy to avoid modifying the original array

while (unhandledUnclosedTags.length > i) {
const unclosedTag = unhandledUnclosedTags.pop()

// If tag is unclosable, don't close it and leave it in the original array,
// So that it can be closed when it's no longer unclosable
if (
unclosableTags &&
unclosedTag &&
unclosableTags.find((element) => element.tag === unclosedTag.tag)
) {
continue
}

if (!hasFormat(nextNode, format)) {
output += tag
if (unclosedTag && typeof unclosedTag.tag === 'string') {
if (!nodeHasFormat) {
// Handles cases where the tag has not been closed before, e.g. if the previous node
// was a text match transformer that did not account for closing tags of the next node (e.g. a link)
closingTagsBefore += unclosedTag.tag
} else if (!nextNodeHasFormat) {
closingTagsAfter += unclosedTag.tag
}
}
// Mutate the original array to remove the closed tag
unclosedTags.pop()
}
break
}

output = openingTags + output + closingTagsAfter
// Replace trimmed version of textContent ensuring surrounding whitespace is not modified
return textContent.replace(frozenString, () => output)
return closingTagsBefore + textContent.replace(frozenString, () => output)
}

// Get next or previous text sibling a text node, including cases
Expand Down
Loading

0 comments on commit 885e966

Please sign in to comment.