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.
@@ -0,0 +1,11 @@
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
+ 'use strict';
10
+
11
+ module.exports = require('./dist/OnchainLexicalMarkdown.js');
package/README.md ADDED
@@ -0,0 +1,96 @@
1
+ # `@lexical/markdown`
2
+
3
+ [![See API Documentation](https://lexical.dev/img/see-api-documentation.svg)](https://lexical.dev/docs/api/modules/lexical_markdown)
4
+
5
+ This package contains markdown helpers for Lexical: import, export and shortcuts.
6
+
7
+ ## Import and export
8
+ ```js
9
+ import {
10
+ $convertFromMarkdownString,
11
+ $convertToMarkdownString,
12
+ TRANSFORMERS,
13
+ } from '@lexical/markdown';
14
+
15
+ editor.update(() => {
16
+ const markdown = $convertToMarkdownString(TRANSFORMERS);
17
+ ...
18
+ });
19
+
20
+ editor.update(() => {
21
+ $convertFromMarkdownString(markdown, TRANSFORMERS);
22
+ });
23
+ ```
24
+
25
+ It can also be used for initializing editor's state from markdown string. Here's an example with react `<RichTextPlugin>`
26
+ ```jsx
27
+ <LexicalComposer initialConfig={{
28
+ editorState: () => $convertFromMarkdownString(markdown, TRANSFORMERS)
29
+ }}>
30
+ <RichTextPlugin />
31
+ </LexicalComposer>
32
+ ```
33
+
34
+ ## Shortcuts
35
+ Can use `<MarkdownShortcutPlugin>` if using React
36
+ ```jsx
37
+ import { TRANSFORMERS } from '@lexical/markdown';
38
+ import {MarkdownShortcutPlugin} from '@lexical/react/LexicalMarkdownShortcutPlugin';
39
+
40
+ <LexicalComposer>
41
+ <MarkdownShortcutPlugin transformers={TRANSFORMERS} />
42
+ </LexicalComposer>
43
+ ```
44
+
45
+ Or `registerMarkdownShortcuts` to register it manually:
46
+ ```js
47
+ import {
48
+ registerMarkdownShortcuts,
49
+ TRANSFORMERS,
50
+ } from '@lexical/markdown';
51
+
52
+ const editor = createEditor(...);
53
+ registerMarkdownShortcuts(editor, TRANSFORMERS);
54
+ ```
55
+
56
+ ## Transformers
57
+ Markdown functionality relies on transformers configuration. It's an array of objects that define how certain text or nodes
58
+ are processed during import, export or while typing. `@lexical/markdown` package provides set of built-in transformers:
59
+ ```js
60
+ // Element transformers
61
+ UNORDERED_LIST
62
+ CODE
63
+ HEADING
64
+ ORDERED_LIST
65
+ QUOTE
66
+
67
+ // Text format transformers
68
+ BOLD_ITALIC_STAR
69
+ BOLD_ITALIC_UNDERSCORE
70
+ BOLD_STAR
71
+ BOLD_UNDERSCORE
72
+ INLINE_CODE
73
+ ITALIC_STAR
74
+ ITALIC_UNDERSCORE
75
+ STRIKETHROUGH
76
+
77
+ // Text match transformers
78
+ LINK
79
+ ```
80
+
81
+ And bundles of commonly used transformers:
82
+ - `TRANSFORMERS` - all built-in transformers
83
+ - `ELEMENT_TRANSFORMERS` - all built-in element transformers
84
+ - `MULTILINE_ELEMENT_TRANSFORMERS` - all built-in multiline element transformers
85
+ - `TEXT_FORMAT_TRANSFORMERS` - all built-in text format transformers
86
+ - `TEXT_MATCH_TRANSFORMERS` - all built-in text match transformers
87
+
88
+ Transformers are explicitly passed to markdown API allowing application-specific subset of markdown or custom transformers.
89
+
90
+ There're three types of transformers:
91
+
92
+ - **Element transformer** handles top level elements (lists, headings, quotes, tables or code blocks)
93
+ - **Text format transformer** applies text range formats defined in `TextFormatType` (bold, italic, underline, strikethrough, code, subscript and superscript)
94
+ - **Text match transformer** relies on matching leaf text node content
95
+
96
+ See `MarkdownTransformers.js` for transformer implementation examples
@@ -0,0 +1,130 @@
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
+ * @flow strict
8
+ */
9
+
10
+ import type {
11
+ LexicalEditor,
12
+ LexicalNode,
13
+ ElementNode,
14
+ TextFormatType,
15
+ TextNode,
16
+ } from 'lexical';
17
+
18
+ export type Transformer =
19
+ | ElementTransformer
20
+ | MultilineElementTransformer
21
+ | TextFormatTransformer
22
+ | TextMatchTransformer;
23
+
24
+ export type ElementTransformer = {
25
+ dependencies: Array<Class<LexicalNode>>,
26
+ export: (
27
+ node: LexicalNode,
28
+ traverseChildren: (node: ElementNode) => string,
29
+ ) => string | null,
30
+ regExp: RegExp,
31
+ replace: (
32
+ parentNode: ElementNode,
33
+ children: Array<LexicalNode>,
34
+ match: Array<string>,
35
+ isImport: boolean,
36
+ ) => boolean | void,
37
+ type: 'element',
38
+ };
39
+
40
+ export type MultilineElementTransformer = {
41
+ dependencies: Array<Class<LexicalNode>>;
42
+ export?: (
43
+ node: LexicalNode,
44
+ traverseChildren: (node: ElementNode) => string,
45
+ ) => string | null;
46
+ regExpStart: RegExp;
47
+ regExpEnd?:
48
+ | RegExp
49
+ | {
50
+ optional?: true;
51
+ regExp: RegExp;
52
+ };
53
+ replace: (
54
+ rootNode: ElementNode,
55
+ children: Array<LexicalNode> | null,
56
+ startMatch: Array<string>,
57
+ endMatch: Array<string> | null,
58
+ linesInBetween: Array<string> | null,
59
+ isImport: boolean,
60
+ ) => boolean | void;
61
+ type: 'multiline-element';
62
+ };
63
+
64
+ export type TextFormatTransformer = $ReadOnly<{
65
+ format: $ReadOnlyArray<TextFormatType>,
66
+ tag: string,
67
+ intraword?: boolean,
68
+ type: 'text-format',
69
+ }>;
70
+
71
+ export type TextMatchTransformer = $ReadOnly<{
72
+ dependencies: Array<Class<LexicalNode>>,
73
+ export: (
74
+ node: LexicalNode,
75
+ exportChildren: (node: ElementNode) => string,
76
+ exportFormat: (node: TextNode, textContent: string) => string,
77
+ ) => string | null,
78
+ importRegExp: RegExp,
79
+ regExp: RegExp,
80
+ replace: (node: TextNode, match: RegExp$matchResult) => void,
81
+ trigger: string,
82
+ type: 'text-match',
83
+ }>;
84
+ // TODO:
85
+ // transformers should be required argument, breaking change
86
+ declare export function registerMarkdownShortcuts(
87
+ editor: LexicalEditor,
88
+ transformers?: Array<Transformer>,
89
+ ): () => void;
90
+
91
+ // TODO:
92
+ // transformers should be required argument, breaking change
93
+ declare export function $convertFromMarkdownString(
94
+ markdown: string,
95
+ transformers?: Array<Transformer>,
96
+ node?: ElementNode,
97
+ shouldPreserveNewLines?: boolean,
98
+ shouldMergeAdjacentLines?: boolean,
99
+ ): void;
100
+
101
+ // TODO:
102
+ // transformers should be required argument, breaking change
103
+ declare export function $convertToMarkdownString(
104
+ transformers?: Array<Transformer>,
105
+ node?: ElementNode,
106
+ shouldPreserveNewLines?: boolean,
107
+ ): string;
108
+
109
+ declare export var BOLD_ITALIC_STAR: TextFormatTransformer;
110
+ declare export var BOLD_ITALIC_UNDERSCORE: TextFormatTransformer;
111
+ declare export var BOLD_STAR: TextFormatTransformer;
112
+ declare export var BOLD_UNDERSCORE: TextFormatTransformer;
113
+ declare export var INLINE_CODE: TextFormatTransformer;
114
+ declare export var ITALIC_STAR: TextFormatTransformer;
115
+ declare export var ITALIC_UNDERSCORE: TextFormatTransformer;
116
+ declare export var STRIKETHROUGH: TextFormatTransformer;
117
+
118
+ declare export var UNORDERED_LIST: ElementTransformer;
119
+ declare export var CODE: MultilineElementTransformer;
120
+ declare export var HEADING: ElementTransformer;
121
+ declare export var ORDERED_LIST: ElementTransformer;
122
+ declare export var QUOTE: ElementTransformer;
123
+ declare export var CHECK_LIST: ElementTransformer;
124
+
125
+ declare export var LINK: TextMatchTransformer;
126
+
127
+ declare export var TRANSFORMERS: Array<Transformer>;
128
+ declare export var ELEMENT_TRANSFORMERS: Array<ElementTransformer>;
129
+ declare export var TEXT_FORMAT_TRANSFORMERS: Array<TextFormatTransformer>;
130
+ declare export var TEXT_MATCH_TRANSFORMERS: Array<TextFormatTransformer>;
package/package.json ADDED
@@ -0,0 +1,45 @@
1
+ {
2
+ "name": "onchain-lexical-markdown",
3
+ "description": "This package contains Markdown helpers and functionality for Lexical.",
4
+ "keywords": [
5
+ "lexical",
6
+ "editor",
7
+ "rich-text",
8
+ "markdown"
9
+ ],
10
+ "license": "MIT",
11
+ "version": "0.0.1",
12
+ "main": "OnchainLexicalMarkdown.js",
13
+ "types": "index.d.ts",
14
+ "dependencies": {
15
+ "@lexical/code": "0.30.0",
16
+ "@lexical/link": "0.30.0",
17
+ "@lexical/list": "0.30.0",
18
+ "@lexical/react": "^0.30.0",
19
+ "@lexical/rich-text": "0.30.0",
20
+ "@lexical/table": "^0.30.0",
21
+ "@lexical/text": "0.30.0",
22
+ "@lexical/utils": "0.30.0",
23
+ "lexical": "0.30.0",
24
+ "onchain-lexical-instance": "^0.0.1"
25
+ },
26
+ "repository": {
27
+ "type": "git",
28
+ "url": "https://github.com/facebook/lexical",
29
+ "directory": "packages/lexical-markdown"
30
+ },
31
+ "module": "OnchainLexicalMarkdown.mjs",
32
+ "sideEffects": false,
33
+ "exports": {
34
+ ".": {
35
+ "import": {
36
+ "types": "./index.d.ts",
37
+ "default": "./dist/OnchainLexicalMarkdown.mjs"
38
+ },
39
+ "require": {
40
+ "types": "./index.d.ts",
41
+ "default": "./dist/OnchainLexicalMarkdown.js"
42
+ }
43
+ }
44
+ }
45
+ }
@@ -0,0 +1,362 @@
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 {ElementNode, LexicalNode, TextFormatType, TextNode} from 'lexical';
10
+
11
+ import {
12
+ $getRoot,
13
+ $isDecoratorNode,
14
+ $isElementNode,
15
+ $isLineBreakNode,
16
+ $isTextNode,
17
+ } from 'lexical';
18
+ import {
19
+ $isBarDecoratorNode,
20
+ $isNumberDecoratorNode,
21
+ } from 'onchain-lexical-instance';
22
+
23
+ import {
24
+ ElementTransformer,
25
+ MultilineElementTransformer,
26
+ TextFormatTransformer,
27
+ TextMatchTransformer,
28
+ Transformer,
29
+ } from './MarkdownTransformers';
30
+ import {isEmptyParagraph, transformersByType} from './utils';
31
+
32
+ /**
33
+ * Renders string from markdown. The selection is moved to the start after the operation.
34
+ */
35
+ export function createMarkdownExport(
36
+ transformers: Array<Transformer>,
37
+ shouldPreserveNewLines: boolean = false,
38
+ ): (node?: ElementNode) => string {
39
+ const byType = transformersByType(transformers);
40
+ const elementTransformers = [...byType.multilineElement, ...byType.element];
41
+ const isNewlineDelimited = !shouldPreserveNewLines;
42
+
43
+ // Export only uses text formats that are responsible for single format
44
+ // e.g. it will filter out *** (bold, italic) and instead use separate ** and *
45
+ const textFormatTransformers = byType.textFormat
46
+ .filter((transformer) => transformer.format.length === 1)
47
+ // Make sure all text transformers that contain 'code' in their format are at the end of the array. Otherwise, formatted code like
48
+ // <strong><code>code</code></strong> will be exported as `**Bold Code**`, as the code format will be applied first, and the bold format
49
+ // will be applied second and thus skipped entirely, as the code format will prevent any further formatting.
50
+ .sort((a, b) => {
51
+ return (
52
+ Number(a.format.includes('code')) - Number(b.format.includes('code'))
53
+ );
54
+ });
55
+
56
+ return (node) => {
57
+ const output = [];
58
+ const children = (node || $getRoot()).getChildren();
59
+
60
+ for (let i = 0; i < children.length; i++) {
61
+ const child = children[i];
62
+ const result = exportTopLevelElements(
63
+ child,
64
+ elementTransformers,
65
+ textFormatTransformers,
66
+ byType.textMatch,
67
+ );
68
+ if (result != null) {
69
+ if (
70
+ [$isBarDecoratorNode, $isNumberDecoratorNode].every(
71
+ (fn) => !fn(child),
72
+ )
73
+ ) {
74
+ output.push(
75
+ // separate consecutive group of texts with a line break: eg. ["hello", "world"] -> ["hello", "/nworld"]
76
+ isNewlineDelimited &&
77
+ i > 0 &&
78
+ !isEmptyParagraph(child) &&
79
+ !isEmptyParagraph(children[i - 1])
80
+ ? '\n'.concat(result)
81
+ : result,
82
+ );
83
+ }
84
+ }
85
+ }
86
+ // Ensure consecutive groups of texts are at least \n\n apart while each empty paragraph render as a newline.
87
+ // Eg. ["hello", "", "", "hi", "\nworld"] -> "hello\n\n\nhi\n\nworld"
88
+ return output.join('\n');
89
+ };
90
+ }
91
+
92
+ function exportTopLevelElements(
93
+ node: LexicalNode,
94
+ elementTransformers: Array<ElementTransformer | MultilineElementTransformer>,
95
+ textTransformersIndex: Array<TextFormatTransformer>,
96
+ textMatchTransformers: Array<TextMatchTransformer>,
97
+ ): string | null {
98
+ for (const transformer of elementTransformers) {
99
+ if (!transformer.export) {
100
+ continue;
101
+ }
102
+ const result = transformer.export(node, (_node) =>
103
+ exportChildren(_node, textTransformersIndex, textMatchTransformers),
104
+ );
105
+
106
+ if (result != null) {
107
+ return result;
108
+ }
109
+ }
110
+
111
+ if ($isElementNode(node)) {
112
+ return exportChildren(node, textTransformersIndex, textMatchTransformers);
113
+ } else if ($isDecoratorNode(node)) {
114
+ return node.getTextContent();
115
+ } else {
116
+ return null;
117
+ }
118
+ }
119
+
120
+ function exportChildren(
121
+ node: ElementNode,
122
+ textTransformersIndex: Array<TextFormatTransformer>,
123
+ textMatchTransformers: Array<TextMatchTransformer>,
124
+ unclosedTags?: Array<{format: TextFormatType; tag: string}>,
125
+ unclosableTags?: Array<{format: TextFormatType; tag: string}>,
126
+ ): string {
127
+ const output = [];
128
+ const children = node.getChildren();
129
+ // keep track of unclosed tags from the very beginning
130
+ if (!unclosedTags) {
131
+ unclosedTags = [];
132
+ }
133
+ if (!unclosableTags) {
134
+ unclosableTags = [];
135
+ }
136
+
137
+ mainLoop: for (const child of children) {
138
+ for (const transformer of textMatchTransformers) {
139
+ if (!transformer.export) {
140
+ continue;
141
+ }
142
+
143
+ const result = transformer.export(
144
+ child,
145
+ (parentNode) =>
146
+ exportChildren(
147
+ parentNode,
148
+ textTransformersIndex,
149
+ textMatchTransformers,
150
+ unclosedTags,
151
+ // Add current unclosed tags to the list of unclosable tags - we don't want nested tags from
152
+ // textmatch transformers to close the outer ones, as that may result in invalid markdown.
153
+ // E.g. **text [text**](https://lexical.io)
154
+ // is invalid markdown, as the closing ** is inside the link.
155
+ //
156
+ [...unclosableTags, ...unclosedTags],
157
+ ),
158
+ (textNode, textContent) =>
159
+ exportTextFormat(
160
+ textNode,
161
+ textContent,
162
+ textTransformersIndex,
163
+ unclosedTags,
164
+ unclosableTags,
165
+ ),
166
+ );
167
+
168
+ if (result != null) {
169
+ output.push(result);
170
+ continue mainLoop;
171
+ }
172
+ }
173
+
174
+ if ($isLineBreakNode(child)) {
175
+ output.push('\n');
176
+ } else if ($isTextNode(child)) {
177
+ output.push(
178
+ exportTextFormat(
179
+ child,
180
+ child.getTextContent(),
181
+ textTransformersIndex,
182
+ unclosedTags,
183
+ unclosableTags,
184
+ ),
185
+ );
186
+ } else if ($isElementNode(child)) {
187
+ // empty paragraph returns ""
188
+ output.push(
189
+ exportChildren(
190
+ child,
191
+ textTransformersIndex,
192
+ textMatchTransformers,
193
+ unclosedTags,
194
+ unclosableTags,
195
+ ),
196
+ );
197
+ } else if ($isDecoratorNode(child)) {
198
+ output.push(child.getTextContent());
199
+ }
200
+ }
201
+
202
+ return output.join('');
203
+ }
204
+
205
+ function exportTextFormat(
206
+ node: TextNode,
207
+ textContent: string,
208
+ textTransformers: Array<TextFormatTransformer>,
209
+ // unclosed tags include the markdown tags that haven't been closed yet, and their associated formats
210
+ unclosedTags: Array<{format: TextFormatType; tag: string}>,
211
+ unclosableTags?: Array<{format: TextFormatType; tag: string}>,
212
+ ): string {
213
+ // This function handles the case of a string looking like this: " foo "
214
+ // Where it would be invalid markdown to generate: "** foo **"
215
+ // If the node has no format, we use the original text.
216
+ // Otherwise, we escape leading and trailing whitespaces to their corresponding code points,
217
+ // ensuring the returned string maintains its original formatting, e.g., "**&#32;&#32;&#32;foo&#32;&#32;&#32;**".
218
+ let output =
219
+ node.getFormat() === 0
220
+ ? textContent
221
+ : escapeLeadingAndTrailingWhitespaces(textContent);
222
+
223
+ if (!node.hasFormat('code')) {
224
+ // Escape any markdown characters in the text content
225
+ output = output.replace(/([*_`~\\])/g, '\\$1');
226
+ }
227
+
228
+ // the opening tags to be added to the result
229
+ let openingTags = '';
230
+ // the closing tags to be added to the result
231
+ let closingTagsBefore = '';
232
+ let closingTagsAfter = '';
233
+
234
+ const prevNode = getTextSibling(node, true);
235
+ const nextNode = getTextSibling(node, false);
236
+
237
+ const applied = new Set();
238
+
239
+ for (const transformer of textTransformers) {
240
+ const format = transformer.format[0];
241
+ const tag = transformer.tag;
242
+
243
+ // dedup applied formats
244
+ if (hasFormat(node, format) && !applied.has(format)) {
245
+ // Multiple tags might be used for the same format (*, _)
246
+ applied.add(format);
247
+
248
+ // append the tag to openingTags, if it's not applied to the previous nodes,
249
+ // or the nodes before that (which would result in an unclosed tag)
250
+ if (
251
+ !hasFormat(prevNode, format) ||
252
+ !unclosedTags.find((element) => element.tag === tag)
253
+ ) {
254
+ unclosedTags.push({format, tag});
255
+ openingTags += tag;
256
+ }
257
+ }
258
+ }
259
+
260
+ // close any tags in the same order they were applied, if necessary
261
+ for (let i = 0; i < unclosedTags.length; i++) {
262
+ const nodeHasFormat = hasFormat(node, unclosedTags[i].format);
263
+ const nextNodeHasFormat = hasFormat(nextNode, unclosedTags[i].format);
264
+
265
+ // prevent adding closing tag if next sibling will do it
266
+ if (nodeHasFormat && nextNodeHasFormat) {
267
+ continue;
268
+ }
269
+
270
+ const unhandledUnclosedTags = [...unclosedTags]; // Shallow copy to avoid modifying the original array
271
+
272
+ while (unhandledUnclosedTags.length > i) {
273
+ const unclosedTag = unhandledUnclosedTags.pop();
274
+
275
+ // If tag is unclosable, don't close it and leave it in the original array,
276
+ // So that it can be closed when it's no longer unclosable
277
+ if (
278
+ unclosableTags &&
279
+ unclosedTag &&
280
+ unclosableTags.find((element) => element.tag === unclosedTag.tag)
281
+ ) {
282
+ continue;
283
+ }
284
+
285
+ if (unclosedTag && typeof unclosedTag.tag === 'string') {
286
+ if (!nodeHasFormat) {
287
+ // Handles cases where the tag has not been closed before, e.g. if the previous node
288
+ // was a text match transformer that did not account for closing tags of the next node (e.g. a link)
289
+ closingTagsBefore += unclosedTag.tag;
290
+ } else if (!nextNodeHasFormat) {
291
+ closingTagsAfter += unclosedTag.tag;
292
+ }
293
+ }
294
+ // Mutate the original array to remove the closed tag
295
+ unclosedTags.pop();
296
+ }
297
+ break;
298
+ }
299
+
300
+ output = openingTags + output + closingTagsAfter;
301
+ // Replace trimmed version of textContent ensuring surrounding whitespace is not modified
302
+ return closingTagsBefore + output;
303
+ }
304
+
305
+ // Get next or previous text sibling a text node, including cases
306
+ // when it's a child of inline element (e.g. link)
307
+ function getTextSibling(node: TextNode, backward: boolean): TextNode | null {
308
+ let sibling = backward ? node.getPreviousSibling() : node.getNextSibling();
309
+
310
+ if (!sibling) {
311
+ const parent = node.getParentOrThrow();
312
+
313
+ if (parent.isInline()) {
314
+ sibling = backward
315
+ ? parent.getPreviousSibling()
316
+ : parent.getNextSibling();
317
+ }
318
+ }
319
+
320
+ while (sibling) {
321
+ if ($isElementNode(sibling)) {
322
+ if (!sibling.isInline()) {
323
+ break;
324
+ }
325
+
326
+ const descendant = backward
327
+ ? sibling.getLastDescendant()
328
+ : sibling.getFirstDescendant();
329
+
330
+ if ($isTextNode(descendant)) {
331
+ return descendant;
332
+ } else {
333
+ sibling = backward
334
+ ? sibling.getPreviousSibling()
335
+ : sibling.getNextSibling();
336
+ }
337
+ }
338
+
339
+ if ($isTextNode(sibling)) {
340
+ return sibling;
341
+ }
342
+
343
+ if (!$isElementNode(sibling)) {
344
+ return null;
345
+ }
346
+ }
347
+
348
+ return null;
349
+ }
350
+
351
+ function hasFormat(
352
+ node: LexicalNode | null | undefined,
353
+ format: TextFormatType,
354
+ ): boolean {
355
+ return $isTextNode(node) && node.hasFormat(format);
356
+ }
357
+
358
+ function escapeLeadingAndTrailingWhitespaces(textContent: string) {
359
+ return textContent.replace(/^\s+|\s+$/g, (match) => {
360
+ return [...match].map((char) => '&#' + char.codePointAt(0) + ';').join('');
361
+ });
362
+ }