@lexical/markdown 0.44.1-nightly.20260519.0 → 0.45.1-dev.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.
Files changed (29) hide show
  1. package/{LexicalMarkdown.dev.js → dist/LexicalMarkdown.dev.js} +1 -1
  2. package/{LexicalMarkdown.dev.mjs → dist/LexicalMarkdown.dev.mjs} +1 -1
  3. package/dist/LexicalMarkdown.prod.js +9 -0
  4. package/dist/LexicalMarkdown.prod.mjs +9 -0
  5. package/package.json +37 -22
  6. package/src/MarkdownExport.ts +627 -0
  7. package/src/MarkdownImport.ts +363 -0
  8. package/src/MarkdownShortcuts.ts +677 -0
  9. package/src/MarkdownTransformers.ts +962 -0
  10. package/src/importTextFormatTransformer.ts +389 -0
  11. package/src/importTextMatchTransformer.ts +110 -0
  12. package/src/importTextTransformers.ts +141 -0
  13. package/src/index.ts +138 -0
  14. package/src/utils.ts +472 -0
  15. package/LexicalMarkdown.prod.js +0 -9
  16. package/LexicalMarkdown.prod.mjs +0 -9
  17. /package/{LexicalMarkdown.js → dist/LexicalMarkdown.js} +0 -0
  18. /package/{LexicalMarkdown.js.flow → dist/LexicalMarkdown.js.flow} +0 -0
  19. /package/{LexicalMarkdown.mjs → dist/LexicalMarkdown.mjs} +0 -0
  20. /package/{LexicalMarkdown.node.mjs → dist/LexicalMarkdown.node.mjs} +0 -0
  21. /package/{MarkdownExport.d.ts → dist/MarkdownExport.d.ts} +0 -0
  22. /package/{MarkdownImport.d.ts → dist/MarkdownImport.d.ts} +0 -0
  23. /package/{MarkdownShortcuts.d.ts → dist/MarkdownShortcuts.d.ts} +0 -0
  24. /package/{MarkdownTransformers.d.ts → dist/MarkdownTransformers.d.ts} +0 -0
  25. /package/{importTextFormatTransformer.d.ts → dist/importTextFormatTransformer.d.ts} +0 -0
  26. /package/{importTextMatchTransformer.d.ts → dist/importTextMatchTransformer.d.ts} +0 -0
  27. /package/{importTextTransformers.d.ts → dist/importTextTransformers.d.ts} +0 -0
  28. /package/{index.d.ts → dist/index.d.ts} +0 -0
  29. /package/{utils.d.ts → dist/utils.d.ts} +0 -0
@@ -0,0 +1,363 @@
1
+ /**
2
+ * Copyright (c) Meta Platforms, Inc. and affiliates.
3
+ *
4
+ * This source code is licensed under the MIT license found in the
5
+ * LICENSE file in the root directory of this source tree.
6
+ *
7
+ */
8
+
9
+ import type {
10
+ ElementTransformer,
11
+ MultilineElementTransformer,
12
+ TextFormatTransformer,
13
+ TextMatchTransformer,
14
+ Transformer,
15
+ } from './MarkdownTransformers';
16
+
17
+ import {$isListItemNode, $isListNode, ListItemNode} from '@lexical/list';
18
+ import {$isQuoteNode} from '@lexical/rich-text';
19
+ import {$findMatchingParent} from '@lexical/utils';
20
+ import {
21
+ $createParagraphNode,
22
+ $createTabNode,
23
+ $createTextNode,
24
+ $getRoot,
25
+ $getSelection,
26
+ $isElementNode,
27
+ $isParagraphNode,
28
+ ElementNode,
29
+ TextNode,
30
+ } from 'lexical';
31
+
32
+ import {importTextTransformers} from './importTextTransformers';
33
+ import {$createMarkdownLineBreakNode} from './MarkdownTransformers';
34
+ import {isEmptyParagraph, transformersByType} from './utils';
35
+
36
+ export type TextFormatTransformersIndex = Readonly<{
37
+ fullMatchRegExpByTag: Readonly<Record<string, RegExp>>;
38
+ openTagsRegExp: RegExp;
39
+ transformersByTag: Readonly<Record<string, TextFormatTransformer>>;
40
+ }>;
41
+
42
+ /**
43
+ * Renders markdown from a string. The selection is moved to the start after the operation.
44
+ */
45
+ export function createMarkdownImport(
46
+ transformers: Array<Transformer>,
47
+ shouldPreserveNewLines = false,
48
+ ): (markdownString: string, node?: ElementNode) => void {
49
+ const byType = transformersByType(transformers);
50
+ const textFormatTransformersIndex = createTextFormatTransformersIndex(
51
+ byType.textFormat,
52
+ );
53
+
54
+ return (markdownString, node) => {
55
+ const lines = markdownString.split('\n');
56
+ const linesLength = lines.length;
57
+ const root = node || $getRoot();
58
+ root.clear();
59
+
60
+ for (let i = 0; i < linesLength; i++) {
61
+ const lineText = lines[i];
62
+
63
+ const [imported, shiftedIndex] = $importMultiline(
64
+ lines,
65
+ i,
66
+ byType.multilineElement,
67
+ root,
68
+ );
69
+
70
+ if (imported) {
71
+ // If a multiline markdown element was imported, we don't want to process the lines that were part of it anymore.
72
+ // There could be other sub-markdown elements (both multiline and normal ones) matching within this matched multiline element's children.
73
+ // However, it would be the responsibility of the matched multiline transformer to decide how it wants to handle them.
74
+ // We cannot handle those, as there is no way for us to know how to maintain the correct order of generated lexical nodes for possible children.
75
+ i = shiftedIndex; // Next loop will start from the line after the last line of the multiline element
76
+ continue;
77
+ }
78
+
79
+ $importBlocks(
80
+ lineText,
81
+ root,
82
+ byType.element,
83
+ textFormatTransformersIndex,
84
+ byType.textMatch,
85
+ shouldPreserveNewLines,
86
+ );
87
+ }
88
+
89
+ const children = root.getChildren();
90
+ for (const child of children) {
91
+ // By default, removing empty paragraphs as md does not really
92
+ // allow empty lines and uses them as delimiter.
93
+ // If you need empty lines set shouldPreserveNewLines = true.
94
+ if (
95
+ !shouldPreserveNewLines &&
96
+ isEmptyParagraph(child) &&
97
+ root.getChildrenSize() > 1
98
+ ) {
99
+ child.remove();
100
+ continue;
101
+ }
102
+ // Convert all '\t' into TabNode.
103
+ if ($isElementNode(child)) {
104
+ for (const textNode of child.getAllTextNodes()) {
105
+ $normalizeMarkdownTextNode(textNode);
106
+ }
107
+ }
108
+ }
109
+
110
+ if ($getSelection() !== null) {
111
+ root.selectStart();
112
+ }
113
+ };
114
+ }
115
+
116
+ /**
117
+ *
118
+ * @returns first element of the returned tuple is a boolean indicating if a multiline element was imported. The second element is the index of the last line that was processed.
119
+ */
120
+ function $importMultiline(
121
+ lines: Array<string>,
122
+ startLineIndex: number,
123
+ multilineElementTransformers: Array<MultilineElementTransformer>,
124
+ rootNode: ElementNode,
125
+ ): [boolean, number] {
126
+ for (const transformer of multilineElementTransformers) {
127
+ const {handleImportAfterStartMatch, regExpEnd, regExpStart, replace} =
128
+ transformer;
129
+
130
+ const startMatch = lines[startLineIndex].match(regExpStart);
131
+ if (!startMatch) {
132
+ continue; // Try next transformer
133
+ }
134
+
135
+ if (handleImportAfterStartMatch) {
136
+ const result = handleImportAfterStartMatch({
137
+ lines,
138
+ rootNode,
139
+ startLineIndex,
140
+ startMatch,
141
+ transformer,
142
+ });
143
+ if (result === null) {
144
+ continue;
145
+ } else if (result) {
146
+ return result;
147
+ }
148
+ }
149
+
150
+ const regexpEndRegex: RegExp | undefined =
151
+ typeof regExpEnd === 'object' && 'regExp' in regExpEnd
152
+ ? regExpEnd.regExp
153
+ : regExpEnd;
154
+
155
+ const isEndOptional =
156
+ regExpEnd && typeof regExpEnd === 'object' && 'optional' in regExpEnd
157
+ ? regExpEnd.optional
158
+ : !regExpEnd;
159
+
160
+ let endLineIndex = startLineIndex;
161
+ const linesLength = lines.length;
162
+
163
+ // check every single line for the closing match. It could also be on the same line as the opening match.
164
+ while (endLineIndex < linesLength) {
165
+ const endMatch = regexpEndRegex
166
+ ? lines[endLineIndex].match(regexpEndRegex)
167
+ : null;
168
+ if (!endMatch) {
169
+ if (
170
+ !isEndOptional ||
171
+ (isEndOptional && endLineIndex < linesLength - 1) // Optional end, but didn't reach the end of the document yet => continue searching for potential closing match
172
+ ) {
173
+ endLineIndex++;
174
+ continue; // Search next line for closing match
175
+ }
176
+ }
177
+
178
+ // Now, check if the closing match matched is the same as the opening match.
179
+ // If it is, we need to continue searching for the actual closing match.
180
+ if (
181
+ endMatch &&
182
+ startLineIndex === endLineIndex &&
183
+ endMatch.index === startMatch.index
184
+ ) {
185
+ endLineIndex++;
186
+ continue; // Search next line for closing match
187
+ }
188
+
189
+ // At this point, we have found the closing match. Next: calculate the lines in between open and closing match
190
+ // This should not include the matches themselves, and be split up by lines
191
+ const linesInBetween = [];
192
+
193
+ if (endMatch && startLineIndex === endLineIndex) {
194
+ linesInBetween.push(
195
+ lines[startLineIndex].slice(
196
+ startMatch[0].length,
197
+ -endMatch[0].length,
198
+ ),
199
+ );
200
+ } else {
201
+ for (let i = startLineIndex; i <= endLineIndex; i++) {
202
+ if (i === startLineIndex) {
203
+ const text = lines[i].slice(startMatch[0].length);
204
+ linesInBetween.push(text); // Also include empty text
205
+ } else if (i === endLineIndex && endMatch) {
206
+ const text = lines[i].slice(0, -endMatch[0].length);
207
+ linesInBetween.push(text); // Also include empty text
208
+ } else {
209
+ linesInBetween.push(lines[i]);
210
+ }
211
+ }
212
+ }
213
+
214
+ if (
215
+ replace(rootNode, null, startMatch, endMatch, linesInBetween, true) !==
216
+ false
217
+ ) {
218
+ // Return here. This $importMultiline function is run line by line and should only process a single multiline element at a time.
219
+ return [true, endLineIndex];
220
+ }
221
+
222
+ // The replace function returned false, despite finding the matching open and close tags => this transformer does not want to handle it.
223
+ // Thus, we continue letting the remaining transformers handle the passed lines of text from the beginning
224
+ break;
225
+ }
226
+ }
227
+
228
+ // No multiline transformer handled this line successfully
229
+ return [false, startLineIndex];
230
+ }
231
+
232
+ function $importBlocks(
233
+ lineText: string,
234
+ rootNode: ElementNode,
235
+ elementTransformers: Array<ElementTransformer>,
236
+ textFormatTransformersIndex: TextFormatTransformersIndex,
237
+ textMatchTransformers: Array<TextMatchTransformer>,
238
+ shouldPreserveNewLines: boolean,
239
+ ) {
240
+ const textNode = $createTextNode(lineText);
241
+ const elementNode = $createParagraphNode();
242
+ elementNode.append(textNode);
243
+ rootNode.append(elementNode);
244
+
245
+ for (const {regExp, replace} of elementTransformers) {
246
+ const match = lineText.match(regExp);
247
+
248
+ if (match) {
249
+ textNode.setTextContent(lineText.slice(match[0].length));
250
+ if (replace(elementNode, [textNode], match, true) !== false) {
251
+ break;
252
+ }
253
+ }
254
+ }
255
+
256
+ importTextTransformers(
257
+ textNode,
258
+ textFormatTransformersIndex,
259
+ textMatchTransformers,
260
+ );
261
+
262
+ // If no transformer found and we left with original paragraph node
263
+ // can check if its content can be appended to the previous node
264
+ // if it's a paragraph, quote or list
265
+ if (elementNode.isAttached() && lineText.length > 0) {
266
+ const previousNode = elementNode.getPreviousSibling();
267
+ if (
268
+ !shouldPreserveNewLines && // Only append if we're not preserving newlines
269
+ ($isParagraphNode(previousNode) ||
270
+ $isQuoteNode(previousNode) ||
271
+ $isListNode(previousNode))
272
+ ) {
273
+ let targetNode: typeof previousNode | ListItemNode | null = previousNode;
274
+
275
+ if ($isListNode(previousNode)) {
276
+ const lastDescendant = previousNode.getLastDescendant();
277
+ if (lastDescendant == null) {
278
+ targetNode = null;
279
+ } else {
280
+ targetNode = $findMatchingParent(lastDescendant, $isListItemNode);
281
+ }
282
+ }
283
+
284
+ if (targetNode != null && targetNode.getTextContentSize() > 0) {
285
+ targetNode.splice(targetNode.getChildrenSize(), 0, [
286
+ $createMarkdownLineBreakNode(targetNode),
287
+ ...elementNode.getChildren(),
288
+ ]);
289
+ elementNode.remove();
290
+ }
291
+ }
292
+ }
293
+ }
294
+
295
+ // Look in node for '\t' and create a TabNode for each occurrence.
296
+ function $normalizeMarkdownTextNode(textNode: TextNode): void {
297
+ const tabOffsets: Set<number> = new Set();
298
+ const text = textNode.getTextContent();
299
+ let index = text.indexOf('\t');
300
+
301
+ // Find all tab occurrences
302
+ while (index !== -1) {
303
+ tabOffsets.add(index);
304
+ tabOffsets.add(index + 1);
305
+ index = text.indexOf('\t', index + 1);
306
+ }
307
+
308
+ // Split node to isolate each tab then replace '\t' into TabNode
309
+ const splitNodes = textNode.splitText(...tabOffsets);
310
+ splitNodes.forEach(node => {
311
+ if (node.getTextContent() === '\t') {
312
+ node.replace($createTabNode());
313
+ }
314
+ });
315
+ }
316
+
317
+ function createTextFormatTransformersIndex(
318
+ textTransformers: Array<TextFormatTransformer>,
319
+ ): TextFormatTransformersIndex {
320
+ const transformersByTag: Record<string, TextFormatTransformer> = {};
321
+ const fullMatchRegExpByTag: Record<string, RegExp> = {};
322
+ const openTagsRegExp: string[] = [];
323
+
324
+ for (const transformer of textTransformers) {
325
+ const {tag} = transformer;
326
+ transformersByTag[tag] = transformer;
327
+ const tagRegExp = tag.replace(/(\*|\^|\+)/g, '\\$1');
328
+ openTagsRegExp.push(tagRegExp);
329
+
330
+ // Single-char tag (e.g. "*")
331
+ if (tag.length === 1) {
332
+ if (tag === '`') {
333
+ // Capture the preceding character in group 1 (empty string at start-of-string
334
+ // via the ^ branch) rather than using a negative lookbehind, which is not
335
+ // supported in Safari < 16.4. Consumers must add match[1].length to
336
+ // match.index to find the real start of the span (see importTextFormatTransformer.ts).
337
+ fullMatchRegExpByTag[tag] = new RegExp(
338
+ `(^|[^\\\\\`])(\`)((?:\\\\\`|[^\`])+?)(\`)(?!\`)`,
339
+ );
340
+ } else {
341
+ fullMatchRegExpByTag[tag] = new RegExp(
342
+ `(^|[^\\\\${tagRegExp}])(${tagRegExp})((\\\\${tagRegExp})?.*?[^${tagRegExp}\\s](\\\\${tagRegExp})?)(${tagRegExp})(?![\\\\${tagRegExp}])`,
343
+ );
344
+ }
345
+ } else {
346
+ // Multi-char tags (e.g. "**")
347
+ fullMatchRegExpByTag[tag] = new RegExp(
348
+ `(^|[^\\\\])(${tagRegExp})((\\\\${tagRegExp})?.*?[^\\s](\\\\${tagRegExp})?)(${tagRegExp})(?!\\\\)`,
349
+ );
350
+ }
351
+ }
352
+
353
+ return {
354
+ // Reg exp to find open tag + content + close tag
355
+ fullMatchRegExpByTag,
356
+
357
+ // Regexp to locate *any* potential opening tag (longest first).
358
+ // The former (?<!\\) escape guard has been removed — the delimiter
359
+ // scanner's isEscaped() check handles escape filtering at match time.
360
+ openTagsRegExp: new RegExp(`(${openTagsRegExp.join('|')})`, 'g'),
361
+ transformersByTag,
362
+ };
363
+ }