@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.
- package/README.md +10 -0
- package/dist/LexicalMarkdown.dev.js +484 -426
- package/dist/LexicalMarkdown.prod.js +1 -1
- package/dist/importTextFormatTransformer.d.ts +5 -0
- package/dist/utils.d.ts +1 -0
- package/package.json +10 -10
- package/src/MarkdownExport.ts +8 -1
- package/src/MarkdownTransformers.ts +59 -10
- package/src/importTextFormatTransformer.ts +1 -1
- package/src/importTextMatchTransformer.ts +23 -1
- package/src/utils.ts +25 -6
|
@@ -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
|
-
//
|
|
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
|
|
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
|
-
|
|
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, '&');
|
|
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
|
-
|
|
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
|
|
926
|
-
|
|
1289
|
+
const openBracketIndex = getLinkLabelOpener(linkText);
|
|
1290
|
+
if (openBracketIndex === null) return;
|
|
927
1291
|
let parsedLinkText = linkText;
|
|
928
1292
|
let outsideLinkText = '';
|
|
929
|
-
if (
|
|
930
|
-
|
|
931
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1556
|
-
|
|
1557
|
-
|
|
1558
|
-
|
|
1559
|
-
|
|
1560
|
-
|
|
1561
|
-
|
|
1562
|
-
|
|
1563
|
-
|
|
1564
|
-
|
|
1565
|
-
|
|
1566
|
-
|
|
1567
|
-
|
|
1568
|
-
|
|
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
|
-
|
|
1578
|
-
|
|
1579
|
-
|
|
1580
|
-
|
|
1581
|
-
|
|
1582
|
-
|
|
1583
|
-
|
|
1584
|
-
|
|
1585
|
-
|
|
1586
|
-
|
|
1587
|
-
|
|
1588
|
-
|
|
1589
|
-
|
|
1590
|
-
|
|
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
|
-
|
|
1596
|
-
|
|
1597
|
-
|
|
1598
|
-
|
|
1599
|
-
|
|
1600
|
-
|
|
1601
|
-
|
|
1602
|
-
|
|
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
|
-
|
|
1609
|
-
|
|
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
|
-
//
|
|
1625
|
-
//
|
|
1626
|
-
|
|
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
|
-
|
|
1642
|
-
|
|
1643
|
-
|
|
1644
|
-
|
|
1645
|
-
|
|
1646
|
-
|
|
1647
|
-
|
|
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
|
-
|
|
1747
|
+
// Mutate the original array to remove the closed tag
|
|
1748
|
+
unclosedTags.pop();
|
|
1672
1749
|
}
|
|
1750
|
+
break;
|
|
1673
1751
|
}
|
|
1674
|
-
|
|
1675
|
-
|
|
1676
|
-
|
|
1677
|
-
|
|
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
|
-
|
|
1684
|
-
|
|
1685
|
-
|
|
1686
|
-
|
|
1687
|
-
|
|
1688
|
-
|
|
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
|
|
1766
|
+
return null;
|
|
1691
1767
|
}
|
|
1692
|
-
function
|
|
1693
|
-
|
|
1694
|
-
|
|
1695
|
-
|
|
1696
|
-
if (
|
|
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 (
|
|
1775
|
+
if (f === 'code') {
|
|
1700
1776
|
return true;
|
|
1701
1777
|
}
|
|
1702
|
-
|
|
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
|
|
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
|
}
|