onchain-lexical-markdown 0.0.1
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/OnchainLexicalMarkdown.js +11 -0
- package/README.md +96 -0
- package/flow/OnchainLexicalMarkdown.js.flow +130 -0
- package/package.json +45 -0
- package/src/MarkdownExport.ts +362 -0
- package/src/MarkdownImport.ts +343 -0
- package/src/MarkdownShortcuts.ts +529 -0
- package/src/MarkdownTransformers.ts +631 -0
- package/src/__tests__/unit/LexicalMarkdown.test.ts +891 -0
- package/src/fromMarkdownString.ts +39 -0
- package/src/importTextFormatTransformer.ts +137 -0
- package/src/importTextMatchTransformer.ts +108 -0
- package/src/importTextTransformers.ts +142 -0
- package/src/index.ts +81 -0
- package/src/toMarkdownString.ts +25 -0
- package/src/transformer/const.ts +12 -0
- package/src/transformer/hr.ts +37 -0
- package/src/transformer/index.ts +97 -0
- package/src/transformer/instance.ts +73 -0
- package/src/transformer/levelBasedControl.ts +95 -0
- package/src/transformer/table.ts +182 -0
- package/src/transformer/utils.ts +21 -0
- package/src/utils.ts +462 -0
|
@@ -0,0 +1,631 @@
|
|
|
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 {ListType} from '@lexical/list';
|
|
10
|
+
import type {HeadingTagType} from '@lexical/rich-text';
|
|
11
|
+
|
|
12
|
+
import {$isCodeNode, CodeNode} from '@lexical/code';
|
|
13
|
+
import {$createLinkNode, $isLinkNode, LinkNode} from '@lexical/link';
|
|
14
|
+
import {
|
|
15
|
+
$isListItemNode,
|
|
16
|
+
$isListNode,
|
|
17
|
+
ListItemNode,
|
|
18
|
+
ListNode,
|
|
19
|
+
} from '@lexical/list';
|
|
20
|
+
import {
|
|
21
|
+
$isHeadingNode,
|
|
22
|
+
$isQuoteNode,
|
|
23
|
+
HeadingNode,
|
|
24
|
+
QuoteNode,
|
|
25
|
+
} from '@lexical/rich-text';
|
|
26
|
+
import {
|
|
27
|
+
$createLineBreakNode,
|
|
28
|
+
$createTextNode,
|
|
29
|
+
ElementNode,
|
|
30
|
+
Klass,
|
|
31
|
+
LexicalNode,
|
|
32
|
+
TextFormatType,
|
|
33
|
+
TextNode,
|
|
34
|
+
} from 'lexical';
|
|
35
|
+
import {
|
|
36
|
+
$createInstanceCodeNode,
|
|
37
|
+
$createInstanceHeadingNode,
|
|
38
|
+
$createInstanceListItemNode,
|
|
39
|
+
$createInstanceListNode,
|
|
40
|
+
$createInstanceQuoteNode,
|
|
41
|
+
} from 'onchain-lexical-instance';
|
|
42
|
+
|
|
43
|
+
export type Transformer =
|
|
44
|
+
| ElementTransformer
|
|
45
|
+
| MultilineElementTransformer
|
|
46
|
+
| TextFormatTransformer
|
|
47
|
+
| TextMatchTransformer;
|
|
48
|
+
|
|
49
|
+
export type ElementTransformer = {
|
|
50
|
+
dependencies: Array<Klass<LexicalNode>>;
|
|
51
|
+
/**
|
|
52
|
+
* `export` is called when the `$convertToMarkdownString` is called to convert the editor state into markdown.
|
|
53
|
+
*
|
|
54
|
+
* @return return null to cancel the export, even though the regex matched. Lexical will then search for the next transformer.
|
|
55
|
+
*/
|
|
56
|
+
export: (
|
|
57
|
+
node: LexicalNode,
|
|
58
|
+
// eslint-disable-next-line no-shadow
|
|
59
|
+
traverseChildren: (node: ElementNode) => string,
|
|
60
|
+
) => string | null;
|
|
61
|
+
regExp: RegExp;
|
|
62
|
+
/**
|
|
63
|
+
* `replace` is called when markdown is imported or typed in the editor
|
|
64
|
+
*
|
|
65
|
+
* @return return false to cancel the transform, even though the regex matched. Lexical will then search for the next transformer.
|
|
66
|
+
*/
|
|
67
|
+
replace: (
|
|
68
|
+
parentNode: ElementNode,
|
|
69
|
+
children: Array<LexicalNode>,
|
|
70
|
+
match: Array<string>,
|
|
71
|
+
/**
|
|
72
|
+
* Whether the match is from an import operation (e.g. through `$convertFromMarkdownString`) or not (e.g. through typing in the editor).
|
|
73
|
+
*/
|
|
74
|
+
isImport: boolean,
|
|
75
|
+
) => boolean | void;
|
|
76
|
+
type: 'element';
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
export type MultilineElementTransformer = {
|
|
80
|
+
/**
|
|
81
|
+
* Use this function to manually handle the import process, once the `regExpStart` has matched successfully.
|
|
82
|
+
* Without providing this function, the default behavior is to match until `regExpEnd` is found, or until the end of the document if `regExpEnd.optional` is true.
|
|
83
|
+
*
|
|
84
|
+
* @returns a tuple or null. The 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. If null is returned, the next multilineElementTransformer will be tried. If undefined is returned, the default behavior will be used.
|
|
85
|
+
*/
|
|
86
|
+
handleImportAfterStartMatch?: (args: {
|
|
87
|
+
lines: Array<string>;
|
|
88
|
+
rootNode: ElementNode;
|
|
89
|
+
startLineIndex: number;
|
|
90
|
+
startMatch: RegExpMatchArray;
|
|
91
|
+
transformer: MultilineElementTransformer;
|
|
92
|
+
}) => [boolean, number] | null | undefined;
|
|
93
|
+
dependencies: Array<Klass<LexicalNode>>;
|
|
94
|
+
/**
|
|
95
|
+
* `export` is called when the `$convertToMarkdownString` is called to convert the editor state into markdown.
|
|
96
|
+
*
|
|
97
|
+
* @return return null to cancel the export, even though the regex matched. Lexical will then search for the next transformer.
|
|
98
|
+
*/
|
|
99
|
+
export?: (
|
|
100
|
+
node: LexicalNode,
|
|
101
|
+
// eslint-disable-next-line no-shadow
|
|
102
|
+
traverseChildren: (node: ElementNode) => string,
|
|
103
|
+
) => string | null;
|
|
104
|
+
/**
|
|
105
|
+
* This regex determines when to start matching
|
|
106
|
+
*/
|
|
107
|
+
regExpStart: RegExp;
|
|
108
|
+
/**
|
|
109
|
+
* This regex determines when to stop matching. Anything in between regExpStart and regExpEnd will be matched
|
|
110
|
+
*/
|
|
111
|
+
regExpEnd?:
|
|
112
|
+
| RegExp
|
|
113
|
+
| {
|
|
114
|
+
/**
|
|
115
|
+
* Whether the end match is optional. If true, the end match is not required to match for the transformer to be triggered.
|
|
116
|
+
* The entire text from regexpStart to the end of the document will then be matched.
|
|
117
|
+
*/
|
|
118
|
+
optional?: true;
|
|
119
|
+
regExp: RegExp;
|
|
120
|
+
};
|
|
121
|
+
/**
|
|
122
|
+
* `replace` is called only when markdown is imported in the editor, not when it's typed
|
|
123
|
+
*
|
|
124
|
+
* @return return false to cancel the transform, even though the regex matched. Lexical will then search for the next transformer.
|
|
125
|
+
*/
|
|
126
|
+
replace: (
|
|
127
|
+
rootNode: ElementNode,
|
|
128
|
+
/**
|
|
129
|
+
* During markdown shortcut transforms, children nodes may be provided to the transformer. If this is the case, no `linesInBetween` will be provided and
|
|
130
|
+
* the children nodes should be used instead of the `linesInBetween` to create the new node.
|
|
131
|
+
*/
|
|
132
|
+
children: Array<LexicalNode> | null,
|
|
133
|
+
startMatch: Array<string>,
|
|
134
|
+
endMatch: Array<string> | null,
|
|
135
|
+
/**
|
|
136
|
+
* linesInBetween includes the text between the start & end matches, split up by lines, not including the matches themselves.
|
|
137
|
+
* This is null when the transformer is triggered through markdown shortcuts (by typing in the editor)
|
|
138
|
+
*/
|
|
139
|
+
linesInBetween: Array<string> | null,
|
|
140
|
+
/**
|
|
141
|
+
* Whether the match is from an import operation (e.g. through `$convertFromMarkdownString`) or not (e.g. through typing in the editor).
|
|
142
|
+
*/
|
|
143
|
+
isImport: boolean,
|
|
144
|
+
) => boolean | void;
|
|
145
|
+
type: 'multiline-element';
|
|
146
|
+
};
|
|
147
|
+
|
|
148
|
+
export type TextFormatTransformer = Readonly<{
|
|
149
|
+
format: ReadonlyArray<TextFormatType>;
|
|
150
|
+
tag: string;
|
|
151
|
+
intraword?: boolean;
|
|
152
|
+
type: 'text-format';
|
|
153
|
+
}>;
|
|
154
|
+
|
|
155
|
+
export type TextMatchTransformer = Readonly<{
|
|
156
|
+
dependencies: Array<Klass<LexicalNode>>;
|
|
157
|
+
/**
|
|
158
|
+
* Determines how a node should be exported to markdown
|
|
159
|
+
*/
|
|
160
|
+
export?: (
|
|
161
|
+
node: LexicalNode,
|
|
162
|
+
// eslint-disable-next-line no-shadow
|
|
163
|
+
exportChildren: (node: ElementNode) => string,
|
|
164
|
+
// eslint-disable-next-line no-shadow
|
|
165
|
+
exportFormat: (node: TextNode, textContent: string) => string,
|
|
166
|
+
) => string | null;
|
|
167
|
+
/**
|
|
168
|
+
* This regex determines what text is matched during markdown imports
|
|
169
|
+
*/
|
|
170
|
+
importRegExp?: RegExp;
|
|
171
|
+
/**
|
|
172
|
+
* This regex determines what text is matched for markdown shortcuts while typing in the editor
|
|
173
|
+
*/
|
|
174
|
+
regExp: RegExp;
|
|
175
|
+
/**
|
|
176
|
+
* Determines how the matched markdown text should be transformed into a node during the markdown import process
|
|
177
|
+
*
|
|
178
|
+
* @returns nothing, or a TextNode that may be a child of the new node that is created.
|
|
179
|
+
* If a TextNode is returned, text format matching will be applied to it (e.g. bold, italic, etc.)
|
|
180
|
+
*/
|
|
181
|
+
replace?: (node: TextNode, match: RegExpMatchArray) => void | TextNode;
|
|
182
|
+
/**
|
|
183
|
+
* For import operations, this function can be used to determine the end index of the match, after `importRegExp` has matched.
|
|
184
|
+
* Without this function, the end index will be determined by the length of the match from `importRegExp`. Manually determining the end index can be useful if
|
|
185
|
+
* the match from `importRegExp` is not the entire text content of the node. That way, `importRegExp` can be used to match only the start of the node, and `getEndIndex`
|
|
186
|
+
* can be used to match the end of the node.
|
|
187
|
+
*
|
|
188
|
+
* @returns The end index of the match, or false if the match was unsuccessful and a different transformer should be tried.
|
|
189
|
+
*/
|
|
190
|
+
getEndIndex?: (node: TextNode, match: RegExpMatchArray) => number | false;
|
|
191
|
+
/**
|
|
192
|
+
* Single character that allows the transformer to trigger when typed in the editor. This does not affect markdown imports outside of the markdown shortcut plugin.
|
|
193
|
+
* If the trigger is matched, the `regExp` will be used to match the text in the second step.
|
|
194
|
+
*/
|
|
195
|
+
trigger?: string;
|
|
196
|
+
type: 'text-match';
|
|
197
|
+
}>;
|
|
198
|
+
|
|
199
|
+
const ORDERED_LIST_REGEX = /^(\s*)(\d{1,})\.\s/;
|
|
200
|
+
const UNORDERED_LIST_REGEX = /^(\s*)[-*+]\s/;
|
|
201
|
+
const CHECK_LIST_REGEX = /^(\s*)(?:-\s)?\s?(\[(\s|x)?\])\s/i;
|
|
202
|
+
const HEADING_REGEX = /^(#{1,6})\s/;
|
|
203
|
+
const QUOTE_REGEX = /^>\s/;
|
|
204
|
+
const CODE_START_REGEX = /^[ \t]*```(\w+)?/;
|
|
205
|
+
const CODE_END_REGEX = /[ \t]*```$/;
|
|
206
|
+
const CODE_SINGLE_LINE_REGEX =
|
|
207
|
+
/^[ \t]*```[^`]+(?:(?:`{1,2}|`{4,})[^`]+)*```(?:[^`]|$)/;
|
|
208
|
+
const TABLE_ROW_REG_EXP = /^(?:\|)(.+)(?:\|)\s?$/;
|
|
209
|
+
const TABLE_ROW_DIVIDER_REG_EXP = /^(\| ?:?-*:? ?)+\|\s?$/;
|
|
210
|
+
|
|
211
|
+
export const createBlockNode = (
|
|
212
|
+
createNode: (match: Array<string>) => ElementNode,
|
|
213
|
+
): ElementTransformer['replace'] => {
|
|
214
|
+
return (parentNode, children, match) => {
|
|
215
|
+
const node = createNode(match);
|
|
216
|
+
node.append(...children);
|
|
217
|
+
parentNode.replace(node);
|
|
218
|
+
node.select(0, 0);
|
|
219
|
+
};
|
|
220
|
+
};
|
|
221
|
+
|
|
222
|
+
// Amount of spaces that define indentation level
|
|
223
|
+
// TODO: should be an option
|
|
224
|
+
const LIST_INDENT_SIZE = 4;
|
|
225
|
+
|
|
226
|
+
function getIndent(whitespaces: string): number {
|
|
227
|
+
const tabs = whitespaces.match(/\t/g);
|
|
228
|
+
const spaces = whitespaces.match(/ /g);
|
|
229
|
+
|
|
230
|
+
let indent = 0;
|
|
231
|
+
|
|
232
|
+
if (tabs) {
|
|
233
|
+
indent += tabs.length;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
if (spaces) {
|
|
237
|
+
indent += Math.floor(spaces.length / LIST_INDENT_SIZE);
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
return indent;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
const listReplace = (listType: ListType): ElementTransformer['replace'] => {
|
|
244
|
+
return (parentNode, children, match) => {
|
|
245
|
+
const previousNode = parentNode.getPreviousSibling();
|
|
246
|
+
const nextNode = parentNode.getNextSibling();
|
|
247
|
+
const listItem = $createInstanceListItemNode(
|
|
248
|
+
listType === 'check' ? match[3] === 'x' : undefined,
|
|
249
|
+
);
|
|
250
|
+
if ($isListNode(nextNode) && nextNode.getListType() === listType) {
|
|
251
|
+
const firstChild = nextNode.getFirstChild();
|
|
252
|
+
if (firstChild !== null) {
|
|
253
|
+
firstChild.insertBefore(listItem);
|
|
254
|
+
} else {
|
|
255
|
+
// should never happen, but let's handle gracefully, just in case.
|
|
256
|
+
nextNode.append(listItem);
|
|
257
|
+
}
|
|
258
|
+
parentNode.remove();
|
|
259
|
+
} else if (
|
|
260
|
+
$isListNode(previousNode) &&
|
|
261
|
+
previousNode.getListType() === listType
|
|
262
|
+
) {
|
|
263
|
+
previousNode.append(listItem);
|
|
264
|
+
parentNode.remove();
|
|
265
|
+
} else {
|
|
266
|
+
const list = $createInstanceListNode(
|
|
267
|
+
listType,
|
|
268
|
+
listType === 'number' ? Number(match[2]) : undefined,
|
|
269
|
+
);
|
|
270
|
+
list.append(listItem);
|
|
271
|
+
parentNode.replace(list);
|
|
272
|
+
}
|
|
273
|
+
listItem.append(...children);
|
|
274
|
+
listItem.select(0, 0);
|
|
275
|
+
const indent = getIndent(match[1]);
|
|
276
|
+
if (indent) {
|
|
277
|
+
listItem.setIndent(indent);
|
|
278
|
+
}
|
|
279
|
+
};
|
|
280
|
+
};
|
|
281
|
+
|
|
282
|
+
const listExport = (
|
|
283
|
+
listNode: ListNode,
|
|
284
|
+
exportChildren: (node: ElementNode) => string,
|
|
285
|
+
depth: number,
|
|
286
|
+
): string => {
|
|
287
|
+
const output = [];
|
|
288
|
+
const children = listNode.getChildren();
|
|
289
|
+
let index = 0;
|
|
290
|
+
for (const listItemNode of children) {
|
|
291
|
+
if ($isListItemNode(listItemNode)) {
|
|
292
|
+
if (listItemNode.getChildrenSize() === 1) {
|
|
293
|
+
const firstChild = listItemNode.getFirstChild();
|
|
294
|
+
if ($isListNode(firstChild)) {
|
|
295
|
+
output.push(listExport(firstChild, exportChildren, depth + 1));
|
|
296
|
+
continue;
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
const indent = ' '.repeat(depth * LIST_INDENT_SIZE);
|
|
300
|
+
const listType = listNode.getListType();
|
|
301
|
+
const prefix =
|
|
302
|
+
listType === 'number'
|
|
303
|
+
? `${listNode.getStart() + index}. `
|
|
304
|
+
: listType === 'check'
|
|
305
|
+
? `- [${listItemNode.getChecked() ? 'x' : ' '}] `
|
|
306
|
+
: '- ';
|
|
307
|
+
output.push(indent + prefix + exportChildren(listItemNode));
|
|
308
|
+
index++;
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
return output.join('\n');
|
|
313
|
+
};
|
|
314
|
+
|
|
315
|
+
export const HEADING: ElementTransformer = {
|
|
316
|
+
dependencies: [HeadingNode],
|
|
317
|
+
export: (node, exportChildren) => {
|
|
318
|
+
if (!$isHeadingNode(node)) {
|
|
319
|
+
return null;
|
|
320
|
+
}
|
|
321
|
+
const level = Number(node.getTag().slice(1));
|
|
322
|
+
return '#'.repeat(level) + ' ' + exportChildren(node);
|
|
323
|
+
},
|
|
324
|
+
regExp: HEADING_REGEX,
|
|
325
|
+
replace: createBlockNode((match) => {
|
|
326
|
+
const tag = ('h' + match[1].length) as HeadingTagType;
|
|
327
|
+
return $createInstanceHeadingNode(tag);
|
|
328
|
+
}),
|
|
329
|
+
type: 'element',
|
|
330
|
+
};
|
|
331
|
+
|
|
332
|
+
export const QUOTE: ElementTransformer = {
|
|
333
|
+
dependencies: [QuoteNode],
|
|
334
|
+
export: (node, exportChildren) => {
|
|
335
|
+
if (!$isQuoteNode(node)) {
|
|
336
|
+
return null;
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
const lines = exportChildren(node).split('\n');
|
|
340
|
+
const output = [];
|
|
341
|
+
for (const line of lines) {
|
|
342
|
+
output.push('> ' + line);
|
|
343
|
+
}
|
|
344
|
+
return output.join('\n');
|
|
345
|
+
},
|
|
346
|
+
regExp: QUOTE_REGEX,
|
|
347
|
+
replace: (parentNode, children, _match, isImport) => {
|
|
348
|
+
if (isImport) {
|
|
349
|
+
const previousNode = parentNode.getPreviousSibling();
|
|
350
|
+
if ($isQuoteNode(previousNode)) {
|
|
351
|
+
previousNode.splice(previousNode.getChildrenSize(), 0, [
|
|
352
|
+
$createLineBreakNode(),
|
|
353
|
+
...children,
|
|
354
|
+
]);
|
|
355
|
+
previousNode.select(0, 0);
|
|
356
|
+
parentNode.remove();
|
|
357
|
+
return;
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
const node = $createInstanceQuoteNode();
|
|
362
|
+
node.append(...children);
|
|
363
|
+
parentNode.replace(node);
|
|
364
|
+
node.select(0, 0);
|
|
365
|
+
},
|
|
366
|
+
type: 'element',
|
|
367
|
+
};
|
|
368
|
+
|
|
369
|
+
export const CODE: MultilineElementTransformer = {
|
|
370
|
+
dependencies: [CodeNode],
|
|
371
|
+
export: (node: LexicalNode) => {
|
|
372
|
+
if (!$isCodeNode(node)) {
|
|
373
|
+
return null;
|
|
374
|
+
}
|
|
375
|
+
const textContent = node.getTextContent();
|
|
376
|
+
return (
|
|
377
|
+
'```' +
|
|
378
|
+
(node.getLanguage() || '') +
|
|
379
|
+
(textContent ? '\n' + textContent : '') +
|
|
380
|
+
'\n' +
|
|
381
|
+
'```'
|
|
382
|
+
);
|
|
383
|
+
},
|
|
384
|
+
regExpEnd: {
|
|
385
|
+
optional: true,
|
|
386
|
+
regExp: CODE_END_REGEX,
|
|
387
|
+
},
|
|
388
|
+
regExpStart: CODE_START_REGEX,
|
|
389
|
+
replace: (
|
|
390
|
+
rootNode,
|
|
391
|
+
children,
|
|
392
|
+
startMatch,
|
|
393
|
+
endMatch,
|
|
394
|
+
linesInBetween,
|
|
395
|
+
isImport,
|
|
396
|
+
) => {
|
|
397
|
+
let codeBlockNode: CodeNode;
|
|
398
|
+
let code: string;
|
|
399
|
+
|
|
400
|
+
if (!children && linesInBetween) {
|
|
401
|
+
if (linesInBetween.length === 1) {
|
|
402
|
+
// Single-line code blocks
|
|
403
|
+
if (endMatch) {
|
|
404
|
+
// End match on same line. Example: ```markdown hello```. markdown should not be considered the language here.
|
|
405
|
+
codeBlockNode = $createInstanceCodeNode();
|
|
406
|
+
code = startMatch[1] + linesInBetween[0];
|
|
407
|
+
} else {
|
|
408
|
+
// No end match. We should assume the language is next to the backticks and that code will be typed on the next line in the future
|
|
409
|
+
codeBlockNode = $createInstanceCodeNode(startMatch[1]);
|
|
410
|
+
code = linesInBetween[0].startsWith(' ')
|
|
411
|
+
? linesInBetween[0].slice(1)
|
|
412
|
+
: linesInBetween[0];
|
|
413
|
+
}
|
|
414
|
+
} else {
|
|
415
|
+
// Treat multi-line code blocks as if they always have an end match
|
|
416
|
+
codeBlockNode = $createInstanceCodeNode(startMatch[1]);
|
|
417
|
+
|
|
418
|
+
if (linesInBetween[0].trim().length === 0) {
|
|
419
|
+
// Filter out all start and end lines that are length 0 until we find the first line with content
|
|
420
|
+
while (linesInBetween.length > 0 && !linesInBetween[0].length) {
|
|
421
|
+
linesInBetween.shift();
|
|
422
|
+
}
|
|
423
|
+
} else {
|
|
424
|
+
// The first line already has content => Remove the first space of the line if it exists
|
|
425
|
+
linesInBetween[0] = linesInBetween[0].startsWith(' ')
|
|
426
|
+
? linesInBetween[0].slice(1)
|
|
427
|
+
: linesInBetween[0];
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
// Filter out all end lines that are length 0 until we find the last line with content
|
|
431
|
+
while (
|
|
432
|
+
linesInBetween.length > 0 &&
|
|
433
|
+
!linesInBetween[linesInBetween.length - 1].length
|
|
434
|
+
) {
|
|
435
|
+
linesInBetween.pop();
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
code = linesInBetween.join('\n');
|
|
439
|
+
}
|
|
440
|
+
const textNode = $createTextNode(code);
|
|
441
|
+
codeBlockNode.append(textNode);
|
|
442
|
+
rootNode.append(codeBlockNode);
|
|
443
|
+
} else if (children) {
|
|
444
|
+
createBlockNode((match) => {
|
|
445
|
+
return $createInstanceCodeNode(match ? match[1] : undefined);
|
|
446
|
+
})(rootNode, children, startMatch, isImport);
|
|
447
|
+
}
|
|
448
|
+
},
|
|
449
|
+
type: 'multiline-element',
|
|
450
|
+
};
|
|
451
|
+
|
|
452
|
+
export const UNORDERED_LIST: ElementTransformer = {
|
|
453
|
+
dependencies: [ListNode, ListItemNode],
|
|
454
|
+
export: (node, exportChildren) => {
|
|
455
|
+
return $isListNode(node) ? listExport(node, exportChildren, 0) : null;
|
|
456
|
+
},
|
|
457
|
+
regExp: UNORDERED_LIST_REGEX,
|
|
458
|
+
replace: listReplace('bullet'),
|
|
459
|
+
type: 'element',
|
|
460
|
+
};
|
|
461
|
+
|
|
462
|
+
export const CHECK_LIST: ElementTransformer = {
|
|
463
|
+
dependencies: [ListNode, ListItemNode],
|
|
464
|
+
export: (node, exportChildren) => {
|
|
465
|
+
return $isListNode(node) ? listExport(node, exportChildren, 0) : null;
|
|
466
|
+
},
|
|
467
|
+
regExp: CHECK_LIST_REGEX,
|
|
468
|
+
replace: listReplace('check'),
|
|
469
|
+
type: 'element',
|
|
470
|
+
};
|
|
471
|
+
|
|
472
|
+
export const ORDERED_LIST: ElementTransformer = {
|
|
473
|
+
dependencies: [ListNode, ListItemNode],
|
|
474
|
+
export: (node, exportChildren) => {
|
|
475
|
+
return $isListNode(node) ? listExport(node, exportChildren, 0) : null;
|
|
476
|
+
},
|
|
477
|
+
regExp: ORDERED_LIST_REGEX,
|
|
478
|
+
replace: listReplace('number'),
|
|
479
|
+
type: 'element',
|
|
480
|
+
};
|
|
481
|
+
|
|
482
|
+
export const INLINE_CODE: TextFormatTransformer = {
|
|
483
|
+
format: ['code'],
|
|
484
|
+
tag: '`',
|
|
485
|
+
type: 'text-format',
|
|
486
|
+
};
|
|
487
|
+
|
|
488
|
+
export const HIGHLIGHT: TextFormatTransformer = {
|
|
489
|
+
format: ['highlight'],
|
|
490
|
+
tag: '==',
|
|
491
|
+
type: 'text-format',
|
|
492
|
+
};
|
|
493
|
+
|
|
494
|
+
export const BOLD_ITALIC_STAR: TextFormatTransformer = {
|
|
495
|
+
format: ['bold', 'italic'],
|
|
496
|
+
tag: '***',
|
|
497
|
+
type: 'text-format',
|
|
498
|
+
};
|
|
499
|
+
|
|
500
|
+
export const BOLD_ITALIC_UNDERSCORE: TextFormatTransformer = {
|
|
501
|
+
format: ['bold', 'italic'],
|
|
502
|
+
intraword: false,
|
|
503
|
+
tag: '___',
|
|
504
|
+
type: 'text-format',
|
|
505
|
+
};
|
|
506
|
+
|
|
507
|
+
export const BOLD_STAR: TextFormatTransformer = {
|
|
508
|
+
format: ['bold'],
|
|
509
|
+
tag: '**',
|
|
510
|
+
type: 'text-format',
|
|
511
|
+
};
|
|
512
|
+
|
|
513
|
+
export const BOLD_UNDERSCORE: TextFormatTransformer = {
|
|
514
|
+
format: ['bold'],
|
|
515
|
+
intraword: false,
|
|
516
|
+
tag: '__',
|
|
517
|
+
type: 'text-format',
|
|
518
|
+
};
|
|
519
|
+
|
|
520
|
+
export const STRIKETHROUGH: TextFormatTransformer = {
|
|
521
|
+
format: ['strikethrough'],
|
|
522
|
+
tag: '~~',
|
|
523
|
+
type: 'text-format',
|
|
524
|
+
};
|
|
525
|
+
|
|
526
|
+
export const ITALIC_STAR: TextFormatTransformer = {
|
|
527
|
+
format: ['italic'],
|
|
528
|
+
tag: '*',
|
|
529
|
+
type: 'text-format',
|
|
530
|
+
};
|
|
531
|
+
|
|
532
|
+
export const ITALIC_UNDERSCORE: TextFormatTransformer = {
|
|
533
|
+
format: ['italic'],
|
|
534
|
+
intraword: false,
|
|
535
|
+
tag: '_',
|
|
536
|
+
type: 'text-format',
|
|
537
|
+
};
|
|
538
|
+
|
|
539
|
+
// Order of text transformers matters:
|
|
540
|
+
//
|
|
541
|
+
// - code should go first as it prevents any transformations inside
|
|
542
|
+
// - then longer tags match (e.g. ** or __ should go before * or _)
|
|
543
|
+
export const LINK: TextMatchTransformer = {
|
|
544
|
+
dependencies: [LinkNode],
|
|
545
|
+
export: (node, exportChildren, exportFormat) => {
|
|
546
|
+
if (!$isLinkNode(node)) {
|
|
547
|
+
return null;
|
|
548
|
+
}
|
|
549
|
+
const title = node.getTitle();
|
|
550
|
+
|
|
551
|
+
const textContent = exportChildren(node);
|
|
552
|
+
|
|
553
|
+
const linkContent = title
|
|
554
|
+
? `[${textContent}](${node.getURL()} "${title}")`
|
|
555
|
+
: `[${textContent}](${node.getURL()})`;
|
|
556
|
+
|
|
557
|
+
return linkContent;
|
|
558
|
+
},
|
|
559
|
+
importRegExp:
|
|
560
|
+
/(?:\[([^[]+)\])(?:\((?:([^()\s]+)(?:\s"((?:[^"]*\\")*[^"]*)"\s*)?)\))/,
|
|
561
|
+
regExp:
|
|
562
|
+
/(?:\[([^[]+)\])(?:\((?:([^()\s]+)(?:\s"((?:[^"]*\\")*[^"]*)"\s*)?)\))$/,
|
|
563
|
+
replace: (textNode, match) => {
|
|
564
|
+
const [, linkText, linkUrl, linkTitle] = match;
|
|
565
|
+
const linkNode = $createLinkNode(linkUrl, {title: linkTitle});
|
|
566
|
+
const linkTextNode = $createTextNode(linkText);
|
|
567
|
+
linkTextNode.setFormat(textNode.getFormat());
|
|
568
|
+
linkNode.append(linkTextNode);
|
|
569
|
+
textNode.replace(linkNode);
|
|
570
|
+
|
|
571
|
+
return linkTextNode;
|
|
572
|
+
},
|
|
573
|
+
trigger: ')',
|
|
574
|
+
type: 'text-match',
|
|
575
|
+
};
|
|
576
|
+
|
|
577
|
+
export function normalizeMarkdown(
|
|
578
|
+
input: string,
|
|
579
|
+
shouldMergeAdjacentLines = false,
|
|
580
|
+
): string {
|
|
581
|
+
const lines = input.split('\n');
|
|
582
|
+
let inCodeBlock = false;
|
|
583
|
+
const sanitizedLines: string[] = [];
|
|
584
|
+
|
|
585
|
+
for (let i = 0; i < lines.length; i++) {
|
|
586
|
+
const line = lines[i];
|
|
587
|
+
const lastLine = sanitizedLines[sanitizedLines.length - 1];
|
|
588
|
+
|
|
589
|
+
// Code blocks of ```single line``` don't toggle the inCodeBlock flag
|
|
590
|
+
if (CODE_SINGLE_LINE_REGEX.test(line)) {
|
|
591
|
+
sanitizedLines.push(line);
|
|
592
|
+
continue;
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
// Detect the start or end of a code block
|
|
596
|
+
if (CODE_START_REGEX.test(line) || CODE_END_REGEX.test(line)) {
|
|
597
|
+
inCodeBlock = !inCodeBlock;
|
|
598
|
+
sanitizedLines.push(line);
|
|
599
|
+
continue;
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
// If we are inside a code block, keep the line unchanged
|
|
603
|
+
if (inCodeBlock) {
|
|
604
|
+
sanitizedLines.push(line);
|
|
605
|
+
continue;
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
// In markdown the concept of "empty paragraphs" does not exist.
|
|
609
|
+
// Blocks must be separated by an empty line. Non-empty adjacent lines must be merged.
|
|
610
|
+
if (
|
|
611
|
+
line === '' ||
|
|
612
|
+
lastLine === '' ||
|
|
613
|
+
!lastLine ||
|
|
614
|
+
HEADING_REGEX.test(lastLine) ||
|
|
615
|
+
HEADING_REGEX.test(line) ||
|
|
616
|
+
QUOTE_REGEX.test(line) ||
|
|
617
|
+
ORDERED_LIST_REGEX.test(line) ||
|
|
618
|
+
UNORDERED_LIST_REGEX.test(line) ||
|
|
619
|
+
CHECK_LIST_REGEX.test(line) ||
|
|
620
|
+
TABLE_ROW_REG_EXP.test(line) ||
|
|
621
|
+
TABLE_ROW_DIVIDER_REG_EXP.test(line) ||
|
|
622
|
+
!shouldMergeAdjacentLines
|
|
623
|
+
) {
|
|
624
|
+
sanitizedLines.push(line);
|
|
625
|
+
} else {
|
|
626
|
+
sanitizedLines[sanitizedLines.length - 1] = lastLine + line;
|
|
627
|
+
}
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
return sanitizedLines.join('\n');
|
|
631
|
+
}
|