@@ -38,7 +38,7 @@ export function createMarkdownExport(
38
38
)
39
39
40
40
return ( node ) => {
41
- const output : string [ ] = [ ]
41
+ const output = [ ]
42
42
const children = ( node || $getRoot ( ) ) . getChildren ( )
43
43
44
44
for ( let i = 0 ; i < children . length ; i ++ ) {
@@ -100,9 +100,18 @@ function exportChildren(
100
100
node : ElementNode ,
101
101
textTransformersIndex : Array < TextFormatTransformer > ,
102
102
textMatchTransformers : Array < TextMatchTransformer > ,
103
+ unclosedTags ?: Array < { format : TextFormatType ; tag : string } > ,
104
+ unclosableTags ?: Array < { format : TextFormatType ; tag : string } > ,
103
105
) : string {
104
- const output : string [ ] = [ ]
106
+ const output = [ ]
105
107
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
+ }
106
115
107
116
mainLoop: for ( const child of children ) {
108
117
for ( const transformer of textMatchTransformers ) {
@@ -112,8 +121,27 @@ function exportChildren(
112
121
113
122
const result = transformer . export (
114
123
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
+ ) ,
117
145
)
118
146
119
147
if ( result != null ) {
@@ -125,10 +153,26 @@ function exportChildren(
125
153
if ( $isLineBreakNode ( child ) ) {
126
154
output . push ( '\n' )
127
155
} 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
+ )
129
165
} else if ( $isElementNode ( child ) ) {
130
166
// 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
+ )
132
176
} else if ( $isDecoratorNode ( child ) ) {
133
177
output . push ( child . getTextContent ( ) )
134
178
}
@@ -141,41 +185,88 @@ function exportTextFormat(
141
185
node : TextNode ,
142
186
textContent : string ,
143
187
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 } > ,
144
191
) : string {
145
192
// This function handles the case of a string looking like this: " foo "
146
193
// Where it would be invalid markdown to generate: "** foo **"
147
194
// We instead want to trim the whitespace out, apply formatting, and then
148
195
// bring the whitespace back. So our returned string looks like this: " **foo** "
149
196
const frozenString = textContent . trim ( )
150
197
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 )
151
206
152
207
const applied = new Set ( )
153
208
154
209
for ( const transformer of textTransformers ) {
155
210
const format = transformer . format [ 0 ]
156
211
const tag = transformer . tag
157
212
213
+ // dedup applied formats
158
214
if ( hasFormat ( node , format ) && ! applied . has ( format ) ) {
159
215
// Multiple tags might be used for the same format (*, _)
160
216
applied . add ( format )
161
- // Prevent adding opening tag is already opened by the previous sibling
162
- const previousNode = getTextSibling ( node , true )
163
217
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
166
223
}
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 )
167
231
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
+ }
170
251
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
+ }
173
260
}
261
+ // Mutate the original array to remove the closed tag
262
+ unclosedTags . pop ( )
174
263
}
264
+ break
175
265
}
176
266
267
+ output = openingTags + output + closingTagsAfter
177
268
// Replace trimmed version of textContent ensuring surrounding whitespace is not modified
178
- return textContent . replace ( frozenString , ( ) => output )
269
+ return closingTagsBefore + textContent . replace ( frozenString , ( ) => output )
179
270
}
180
271
181
272
// Get next or previous text sibling a text node, including cases
0 commit comments