Skip to content

Commit 9e2e070

Browse files
AlessioGrkendelljoseph
authored andcommitted
fix(richtext-lexical): formatted link markdown conversion not working (#10269)
Fixes #8279 Ports over facebook/lexical#7004
1 parent 7178f1b commit 9e2e070

File tree

7 files changed

+483
-186
lines changed

7 files changed

+483
-186
lines changed

packages/richtext-lexical/src/features/link/markdownTransformer.ts

+9-10
Original file line numberDiff line numberDiff line change
@@ -16,21 +16,18 @@ import { $createLinkNode, $isLinkNode, LinkNode } from './nodes/LinkNode.js'
1616
export const LinkMarkdownTransformer: TextMatchTransformer = {
1717
type: 'text-match',
1818
dependencies: [LinkNode],
19-
export: (_node, exportChildren, exportFormat) => {
19+
export: (_node, exportChildren) => {
2020
if (!$isLinkNode(_node)) {
2121
return null
2222
}
2323
const node: LinkNode = _node
2424
const { url } = node.getFields()
25-
const linkContent = `[${node.getTextContent()}](${url})`
26-
const firstChild = node.getFirstChild()
27-
// Add text styles only if link has single text node inside. If it's more
28-
// then one we ignore it as markdown does not support nested styles for links
29-
if (node.getChildrenSize() === 1 && $isTextNode(firstChild)) {
30-
return exportFormat(firstChild, linkContent)
31-
} else {
32-
return linkContent
33-
}
25+
26+
const textContent = exportChildren(node)
27+
28+
const linkContent = `[${textContent}](${url})`
29+
30+
return linkContent
3431
},
3532
importRegExp: /\[([^[]+)\]\(([^()\s]+)(?:\s"((?:[^"]*\\")*[^"]*)"\s*)?\)/,
3633
regExp: /\[([^[]+)\]\(([^()\s]+)(?:\s"((?:[^"]*\\")*[^"]*)"\s*)?\)$/,
@@ -48,6 +45,8 @@ export const LinkMarkdownTransformer: TextMatchTransformer = {
4845
linkTextNode.setFormat(textNode.getFormat())
4946
linkNode.append(linkTextNode)
5047
textNode.replace(linkNode)
48+
49+
return linkTextNode
5150
},
5251
trigger: ')',
5352
}

packages/richtext-lexical/src/packages/@lexical/markdown/MarkdownExport.ts

+106-15
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@ export function createMarkdownExport(
3838
)
3939

4040
return (node) => {
41-
const output: string[] = []
41+
const output = []
4242
const children = (node || $getRoot()).getChildren()
4343

4444
for (let i = 0; i < children.length; i++) {
@@ -100,9 +100,18 @@ function exportChildren(
100100
node: ElementNode,
101101
textTransformersIndex: Array<TextFormatTransformer>,
102102
textMatchTransformers: Array<TextMatchTransformer>,
103+
unclosedTags?: Array<{ format: TextFormatType; tag: string }>,
104+
unclosableTags?: Array<{ format: TextFormatType; tag: string }>,
103105
): string {
104-
const output: string[] = []
106+
const output = []
105107
const children = node.getChildren()
108+
// keep track of unclosed tags from the very beginning
109+
if (!unclosedTags) {
110+
unclosedTags = []
111+
}
112+
if (!unclosableTags) {
113+
unclosableTags = []
114+
}
106115

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

113122
const result = transformer.export(
114123
child,
115-
(parentNode) => exportChildren(parentNode, textTransformersIndex, textMatchTransformers),
116-
(textNode, textContent) => exportTextFormat(textNode, textContent, textTransformersIndex),
124+
(parentNode) =>
125+
exportChildren(
126+
parentNode,
127+
textTransformersIndex,
128+
textMatchTransformers,
129+
unclosedTags,
130+
// Add current unclosed tags to the list of unclosable tags - we don't want nested tags from
131+
// textmatch transformers to close the outer ones, as that may result in invalid markdown.
132+
// E.g. **text [text**](https://lexical.io)
133+
// is invalid markdown, as the closing ** is inside the link.
134+
//
135+
[...unclosableTags, ...unclosedTags],
136+
),
137+
(textNode, textContent) =>
138+
exportTextFormat(
139+
textNode,
140+
textContent,
141+
textTransformersIndex,
142+
unclosedTags,
143+
unclosableTags,
144+
),
117145
)
118146

119147
if (result != null) {
@@ -125,10 +153,26 @@ function exportChildren(
125153
if ($isLineBreakNode(child)) {
126154
output.push('\n')
127155
} else if ($isTextNode(child)) {
128-
output.push(exportTextFormat(child, child.getTextContent(), textTransformersIndex))
156+
output.push(
157+
exportTextFormat(
158+
child,
159+
child.getTextContent(),
160+
textTransformersIndex,
161+
unclosedTags,
162+
unclosableTags,
163+
),
164+
)
129165
} else if ($isElementNode(child)) {
130166
// empty paragraph returns ""
131-
output.push(exportChildren(child, textTransformersIndex, textMatchTransformers))
167+
output.push(
168+
exportChildren(
169+
child,
170+
textTransformersIndex,
171+
textMatchTransformers,
172+
unclosedTags,
173+
unclosableTags,
174+
),
175+
)
132176
} else if ($isDecoratorNode(child)) {
133177
output.push(child.getTextContent())
134178
}
@@ -141,41 +185,88 @@ function exportTextFormat(
141185
node: TextNode,
142186
textContent: string,
143187
textTransformers: Array<TextFormatTransformer>,
188+
// unclosed tags include the markdown tags that haven't been closed yet, and their associated formats
189+
unclosedTags: Array<{ format: TextFormatType; tag: string }>,
190+
unclosableTags?: Array<{ format: TextFormatType; tag: string }>,
144191
): string {
145192
// This function handles the case of a string looking like this: " foo "
146193
// Where it would be invalid markdown to generate: "** foo **"
147194
// We instead want to trim the whitespace out, apply formatting, and then
148195
// bring the whitespace back. So our returned string looks like this: " **foo** "
149196
const frozenString = textContent.trim()
150197
let output = frozenString
198+
// the opening tags to be added to the result
199+
let openingTags = ''
200+
// the closing tags to be added to the result
201+
let closingTagsBefore = ''
202+
let closingTagsAfter = ''
203+
204+
const prevNode = getTextSibling(node, true)
205+
const nextNode = getTextSibling(node, false)
151206

152207
const applied = new Set()
153208

154209
for (const transformer of textTransformers) {
155210
const format = transformer.format[0]
156211
const tag = transformer.tag
157212

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

164-
if (!hasFormat(previousNode, format)) {
165-
output = tag + output
218+
// append the tag to openningTags, if it's not applied to the previous nodes,
219+
// or the nodes before that (which would result in an unclosed tag)
220+
if (!hasFormat(prevNode, format) || !unclosedTags.find((element) => element.tag === tag)) {
221+
unclosedTags.push({ format, tag })
222+
openingTags += tag
166223
}
224+
}
225+
}
226+
227+
// close any tags in the same order they were applied, if necessary
228+
for (let i = 0; i < unclosedTags.length; i++) {
229+
const nodeHasFormat = hasFormat(node, unclosedTags[i].format)
230+
const nextNodeHasFormat = hasFormat(nextNode, unclosedTags[i].format)
167231

168-
// Prevent adding closing tag if next sibling will do it
169-
const nextNode = getTextSibling(node, false)
232+
// prevent adding closing tag if next sibling will do it
233+
if (nodeHasFormat && nextNodeHasFormat) {
234+
continue
235+
}
236+
237+
const unhandledUnclosedTags = [...unclosedTags] // Shallow copy to avoid modifying the original array
238+
239+
while (unhandledUnclosedTags.length > i) {
240+
const unclosedTag = unhandledUnclosedTags.pop()
241+
242+
// If tag is unclosable, don't close it and leave it in the original array,
243+
// So that it can be closed when it's no longer unclosable
244+
if (
245+
unclosableTags &&
246+
unclosedTag &&
247+
unclosableTags.find((element) => element.tag === unclosedTag.tag)
248+
) {
249+
continue
250+
}
170251

171-
if (!hasFormat(nextNode, format)) {
172-
output += tag
252+
if (unclosedTag && typeof unclosedTag.tag === 'string') {
253+
if (!nodeHasFormat) {
254+
// Handles cases where the tag has not been closed before, e.g. if the previous node
255+
// was a text match transformer that did not account for closing tags of the next node (e.g. a link)
256+
closingTagsBefore += unclosedTag.tag
257+
} else if (!nextNodeHasFormat) {
258+
closingTagsAfter += unclosedTag.tag
259+
}
173260
}
261+
// Mutate the original array to remove the closed tag
262+
unclosedTags.pop()
174263
}
264+
break
175265
}
176266

267+
output = openingTags + output + closingTagsAfter
177268
// Replace trimmed version of textContent ensuring surrounding whitespace is not modified
178-
return textContent.replace(frozenString, () => output)
269+
return closingTagsBefore + textContent.replace(frozenString, () => output)
179270
}
180271

181272
// Get next or previous text sibling a text node, including cases

0 commit comments

Comments
 (0)