@lexical/markdown 0.52.1-nightly.20261027.0 → 0.53.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -57,16 +57,342 @@ function isEmptyParagraph(node) {
57
57
  return firstChild == null || node.getChildrenSize() === 1 && $isTextNode(firstChild) && MARKDOWN_EMPTY_LINE_REG_EXP.test(firstChild.getTextContent());
58
58
  }
59
59
 
60
- // A decimal character reference has one to seven digits, and one that names
60
+ // Numeric references have one to seven decimal or one to six hexadecimal digits.
61
+ // A reference that names
61
62
  // U+0000, a surrogate or a number past U+10FFFF reads as U+FFFD instead of
62
63
  // making `String.fromCodePoint` throw.
63
64
  // https://spec.commonmark.org/0.31.2/#decimal-numeric-character
64
- function decodeDecimalCharacterReference(_, digits) {
65
- const codePoint = Number(digits);
65
+ function decodeCharacterReference(digits, hex) {
66
+ const codePoint = digits !== undefined ? Number(digits) : parseInt(hex, 16);
66
67
  return codePoint === 0 || codePoint >= 0xd800 && codePoint <= 0xdfff || codePoint > 0x10ffff ? '\uFFFD' : String.fromCodePoint(codePoint);
67
68
  }
68
69
  function unescapeText(value) {
69
- return value.replace(/\\([!-/:-@[-`{-~])/g, '$1').replace(/&#(\d{1,7});/g, decodeDecimalCharacterReference);
70
+ // Do not decode an escaped ampersand or revisit a decoded reference.
71
+ return value.replace(/\\([!-/:-@[-`{-~])|&#(?:(\d{1,7})|[xX]([\da-fA-F]{1,6}));/g, (_match, escaped, digits, hex) => escaped ?? decodeCharacterReference(digits, hex));
72
+ }
73
+ function isEscaped(text, index) {
74
+ let backslashes = 0;
75
+ for (let i = index - 1; i >= 0 && text[i] === '\\'; i--) {
76
+ backslashes++;
77
+ }
78
+ return backslashes % 2 === 1;
79
+ }
80
+
81
+ /**
82
+ * Copyright (c) Meta Platforms, Inc. and affiliates.
83
+ *
84
+ * This source code is licensed under the MIT license found in the
85
+ * LICENSE file in the root directory of this source tree.
86
+ *
87
+ */
88
+
89
+ function findOutermostTextFormatTransformer(textNode, textFormatTransformersIndex) {
90
+ const textContent = textNode.getTextContent();
91
+
92
+ // Find code spans first. Emphasis delimiters inside inline elements (e.g., code spans)
93
+ // should not be processed. Currently only code spans are handled; other inline elements
94
+ // (e.g., links, raw HTML) may need similar treatment in the future.
95
+ const codeTransformer = textFormatTransformersIndex.transformersByTag['`'];
96
+ const excludeRanges = [];
97
+ let codeMatch = null;
98
+ if (codeTransformer) {
99
+ const codeSpans = scanCodeSpans(textContent);
100
+ for (const span of codeSpans) {
101
+ if (!codeMatch) {
102
+ codeMatch = {
103
+ content: span.content,
104
+ endIndex: span.endIndex,
105
+ startIndex: span.startIndex,
106
+ tag: '`'
107
+ };
108
+ }
109
+ excludeRanges.push({
110
+ end: span.endIndex,
111
+ start: span.startIndex
112
+ });
113
+ }
114
+ }
115
+ const delimiters = scanDelimiters(textContent, textFormatTransformersIndex, excludeRanges);
116
+ const emphasisMatch = delimiters.length > 0 ? processEmphasis(textContent, delimiters, textFormatTransformersIndex) : null;
117
+ let resultMatch = null;
118
+ let resultTransformer = null;
119
+ if (codeMatch && emphasisMatch) {
120
+ if (emphasisMatch.startIndex <= codeMatch.startIndex && emphasisMatch.endIndex >= codeMatch.endIndex) {
121
+ resultMatch = emphasisMatch;
122
+ resultTransformer = textFormatTransformersIndex.transformersByTag[emphasisMatch.tag];
123
+ } else {
124
+ resultMatch = codeMatch;
125
+ resultTransformer = codeTransformer;
126
+ }
127
+ } else if (codeMatch) {
128
+ resultMatch = codeMatch;
129
+ resultTransformer = codeTransformer;
130
+ } else if (emphasisMatch) {
131
+ resultMatch = emphasisMatch;
132
+ resultTransformer = textFormatTransformersIndex.transformersByTag[emphasisMatch.tag];
133
+ }
134
+ if (!resultMatch || !resultTransformer) {
135
+ return null;
136
+ }
137
+ const regexMatch = [textContent.slice(resultMatch.startIndex, resultMatch.endIndex), resultMatch.tag, resultMatch.content];
138
+ regexMatch.index = resultMatch.startIndex;
139
+ regexMatch.input = textContent;
140
+ return {
141
+ endIndex: resultMatch.endIndex,
142
+ // resultTransformer is the registered code transformer (by identity)
143
+ // exactly when the chosen match is the code span.
144
+ isCodeSpan: resultTransformer === codeTransformer,
145
+ match: regexMatch,
146
+ startIndex: resultMatch.startIndex,
147
+ transformer: resultTransformer
148
+ };
149
+ }
150
+
151
+ // Finds all inline code spans, left to right and non-overlapping, per CommonMark
152
+ // rules: https://spec.commonmark.org/#code-spans. A run opens a span and the
153
+ // next run of equal length closes it. An escaped backtick (`\``) cannot open a
154
+ // span, but backslashes are otherwise literal and don't prevent closing.
155
+ function scanCodeSpans(text) {
156
+ const isEscaped = index => {
157
+ let count = 0;
158
+ for (let i = index - 1; i >= 0 && text[i] === '\\'; i--) {
159
+ count++;
160
+ }
161
+ return count % 2 === 1;
162
+ };
163
+
164
+ // Collect maximal backtick runs.
165
+ const runs = [];
166
+ let i = 0;
167
+ while (i < text.length) {
168
+ if (text[i] === '`') {
169
+ let length = 1;
170
+ while (i + length < text.length && text[i + length] === '`') {
171
+ length++;
172
+ }
173
+ runs.push({
174
+ index: i,
175
+ length
176
+ });
177
+ i += length;
178
+ } else {
179
+ i++;
180
+ }
181
+ }
182
+ const spans = [];
183
+ let openIdx = 0;
184
+ while (openIdx < runs.length) {
185
+ const opener = runs[openIdx];
186
+
187
+ // An escaped backtick run is a literal backtick and cannot open a span.
188
+ if (isEscaped(opener.index)) {
189
+ openIdx++;
190
+ continue;
191
+ }
192
+ let closeIdx = -1;
193
+ for (let c = openIdx + 1; c < runs.length; c++) {
194
+ if (runs[c].length === opener.length) {
195
+ closeIdx = c;
196
+ break;
197
+ }
198
+ }
199
+ if (closeIdx === -1) {
200
+ // No matching closer; treat this run as literal and try the next one.
201
+ openIdx++;
202
+ continue;
203
+ }
204
+ const closer = runs[closeIdx];
205
+ let content = text.slice(opener.index + opener.length, closer.index);
206
+ if (content.length >= 2 && content.startsWith(' ') && content.endsWith(' ') && /[^ ]/.test(content)) {
207
+ content = content.slice(1, -1);
208
+ }
209
+ spans.push({
210
+ content,
211
+ endIndex: closer.index + closer.length,
212
+ startIndex: opener.index
213
+ });
214
+ openIdx = closeIdx + 1;
215
+ }
216
+ return spans;
217
+ }
218
+ function scanDelimiters(text, transformersIndex, excludeRanges = []) {
219
+ const delimiters = [];
220
+ const delimiterChars = new Set(Object.keys(transformersIndex.transformersByTag).filter(tag => tag[0] !== '`').map(tag => tag[0]));
221
+ const isEscaped = index => {
222
+ let count = 0;
223
+ for (let i = index - 1; i >= 0 && text[i] === '\\'; i--) {
224
+ count++;
225
+ }
226
+ return count % 2 === 1;
227
+ };
228
+ const isInExcludedRange = index => {
229
+ return excludeRanges.some(range => index >= range.start && index < range.end);
230
+ };
231
+ let i = 0;
232
+ while (i < text.length) {
233
+ const char = text[i];
234
+ if (!delimiterChars.has(char) || isEscaped(i) || isInExcludedRange(i)) {
235
+ i++;
236
+ continue;
237
+ }
238
+ let len = 1;
239
+ while (i + len < text.length && text[i + len] === char) {
240
+ len++;
241
+ }
242
+ const canOpen = canEmphasis(char, text, i, len, true);
243
+ const canClose = canEmphasis(char, text, i, len, false);
244
+ if (canOpen || canClose) {
245
+ delimiters.push({
246
+ active: true,
247
+ canClose,
248
+ canOpen,
249
+ char,
250
+ index: i,
251
+ length: len
252
+ });
253
+ }
254
+ i += len;
255
+ }
256
+ return delimiters;
257
+ }
258
+ function processEmphasis(text, delimiters, transformersIndex) {
259
+ const openersBottom = {};
260
+ let currentPos = 0;
261
+ let result = null;
262
+ while (currentPos < delimiters.length) {
263
+ const closer = delimiters[currentPos];
264
+ if (!closer.active || !closer.canClose || closer.length === 0) {
265
+ currentPos++;
266
+ continue;
267
+ }
268
+
269
+ // The "no opener below this point" shortcut must be keyed by everything
270
+ // the opener search outcome depends on: the marker, whether the closer
271
+ // can also open (toggles the rule of 3), and the closer's length mod 3
272
+ // (the rule of 3 blocks different openers per class — CommonMark's
273
+ // openers_bottom is likewise per length-mod-3). The length key uses the
274
+ // *current* length: a partially consumed closer switches class and must
275
+ // rescan openers a shorter closer already gave up on (#4895).
276
+ const bottomKey = `${closer.char}${closer.canOpen}${closer.length % 3}`;
277
+ const bottom = openersBottom[bottomKey] ?? -1;
278
+ let foundOpener = false;
279
+ for (let openIdx = currentPos - 1; openIdx > bottom; openIdx--) {
280
+ const opener = delimiters[openIdx];
281
+ if (!opener.active || !opener.canOpen || opener.length === 0 || opener.char !== closer.char) {
282
+ continue;
283
+ }
284
+
285
+ // Rule of 3, on the *remaining* lengths: a delimiter run partially
286
+ // consumed by an earlier pairing is re-measured, matching micromark
287
+ // (the CommonMark reference this models). Using the original lengths
288
+ // instead would refuse pairings like the `**` opener with the two
289
+ // markers left of a `****` run in `**llo*wor****`, so the exporter's
290
+ // own output for overlapping formats would not re-import (#4895).
291
+ if (opener.canClose || closer.canOpen) {
292
+ const sum = opener.length + closer.length;
293
+ if (sum % 3 === 0 && opener.length % 3 !== 0 && closer.length % 3 !== 0) {
294
+ continue;
295
+ }
296
+ }
297
+ const maxLen = Math.min(opener.length, closer.length);
298
+ const matchedTag = Object.keys(transformersIndex.transformersByTag).filter(t => t[0] === opener.char && t.length <= maxLen).sort((a, b) => b.length - a.length)[0];
299
+ if (!matchedTag) {
300
+ continue;
301
+ }
302
+ foundOpener = true;
303
+ const matchLen = matchedTag.length;
304
+ const match = {
305
+ content: text.slice(opener.index + opener.length, closer.index),
306
+ endIndex: closer.index + matchLen,
307
+ startIndex: opener.index + (opener.length - matchLen),
308
+ tag: matchedTag
309
+ };
310
+ if (!result || match.startIndex < result.startIndex || match.startIndex === result.startIndex && match.endIndex > result.endIndex) {
311
+ result = match;
312
+ }
313
+ for (let j = openIdx + 1; j < currentPos; j++) {
314
+ delimiters[j].active = false;
315
+ }
316
+ opener.length -= matchLen;
317
+ closer.length -= matchLen;
318
+ opener.active = opener.length > 0;
319
+ if (closer.length > 0) {
320
+ closer.index += matchLen;
321
+ } else {
322
+ closer.active = false;
323
+ currentPos++;
324
+ }
325
+ break;
326
+ }
327
+ if (!foundOpener) {
328
+ openersBottom[bottomKey] = currentPos - 1;
329
+ if (!closer.canOpen) {
330
+ closer.active = false;
331
+ }
332
+ currentPos++;
333
+ }
334
+ }
335
+ return result;
336
+ }
337
+ function canEmphasis(char, text, index, length, isOpen) {
338
+ if (!isFlanking(text, index, length, isOpen)) {
339
+ return false;
340
+ }
341
+ if (char === '*') {
342
+ return true;
343
+ }
344
+ if (char === '_') {
345
+ if (!isFlanking(text, index, length, !isOpen)) {
346
+ return true;
347
+ }
348
+ const adjacentChar = isOpen ? text[index - 1] : text[index + length];
349
+ return adjacentChar !== undefined && PUNCTUATION.test(adjacentChar);
350
+ }
351
+ return true;
352
+ }
353
+ function isFlanking(text, index, length, isLeft) {
354
+ const charBefore = text[index - 1];
355
+ const charAfter = text[index + length];
356
+ const [primary, secondary] = isLeft ? [charAfter, charBefore] : [charBefore, charAfter];
357
+ if (primary === undefined || WHITESPACE.test(primary)) {
358
+ return false;
359
+ }
360
+ if (!PUNCTUATION.test(primary)) {
361
+ return true;
362
+ }
363
+ return secondary === undefined || WHITESPACE.test(secondary) || PUNCTUATION.test(secondary);
364
+ }
365
+ function importTextFormatTransformer(textNode, startIndex, endIndex, transformer, match) {
366
+ const textContent = textNode.getTextContent();
367
+
368
+ // No text matches - we can safely process the text format match
369
+ let transformedNode, nodeAfter, nodeBefore;
370
+
371
+ // If matching full content there's no need to run splitText and can reuse existing textNode
372
+ // to update its content and apply format. E.g. for **_Hello_** string after applying bold
373
+ // format (**) it will reuse the same text node to apply italic (_)
374
+ if (match[0] === textContent) {
375
+ transformedNode = textNode;
376
+ } else {
377
+ if (startIndex === 0) {
378
+ [transformedNode, nodeAfter] = textNode.splitText(endIndex);
379
+ } else {
380
+ [nodeBefore, transformedNode, nodeAfter] = textNode.splitText(startIndex, endIndex);
381
+ }
382
+ }
383
+ transformedNode.setTextContent(match[2]);
384
+ if (transformer) {
385
+ for (const format of transformer.format) {
386
+ if (!transformedNode.hasFormat(format)) {
387
+ transformedNode.toggleFormat(format);
388
+ }
389
+ }
390
+ }
391
+ return {
392
+ nodeAfter: nodeAfter,
393
+ nodeBefore: nodeBefore,
394
+ transformedNode: transformedNode
395
+ };
70
396
  }
71
397
 
72
398
  /**
@@ -834,6 +1160,42 @@ function escapeCharacterReferences(value) {
834
1160
  return value.replace(/&(?=#\d+;|#[Xx][\dA-Fa-f]+;|[A-Za-z][\dA-Za-z]*;)/g, '&#38;');
835
1161
  }
836
1162
 
1163
+ // Mask escaped/code brackets without changing source offsets, then find an inner
1164
+ // opener after a literal prefix. Return -1 for balanced labels or null when
1165
+ // unmatched closers leave no recoverable opener.
1166
+ function getLinkLabelOpener(text) {
1167
+ const codeSpans = scanCodeSpans(text);
1168
+ let codeIndex = 0;
1169
+ const brackets = text.replace(/\\.|[[\]]/g, (part, index) => {
1170
+ while (codeIndex < codeSpans.length && index >= codeSpans[codeIndex].endIndex) codeIndex++;
1171
+ return codeIndex < codeSpans.length && index >= codeSpans[codeIndex].startIndex || part[0] === '\\' ? ' '.repeat(part.length) : part;
1172
+ });
1173
+ const openers = [];
1174
+ let unmatchedCloser = false;
1175
+ for (let index = 0; index < brackets.length; index++) {
1176
+ const character = brackets[index];
1177
+ if (character === '[') {
1178
+ openers.push(index);
1179
+ } else if (character === ']') {
1180
+ if (openers.pop() === undefined) unmatchedCloser = true;
1181
+ }
1182
+ }
1183
+ return openers.pop() ?? (unmatchedCloser ? null : -1);
1184
+ }
1185
+ function unescapeLinkPrefix(text) {
1186
+ let output = '';
1187
+ let previous = 0;
1188
+ // Keep existing code-prefix source intact; only plain text has escapes.
1189
+ for (const {
1190
+ startIndex,
1191
+ endIndex
1192
+ } of scanCodeSpans(text)) {
1193
+ output += unescapeText(text.slice(previous, startIndex)) + text.slice(startIndex, endIndex);
1194
+ previous = endIndex;
1195
+ }
1196
+ return output + unescapeText(text.slice(previous));
1197
+ }
1198
+
837
1199
  // Order of text transformers matters:
838
1200
  //
839
1201
  // - code should go first as it prevents any transformations inside
@@ -907,7 +1269,9 @@ const LINK = {
907
1269
  regExp: /(?:\[([^[\]]*(?:\[[^[\]]*\][^[\]]*)*)\])(?:\(\s*(?:(?:<((?:\\.|[^<>\n\\])*)>|((?!<)(?:\\[^\s]|\\(?=\s)|\((?:\\[^\s]|[^\s()\\]|\((?:\\[^\s]|[^\s()\\]|\((?:\\[^\s]|[^\s()\\])*\))*\))*\)|[^\s()\\])+))(?:\s+(?:"((?:[^"]*\\")*[^"]*)"|'((?:[^']*\\')*[^']*)'|\(((?:\\.|[^()\\])*)\)))?\s*)?\))$/,
908
1270
  replace: (textNode, match) => {
909
1271
  // https://spec.commonmark.org/0.31.2/#inline-link
910
- if ($findMatchingParent(textNode, $isLinkNode)) {
1272
+ const source = match.input || '';
1273
+ const startIndex = match.index || 0;
1274
+ if ($findMatchingParent(textNode, $isLinkNode) || isEscaped(source, startIndex) || isEscaped(source, startIndex + match[1].length + 1)) {
911
1275
  return;
912
1276
  }
913
1277
  const [, linkText, pointyLinkUrl, rawLinkUrl, quotedTitle, apostrophedTitle, parenthesizedTitle] = match;
@@ -922,16 +1286,13 @@ const LINK = {
922
1286
  const linkNode = $createLinkNode(linkUrl, {
923
1287
  title: linkTitle
924
1288
  });
925
- const openBracketAmount = linkText.split('[').length - 1;
926
- const closeBracketAmount = linkText.split(']').length - 1;
1289
+ const openBracketIndex = getLinkLabelOpener(linkText);
1290
+ if (openBracketIndex === null) return;
927
1291
  let parsedLinkText = linkText;
928
1292
  let outsideLinkText = '';
929
- if (openBracketAmount < closeBracketAmount) {
930
- return;
931
- } else if (openBracketAmount > closeBracketAmount) {
932
- const linkTextParts = linkText.split('[');
933
- outsideLinkText = '[' + linkTextParts[0];
934
- parsedLinkText = linkTextParts.slice(1).join('[');
1293
+ if (openBracketIndex !== -1) {
1294
+ outsideLinkText = '[' + unescapeLinkPrefix(linkText.slice(0, openBracketIndex));
1295
+ parsedLinkText = linkText.slice(openBracketIndex + 1);
935
1296
  }
936
1297
  // Both new nodes stand in for the TextNode being replaced, so the text
937
1298
  // left outside the link carries its inline format just like the link's own
@@ -1286,7 +1647,10 @@ unclosedTags, unclosableTags, shouldPreserveNewLines = false) {
1286
1647
  let output = textContent;
1287
1648
  if (!isCode) {
1288
1649
  // Preserve literal backslashes when preserving source newlines.
1289
- output = shouldPreserveNewLines ? output.replace(/([*_`~])/g, '\\$1') : output.replace(/([*_`~\\])/g, '\\$1');
1650
+ // Default mode guards numeric references; named references keep legacy output.
1651
+ // Numeric escapes avoid changing emphasis flanking beside formatting.
1652
+ // Guard ambiguous '<' and '&' at node ends for constructs split across nodes.
1653
+ output = shouldPreserveNewLines ? output.replace(/([*_`~])/g, '\\$1') : output.replace(/([[\]*_`~\\]|<(?=\S|$)|&(?=#|$))/g, character => /[*_`~\\]/.test(character) ? `\\${character}` : `&#${character.charCodeAt(0)};`);
1290
1654
  }
1291
1655
  let leadingSpace;
1292
1656
  let trimmedOutput;
@@ -1299,439 +1663,122 @@ unclosedTags, unclosableTags, shouldPreserveNewLines = false) {
1299
1663
  fence,
1300
1664
  padded
1301
1665
  } = getCodeSpanDelimiter(textContent);
1302
- leadingSpace = '';
1303
- trailingSpace = '';
1304
- trimmedOutput = fence + padded + fence;
1305
- isWhitespaceOnly = false;
1306
- } else {
1307
- // Extract leading and trailing whitespaces.
1308
- // CommonMark flanking rules require formatting tags to be adjacent to non-whitespace characters.
1309
- const match = output.match(/^(\s*)(.*?)(\s*)$/s) || ['', '', output, ''];
1310
- leadingSpace = match[1];
1311
- trimmedOutput = match[2];
1312
- trailingSpace = match[3];
1313
- isWhitespaceOnly = trimmedOutput === '';
1314
- }
1315
-
1316
- // the opening tags to be added to the result
1317
- let openingTags = '';
1318
- // the closing tags to be added to the result
1319
- let closingTagsBefore = '';
1320
- let closingTagsAfter = '';
1321
- const prevNode = getTextSibling(node, true);
1322
- const nextNode = getTextSibling(node, false);
1323
- const applied = new Set();
1324
- for (const transformer of textTransformers) {
1325
- const format = transformer.format[0];
1326
- const tag = transformer.tag;
1327
-
1328
- // Inline code uses a content-derived fence handled above, not a static tag.
1329
- if (format === 'code') {
1330
- continue;
1331
- }
1332
-
1333
- // dedup applied formats
1334
- // `checkHasFormat` reads the node's whole text, but the text being
1335
- // exported may be a selection slice of it. When the slice is entirely
1336
- // whitespace the tags are dropped by the early return below, so
1337
- // registering them here would leave an unclosed tag for a later node to
1338
- // close. In the full-document path `checkHasFormat` already rejects
1339
- // whitespace-only nodes, so this guard only affects sliced content.
1340
- if (!isWhitespaceOnly && checkHasFormat(node, format) && !applied.has(format)) {
1341
- applied.add(format);
1342
-
1343
- // append the tag to openingTags, if it's not applied to the previous nodes,
1344
- // or the nodes before that (which would result in an unclosed tag)
1345
- if (!checkHasFormat(prevNode, format) || !unclosedTags.find(element => element.tag === tag)) {
1346
- unclosedTags.push({
1347
- format,
1348
- tag
1349
- });
1350
- openingTags += tag;
1351
- }
1352
- }
1353
- }
1354
-
1355
- // close any tags in the same order they were applied, if necessary
1356
- for (let i = 0; i < unclosedTags.length; i++) {
1357
- const nodeHasFormat = hasFormat(node, unclosedTags[i].format);
1358
- const nextNodeHasFormat = hasFormat(nextNode, unclosedTags[i].format);
1359
-
1360
- // prevent adding closing tag if next sibling will do it
1361
- if (nodeHasFormat && nextNodeHasFormat) {
1362
- continue;
1363
- }
1364
- const unhandledUnclosedTags = [...unclosedTags]; // Shallow copy to avoid modifying the original array
1365
-
1366
- while (unhandledUnclosedTags.length > i) {
1367
- const unclosedTag = unhandledUnclosedTags.pop();
1368
-
1369
- // If tag is unclosable, don't close it and leave it in the original array,
1370
- // So that it can be closed when it's no longer unclosable
1371
- if (unclosableTags && unclosedTag && unclosableTags.find(element => element.tag === unclosedTag.tag)) {
1372
- continue;
1373
- }
1374
- if (unclosedTag && typeof unclosedTag.tag === 'string') {
1375
- if (!nodeHasFormat) {
1376
- // Handles cases where the tag has not been closed before, e.g. if the previous node
1377
- // was a text match transformer that did not account for closing tags of the next node (e.g. a link)
1378
- closingTagsBefore += unclosedTag.tag;
1379
- } else if (!nextNodeHasFormat) {
1380
- closingTagsAfter += unclosedTag.tag;
1381
- }
1382
- }
1383
- // Mutate the original array to remove the closed tag
1384
- unclosedTags.pop();
1385
- }
1386
- break;
1387
- }
1388
- // If the node is entirely whitespace, we don't apply opening/closing tags around it.
1389
- // However, it must still output closing tags from previous nodes.
1390
- if (isWhitespaceOnly && !node.hasFormat('code')) {
1391
- return closingTagsBefore + output;
1392
- }
1393
-
1394
- // Flanking Compliance: Notice how openingTags and closingTagsAfter are placed INSIDE the whitespace boundaries!
1395
- return closingTagsBefore + leadingSpace + openingTags + trimmedOutput + closingTagsAfter + trailingSpace;
1396
- }
1397
- function getTextSibling(node, backward) {
1398
- const sibling = backward ? node.getPreviousSibling() : node.getNextSibling();
1399
- if ($isTextNode(sibling)) {
1400
- return sibling;
1401
- }
1402
- return null;
1403
- }
1404
- function hasFormat(node, format) {
1405
- return $isTextNode(node) && node.hasFormat(format);
1406
- }
1407
- function checkHasFormat(n, f) {
1408
- if (!hasFormat(n, f)) {
1409
- return false;
1410
- }
1411
- if (f === 'code') {
1412
- return true;
1413
- }
1414
- if (n && /^\s*$/.test(n.getTextContent())) {
1415
- return false;
1416
- }
1417
- return true;
1418
- }
1419
-
1420
- /**
1421
- * Copyright (c) Meta Platforms, Inc. and affiliates.
1422
- *
1423
- * This source code is licensed under the MIT license found in the
1424
- * LICENSE file in the root directory of this source tree.
1425
- *
1426
- */
1427
-
1428
- function findOutermostTextFormatTransformer(textNode, textFormatTransformersIndex) {
1429
- const textContent = textNode.getTextContent();
1430
-
1431
- // Find code spans first. Emphasis delimiters inside inline elements (e.g., code spans)
1432
- // should not be processed. Currently only code spans are handled; other inline elements
1433
- // (e.g., links, raw HTML) may need similar treatment in the future.
1434
- const codeTransformer = textFormatTransformersIndex.transformersByTag['`'];
1435
- const excludeRanges = [];
1436
- let codeMatch = null;
1437
- if (codeTransformer) {
1438
- const codeSpans = scanCodeSpans(textContent);
1439
- for (const span of codeSpans) {
1440
- if (!codeMatch) {
1441
- codeMatch = {
1442
- content: span.content,
1443
- endIndex: span.endIndex,
1444
- startIndex: span.startIndex,
1445
- tag: '`'
1446
- };
1447
- }
1448
- excludeRanges.push({
1449
- end: span.endIndex,
1450
- start: span.startIndex
1451
- });
1452
- }
1453
- }
1454
- const delimiters = scanDelimiters(textContent, textFormatTransformersIndex, excludeRanges);
1455
- const emphasisMatch = delimiters.length > 0 ? processEmphasis(textContent, delimiters, textFormatTransformersIndex) : null;
1456
- let resultMatch = null;
1457
- let resultTransformer = null;
1458
- if (codeMatch && emphasisMatch) {
1459
- if (emphasisMatch.startIndex <= codeMatch.startIndex && emphasisMatch.endIndex >= codeMatch.endIndex) {
1460
- resultMatch = emphasisMatch;
1461
- resultTransformer = textFormatTransformersIndex.transformersByTag[emphasisMatch.tag];
1462
- } else {
1463
- resultMatch = codeMatch;
1464
- resultTransformer = codeTransformer;
1465
- }
1466
- } else if (codeMatch) {
1467
- resultMatch = codeMatch;
1468
- resultTransformer = codeTransformer;
1469
- } else if (emphasisMatch) {
1470
- resultMatch = emphasisMatch;
1471
- resultTransformer = textFormatTransformersIndex.transformersByTag[emphasisMatch.tag];
1472
- }
1473
- if (!resultMatch || !resultTransformer) {
1474
- return null;
1475
- }
1476
- const regexMatch = [textContent.slice(resultMatch.startIndex, resultMatch.endIndex), resultMatch.tag, resultMatch.content];
1477
- regexMatch.index = resultMatch.startIndex;
1478
- regexMatch.input = textContent;
1479
- return {
1480
- endIndex: resultMatch.endIndex,
1481
- // resultTransformer is the registered code transformer (by identity)
1482
- // exactly when the chosen match is the code span.
1483
- isCodeSpan: resultTransformer === codeTransformer,
1484
- match: regexMatch,
1485
- startIndex: resultMatch.startIndex,
1486
- transformer: resultTransformer
1487
- };
1488
- }
1489
-
1490
- // Finds all inline code spans, left to right and non-overlapping, per CommonMark
1491
- // rules: https://spec.commonmark.org/#code-spans. A run opens a span and the
1492
- // next run of equal length closes it. An escaped backtick (`\``) cannot open a
1493
- // span, but backslashes are otherwise literal and don't prevent closing.
1494
- function scanCodeSpans(text) {
1495
- const isEscaped = index => {
1496
- let count = 0;
1497
- for (let i = index - 1; i >= 0 && text[i] === '\\'; i--) {
1498
- count++;
1499
- }
1500
- return count % 2 === 1;
1501
- };
1502
-
1503
- // Collect maximal backtick runs.
1504
- const runs = [];
1505
- let i = 0;
1506
- while (i < text.length) {
1507
- if (text[i] === '`') {
1508
- let length = 1;
1509
- while (i + length < text.length && text[i + length] === '`') {
1510
- length++;
1511
- }
1512
- runs.push({
1513
- index: i,
1514
- length
1515
- });
1516
- i += length;
1517
- } else {
1518
- i++;
1519
- }
1520
- }
1521
- const spans = [];
1522
- let openIdx = 0;
1523
- while (openIdx < runs.length) {
1524
- const opener = runs[openIdx];
1525
-
1526
- // An escaped backtick run is a literal backtick and cannot open a span.
1527
- if (isEscaped(opener.index)) {
1528
- openIdx++;
1529
- continue;
1530
- }
1531
- let closeIdx = -1;
1532
- for (let c = openIdx + 1; c < runs.length; c++) {
1533
- if (runs[c].length === opener.length) {
1534
- closeIdx = c;
1535
- break;
1536
- }
1537
- }
1538
- if (closeIdx === -1) {
1539
- // No matching closer; treat this run as literal and try the next one.
1540
- openIdx++;
1541
- continue;
1542
- }
1543
- const closer = runs[closeIdx];
1544
- let content = text.slice(opener.index + opener.length, closer.index);
1545
- if (content.length >= 2 && content.startsWith(' ') && content.endsWith(' ') && /[^ ]/.test(content)) {
1546
- content = content.slice(1, -1);
1547
- }
1548
- spans.push({
1549
- content,
1550
- endIndex: closer.index + closer.length,
1551
- startIndex: opener.index
1552
- });
1553
- openIdx = closeIdx + 1;
1666
+ leadingSpace = '';
1667
+ trailingSpace = '';
1668
+ trimmedOutput = fence + padded + fence;
1669
+ isWhitespaceOnly = false;
1670
+ } else {
1671
+ // Extract leading and trailing whitespaces.
1672
+ // CommonMark flanking rules require formatting tags to be adjacent to non-whitespace characters.
1673
+ const match = output.match(/^(\s*)(.*?)(\s*)$/s) || ['', '', output, ''];
1674
+ leadingSpace = match[1];
1675
+ trimmedOutput = match[2];
1676
+ trailingSpace = match[3];
1677
+ isWhitespaceOnly = trimmedOutput === '';
1554
1678
  }
1555
- return spans;
1556
- }
1557
- function scanDelimiters(text, transformersIndex, excludeRanges = []) {
1558
- const delimiters = [];
1559
- const delimiterChars = new Set(Object.keys(transformersIndex.transformersByTag).filter(tag => tag[0] !== '`').map(tag => tag[0]));
1560
- const isEscaped = index => {
1561
- let count = 0;
1562
- for (let i = index - 1; i >= 0 && text[i] === '\\'; i--) {
1563
- count++;
1564
- }
1565
- return count % 2 === 1;
1566
- };
1567
- const isInExcludedRange = index => {
1568
- return excludeRanges.some(range => index >= range.start && index < range.end);
1569
- };
1570
- let i = 0;
1571
- while (i < text.length) {
1572
- const char = text[i];
1573
- if (!delimiterChars.has(char) || isEscaped(i) || isInExcludedRange(i)) {
1574
- i++;
1679
+
1680
+ // the opening tags to be added to the result
1681
+ let openingTags = '';
1682
+ // the closing tags to be added to the result
1683
+ let closingTagsBefore = '';
1684
+ let closingTagsAfter = '';
1685
+ const prevNode = getTextSibling(node, true);
1686
+ const nextNode = getTextSibling(node, false);
1687
+ const applied = new Set();
1688
+ for (const transformer of textTransformers) {
1689
+ const format = transformer.format[0];
1690
+ const tag = transformer.tag;
1691
+
1692
+ // Inline code uses a content-derived fence handled above, not a static tag.
1693
+ if (format === 'code') {
1575
1694
  continue;
1576
1695
  }
1577
- let len = 1;
1578
- while (i + len < text.length && text[i + len] === char) {
1579
- len++;
1580
- }
1581
- const canOpen = canEmphasis(char, text, i, len, true);
1582
- const canClose = canEmphasis(char, text, i, len, false);
1583
- if (canOpen || canClose) {
1584
- delimiters.push({
1585
- active: true,
1586
- canClose,
1587
- canOpen,
1588
- char,
1589
- index: i,
1590
- length: len
1591
- });
1696
+
1697
+ // dedup applied formats
1698
+ // `checkHasFormat` reads the node's whole text, but the text being
1699
+ // exported may be a selection slice of it. When the slice is entirely
1700
+ // whitespace the tags are dropped by the early return below, so
1701
+ // registering them here would leave an unclosed tag for a later node to
1702
+ // close. In the full-document path `checkHasFormat` already rejects
1703
+ // whitespace-only nodes, so this guard only affects sliced content.
1704
+ if (!isWhitespaceOnly && checkHasFormat(node, format) && !applied.has(format)) {
1705
+ applied.add(format);
1706
+
1707
+ // append the tag to openingTags, if it's not applied to the previous nodes,
1708
+ // or the nodes before that (which would result in an unclosed tag)
1709
+ if (!checkHasFormat(prevNode, format) || !unclosedTags.find(element => element.tag === tag)) {
1710
+ unclosedTags.push({
1711
+ format,
1712
+ tag
1713
+ });
1714
+ openingTags += tag;
1715
+ }
1592
1716
  }
1593
- i += len;
1594
1717
  }
1595
- return delimiters;
1596
- }
1597
- function processEmphasis(text, delimiters, transformersIndex) {
1598
- const openersBottom = {};
1599
- let currentPos = 0;
1600
- let result = null;
1601
- while (currentPos < delimiters.length) {
1602
- const closer = delimiters[currentPos];
1603
- if (!closer.active || !closer.canClose || closer.length === 0) {
1604
- currentPos++;
1718
+
1719
+ // close any tags in the same order they were applied, if necessary
1720
+ for (let i = 0; i < unclosedTags.length; i++) {
1721
+ const nodeHasFormat = hasFormat(node, unclosedTags[i].format);
1722
+ const nextNodeHasFormat = hasFormat(nextNode, unclosedTags[i].format);
1723
+
1724
+ // prevent adding closing tag if next sibling will do it
1725
+ if (nodeHasFormat && nextNodeHasFormat) {
1605
1726
  continue;
1606
1727
  }
1728
+ const unhandledUnclosedTags = [...unclosedTags]; // Shallow copy to avoid modifying the original array
1607
1729
 
1608
- // The "no opener below this point" shortcut must be keyed by everything
1609
- // the opener search outcome depends on: the marker, whether the closer
1610
- // can also open (toggles the rule of 3), and the closer's length mod 3
1611
- // (the rule of 3 blocks different openers per class — CommonMark's
1612
- // openers_bottom is likewise per length-mod-3). The length key uses the
1613
- // *current* length: a partially consumed closer switches class and must
1614
- // rescan openers a shorter closer already gave up on (#4895).
1615
- const bottomKey = `${closer.char}${closer.canOpen}${closer.length % 3}`;
1616
- const bottom = openersBottom[bottomKey] ?? -1;
1617
- let foundOpener = false;
1618
- for (let openIdx = currentPos - 1; openIdx > bottom; openIdx--) {
1619
- const opener = delimiters[openIdx];
1620
- if (!opener.active || !opener.canOpen || opener.length === 0 || opener.char !== closer.char) {
1621
- continue;
1622
- }
1730
+ while (unhandledUnclosedTags.length > i) {
1731
+ const unclosedTag = unhandledUnclosedTags.pop();
1623
1732
 
1624
- // Rule of 3, on the *remaining* lengths: a delimiter run partially
1625
- // consumed by an earlier pairing is re-measured, matching micromark
1626
- // (the CommonMark reference this models). Using the original lengths
1627
- // instead would refuse pairings like the `**` opener with the two
1628
- // markers left of a `****` run in `**llo*wor****`, so the exporter's
1629
- // own output for overlapping formats would not re-import (#4895).
1630
- if (opener.canClose || closer.canOpen) {
1631
- const sum = opener.length + closer.length;
1632
- if (sum % 3 === 0 && opener.length % 3 !== 0 && closer.length % 3 !== 0) {
1633
- continue;
1634
- }
1635
- }
1636
- const maxLen = Math.min(opener.length, closer.length);
1637
- const matchedTag = Object.keys(transformersIndex.transformersByTag).filter(t => t[0] === opener.char && t.length <= maxLen).sort((a, b) => b.length - a.length)[0];
1638
- if (!matchedTag) {
1733
+ // If tag is unclosable, don't close it and leave it in the original array,
1734
+ // So that it can be closed when it's no longer unclosable
1735
+ if (unclosableTags && unclosedTag && unclosableTags.find(element => element.tag === unclosedTag.tag)) {
1639
1736
  continue;
1640
1737
  }
1641
- foundOpener = true;
1642
- const matchLen = matchedTag.length;
1643
- const match = {
1644
- content: text.slice(opener.index + opener.length, closer.index),
1645
- endIndex: closer.index + matchLen,
1646
- startIndex: opener.index + (opener.length - matchLen),
1647
- tag: matchedTag
1648
- };
1649
- if (!result || match.startIndex < result.startIndex || match.startIndex === result.startIndex && match.endIndex > result.endIndex) {
1650
- result = match;
1651
- }
1652
- for (let j = openIdx + 1; j < currentPos; j++) {
1653
- delimiters[j].active = false;
1654
- }
1655
- opener.length -= matchLen;
1656
- closer.length -= matchLen;
1657
- opener.active = opener.length > 0;
1658
- if (closer.length > 0) {
1659
- closer.index += matchLen;
1660
- } else {
1661
- closer.active = false;
1662
- currentPos++;
1663
- }
1664
- break;
1665
- }
1666
- if (!foundOpener) {
1667
- openersBottom[bottomKey] = currentPos - 1;
1668
- if (!closer.canOpen) {
1669
- closer.active = false;
1738
+ if (unclosedTag && typeof unclosedTag.tag === 'string') {
1739
+ if (!nodeHasFormat) {
1740
+ // Handles cases where the tag has not been closed before, e.g. if the previous node
1741
+ // was a text match transformer that did not account for closing tags of the next node (e.g. a link)
1742
+ closingTagsBefore += unclosedTag.tag;
1743
+ } else if (!nextNodeHasFormat) {
1744
+ closingTagsAfter += unclosedTag.tag;
1745
+ }
1670
1746
  }
1671
- currentPos++;
1747
+ // Mutate the original array to remove the closed tag
1748
+ unclosedTags.pop();
1672
1749
  }
1750
+ break;
1673
1751
  }
1674
- return result;
1675
- }
1676
- function canEmphasis(char, text, index, length, isOpen) {
1677
- if (!isFlanking(text, index, length, isOpen)) {
1678
- return false;
1679
- }
1680
- if (char === '*') {
1681
- return true;
1752
+ // If the node is entirely whitespace, we don't apply opening/closing tags around it.
1753
+ // However, it must still output closing tags from previous nodes.
1754
+ if (isWhitespaceOnly && !node.hasFormat('code')) {
1755
+ return closingTagsBefore + output;
1682
1756
  }
1683
- if (char === '_') {
1684
- if (!isFlanking(text, index, length, !isOpen)) {
1685
- return true;
1686
- }
1687
- const adjacentChar = isOpen ? text[index - 1] : text[index + length];
1688
- return adjacentChar !== undefined && PUNCTUATION.test(adjacentChar);
1757
+
1758
+ // Flanking Compliance: Notice how openingTags and closingTagsAfter are placed INSIDE the whitespace boundaries!
1759
+ return closingTagsBefore + leadingSpace + openingTags + trimmedOutput + closingTagsAfter + trailingSpace;
1760
+ }
1761
+ function getTextSibling(node, backward) {
1762
+ const sibling = backward ? node.getPreviousSibling() : node.getNextSibling();
1763
+ if ($isTextNode(sibling)) {
1764
+ return sibling;
1689
1765
  }
1690
- return true;
1766
+ return null;
1691
1767
  }
1692
- function isFlanking(text, index, length, isLeft) {
1693
- const charBefore = text[index - 1];
1694
- const charAfter = text[index + length];
1695
- const [primary, secondary] = isLeft ? [charAfter, charBefore] : [charBefore, charAfter];
1696
- if (primary === undefined || WHITESPACE.test(primary)) {
1768
+ function hasFormat(node, format) {
1769
+ return $isTextNode(node) && node.hasFormat(format);
1770
+ }
1771
+ function checkHasFormat(n, f) {
1772
+ if (!hasFormat(n, f)) {
1697
1773
  return false;
1698
1774
  }
1699
- if (!PUNCTUATION.test(primary)) {
1775
+ if (f === 'code') {
1700
1776
  return true;
1701
1777
  }
1702
- return secondary === undefined || WHITESPACE.test(secondary) || PUNCTUATION.test(secondary);
1703
- }
1704
- function importTextFormatTransformer(textNode, startIndex, endIndex, transformer, match) {
1705
- const textContent = textNode.getTextContent();
1706
-
1707
- // No text matches - we can safely process the text format match
1708
- let transformedNode, nodeAfter, nodeBefore;
1709
-
1710
- // If matching full content there's no need to run splitText and can reuse existing textNode
1711
- // to update its content and apply format. E.g. for **_Hello_** string after applying bold
1712
- // format (**) it will reuse the same text node to apply italic (_)
1713
- if (match[0] === textContent) {
1714
- transformedNode = textNode;
1715
- } else {
1716
- if (startIndex === 0) {
1717
- [transformedNode, nodeAfter] = textNode.splitText(endIndex);
1718
- } else {
1719
- [nodeBefore, transformedNode, nodeAfter] = textNode.splitText(startIndex, endIndex);
1720
- }
1721
- }
1722
- transformedNode.setTextContent(match[2]);
1723
- if (transformer) {
1724
- for (const format of transformer.format) {
1725
- if (!transformedNode.hasFormat(format)) {
1726
- transformedNode.toggleFormat(format);
1727
- }
1728
- }
1778
+ if (n && /^\s*$/.test(n.getTextContent())) {
1779
+ return false;
1729
1780
  }
1730
- return {
1731
- nodeAfter: nodeAfter,
1732
- nodeBefore: nodeBefore,
1733
- transformedNode: transformedNode
1734
- };
1781
+ return true;
1735
1782
  }
1736
1783
 
1737
1784
  /**
@@ -1742,6 +1789,15 @@ function importTextFormatTransformer(textNode, startIndex, endIndex, transformer
1742
1789
  *
1743
1790
  */
1744
1791
 
1792
+ function matchLink(text, regExp) {
1793
+ const search = new RegExp(regExp.source, regExp.flags.replace(/[gy]/g, '') + 'g');
1794
+ let match;
1795
+ while ((match = search.exec(text)) !== null) {
1796
+ if (!isEscaped(text, match.index)) return match;
1797
+ search.lastIndex = match.index + 1;
1798
+ }
1799
+ return null;
1800
+ }
1745
1801
  function findOutermostTextMatchTransformer(textNode_, textMatchTransformers) {
1746
1802
  const textNode = textNode_;
1747
1803
  let foundMatchStartIndex = undefined;
@@ -1752,7 +1808,9 @@ function findOutermostTextMatchTransformer(textNode_, textMatchTransformers) {
1752
1808
  if (!transformer.replace || !transformer.importRegExp) {
1753
1809
  continue;
1754
1810
  }
1755
- const match = textNode.getTextContent().match(transformer.importRegExp);
1811
+ const text = textNode.getTextContent();
1812
+ // Transformers sharing the built-in link syntax need the same candidate rules.
1813
+ const match = LINK.importRegExp !== undefined && transformer.importRegExp.source === LINK.importRegExp.source ? matchLink(text, transformer.importRegExp) : text.match(transformer.importRegExp);
1756
1814
  if (!match) {
1757
1815
  continue;
1758
1816
  }