@lexical/mdast 0.0.0-bootstrap.0 → 0.47.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 +207 -2
- package/dist/LexicalMdast.dev.js +2620 -0
- package/dist/LexicalMdast.dev.mjs +2594 -0
- package/dist/LexicalMdast.js +11 -0
- package/dist/LexicalMdast.js.flow +228 -0
- package/dist/LexicalMdast.mjs +36 -0
- package/dist/LexicalMdast.node.mjs +34 -0
- package/dist/LexicalMdast.prod.js +11 -0
- package/dist/LexicalMdast.prod.mjs +11 -0
- package/dist/MdastExport.d.ts +20 -0
- package/dist/MdastExportExtension.d.ts +80 -0
- package/dist/MdastExtension.d.ts +21 -0
- package/dist/MdastGfmExtension.d.ts +20 -0
- package/dist/MdastImport.d.ts +48 -0
- package/dist/MdastImportExtension.d.ts +264 -0
- package/dist/MdastShortcuts.d.ts +27 -0
- package/dist/MdastStream.d.ts +70 -0
- package/dist/MdastTableExtension.d.ts +29 -0
- package/dist/compile.d.ts +18 -0
- package/dist/handlers.d.ts +84 -0
- package/dist/index.d.ts +15 -0
- package/dist/state.d.ts +58 -0
- package/dist/types.d.ts +165 -0
- package/dist/typescript-too-old.d.ts +18 -0
- package/package.json +90 -6
- package/src/MdastExport.ts +545 -0
- package/src/MdastExportExtension.ts +122 -0
- package/src/MdastExtension.ts +30 -0
- package/src/MdastGfmExtension.ts +38 -0
- package/src/MdastImport.ts +236 -0
- package/src/MdastImportExtension.ts +635 -0
- package/src/MdastShortcuts.ts +453 -0
- package/src/MdastStream.ts +187 -0
- package/src/MdastTableExtension.ts +144 -0
- package/src/compile.ts +44 -0
- package/src/handlers.ts +648 -0
- package/src/index.ts +69 -0
- package/src/state.ts +124 -0
- package/src/types.ts +197 -0
|
@@ -0,0 +1,545 @@
|
|
|
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 {CompiledMdast, MdastExportContext, MdastNode} from './types';
|
|
10
|
+
import type {BaseSelection, ElementNode, LexicalNode} from 'lexical';
|
|
11
|
+
import type {
|
|
12
|
+
BlockContent,
|
|
13
|
+
Break,
|
|
14
|
+
Code,
|
|
15
|
+
Heading,
|
|
16
|
+
Link,
|
|
17
|
+
List,
|
|
18
|
+
PhrasingContent,
|
|
19
|
+
Root,
|
|
20
|
+
RootContent,
|
|
21
|
+
ThematicBreak,
|
|
22
|
+
} from 'mdast';
|
|
23
|
+
import type {
|
|
24
|
+
Options as ToMarkdownExtension,
|
|
25
|
+
State,
|
|
26
|
+
} from 'mdast-util-to-markdown';
|
|
27
|
+
|
|
28
|
+
import {$sliceSelectedTextNodeContent} from '@lexical/selection';
|
|
29
|
+
import {
|
|
30
|
+
$getRoot,
|
|
31
|
+
$getSelection,
|
|
32
|
+
$getState,
|
|
33
|
+
$isElementNode,
|
|
34
|
+
$isLineBreakNode,
|
|
35
|
+
$isRangeSelection,
|
|
36
|
+
$isTextNode,
|
|
37
|
+
} from 'lexical';
|
|
38
|
+
import {defaultHandlers, toMarkdown} from 'mdast-util-to-markdown';
|
|
39
|
+
import {toString as mdastToString} from 'mdast-util-to-string';
|
|
40
|
+
|
|
41
|
+
import {
|
|
42
|
+
$exportLineBreak,
|
|
43
|
+
$isBlockLevelNode,
|
|
44
|
+
exportText,
|
|
45
|
+
phrasingFromFormattedText,
|
|
46
|
+
TEXT_FORMAT_MASK,
|
|
47
|
+
} from './handlers';
|
|
48
|
+
import {
|
|
49
|
+
emphasisMarkerState,
|
|
50
|
+
paragraphBreakState,
|
|
51
|
+
strongMarkerState,
|
|
52
|
+
} from './state';
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Runs `fn` with `state.options` temporarily overridden, restoring the
|
|
56
|
+
* previous values afterwards so a per-node override never leaks into the rest
|
|
57
|
+
* of the serialization.
|
|
58
|
+
*/
|
|
59
|
+
function withOptions<T>(
|
|
60
|
+
state: State,
|
|
61
|
+
overrides: State['options'],
|
|
62
|
+
fn: () => T,
|
|
63
|
+
): T {
|
|
64
|
+
const {options} = state;
|
|
65
|
+
const saved: State['options'] = {};
|
|
66
|
+
for (const key of Object.keys(overrides) as (keyof State['options'])[]) {
|
|
67
|
+
saved[key] = options[key] as never;
|
|
68
|
+
options[key] = overrides[key] as never;
|
|
69
|
+
}
|
|
70
|
+
try {
|
|
71
|
+
return fn();
|
|
72
|
+
} finally {
|
|
73
|
+
for (const key of Object.keys(saved) as (keyof State['options'])[]) {
|
|
74
|
+
options[key] = saved[key] as never;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* A to-markdown extension whose handlers reproduce the literal Markdown syntax
|
|
81
|
+
* captured on import (and stored on the Lexical nodes), by temporarily
|
|
82
|
+
* steering the default handlers with per-node options. Delegating to the
|
|
83
|
+
* defaults keeps all the indentation / nesting / disambiguation behavior
|
|
84
|
+
* intact while still honoring each node's original marker/fence. Nodes without
|
|
85
|
+
* captured syntax fall straight through to the defaults, so document-level
|
|
86
|
+
* options (and contributed `toMarkdownExtensions`) still apply to them.
|
|
87
|
+
*/
|
|
88
|
+
const SYNTAX_TO_MARKDOWN: ToMarkdownExtension = {
|
|
89
|
+
handlers: {
|
|
90
|
+
break(node: Break, parent, state, info) {
|
|
91
|
+
// Delegate first: the default handler substitutes a space when a real
|
|
92
|
+
// EOL is unsafe in the current construct (headings, table cells). Only
|
|
93
|
+
// when it chose the hard-break form does the preserved marker apply:
|
|
94
|
+
// trailing-space markers are reproduced, and the empty marker means the
|
|
95
|
+
// break is SOFT (a source newline or editor line break) and serializes
|
|
96
|
+
// as a plain newline rather than being upgraded to a hard break.
|
|
97
|
+
const result = defaultHandlers.break(node, parent, state, info);
|
|
98
|
+
if (result !== '\\\n') {
|
|
99
|
+
return result;
|
|
100
|
+
}
|
|
101
|
+
const marker = node.data && node.data.mdastBreak;
|
|
102
|
+
if (!marker) {
|
|
103
|
+
return '\n';
|
|
104
|
+
}
|
|
105
|
+
return /^ {2,}$/.test(marker) ? `${marker}\n` : result;
|
|
106
|
+
},
|
|
107
|
+
code(node: Code, parent, state, info) {
|
|
108
|
+
const fence = node.data && node.data.mdastFence;
|
|
109
|
+
if (!fence) {
|
|
110
|
+
return defaultHandlers.code(node, parent, state, info);
|
|
111
|
+
}
|
|
112
|
+
return withOptions(
|
|
113
|
+
state,
|
|
114
|
+
{fence: fence[0] === '~' ? '~' : '`', fences: true},
|
|
115
|
+
() => defaultHandlers.code(node, parent, state, info),
|
|
116
|
+
);
|
|
117
|
+
},
|
|
118
|
+
heading(node: Heading, parent, state, info) {
|
|
119
|
+
// `data.mdastSetext` is only present (true) for imported setext
|
|
120
|
+
// headings; everything else defers to the document-level option.
|
|
121
|
+
if (!(node.data && node.data.mdastSetext === true)) {
|
|
122
|
+
return defaultHandlers.heading(node, parent, state, info);
|
|
123
|
+
}
|
|
124
|
+
return withOptions(state, {setext: true}, () =>
|
|
125
|
+
defaultHandlers.heading(node, parent, state, info),
|
|
126
|
+
);
|
|
127
|
+
},
|
|
128
|
+
link(node: Link, parent, state, info) {
|
|
129
|
+
// `data.mdastLinkStyle` preserves the syntax the link was written in.
|
|
130
|
+
// A title can't be expressed in autolink/literal form, so links with a
|
|
131
|
+
// title always fall through to the default (resource form).
|
|
132
|
+
const style = node.data && node.data.mdastLinkStyle;
|
|
133
|
+
if (style === 'literal' && node.title == null) {
|
|
134
|
+
// A bare GFM autolink literal: emit the visible text as-is. The
|
|
135
|
+
// gfm-autolink-literal to-markdown extension keeps surrounding
|
|
136
|
+
// escaping consistent so it re-parses as the same literal.
|
|
137
|
+
return mdastToString(node);
|
|
138
|
+
}
|
|
139
|
+
if (style === 'inline') {
|
|
140
|
+
// Force `[text](url)` even when the text equals the URL (the
|
|
141
|
+
// default handler would normalize that to an autolink).
|
|
142
|
+
return withOptions(state, {resourceLink: true}, () =>
|
|
143
|
+
defaultHandlers.link(node, parent, state, info),
|
|
144
|
+
);
|
|
145
|
+
}
|
|
146
|
+
return defaultHandlers.link(node, parent, state, info);
|
|
147
|
+
},
|
|
148
|
+
list(node: List, parent, state, info) {
|
|
149
|
+
if (node.ordered) {
|
|
150
|
+
const ordered = node.data && node.data.mdastBulletOrdered;
|
|
151
|
+
if (ordered == null) {
|
|
152
|
+
return defaultHandlers.list(node, parent, state, info);
|
|
153
|
+
}
|
|
154
|
+
return withOptions(state, {bulletOrdered: ordered}, () =>
|
|
155
|
+
defaultHandlers.list(node, parent, state, info),
|
|
156
|
+
);
|
|
157
|
+
}
|
|
158
|
+
const bullet = node.data && node.data.mdastBullet;
|
|
159
|
+
if (bullet == null) {
|
|
160
|
+
return defaultHandlers.list(node, parent, state, info);
|
|
161
|
+
}
|
|
162
|
+
return withOptions(
|
|
163
|
+
state,
|
|
164
|
+
{bullet, bulletOther: bullet === '-' ? '*' : '-'},
|
|
165
|
+
() => defaultHandlers.list(node, parent, state, info),
|
|
166
|
+
);
|
|
167
|
+
},
|
|
168
|
+
thematicBreak(node: ThematicBreak, parent, state) {
|
|
169
|
+
const marker = node.data && node.data.mdastRule;
|
|
170
|
+
if (marker !== '-' && marker !== '*' && marker !== '_') {
|
|
171
|
+
return defaultHandlers.thematicBreak(node, parent, state);
|
|
172
|
+
}
|
|
173
|
+
return withOptions(state, {rule: marker}, () =>
|
|
174
|
+
defaultHandlers.thematicBreak(node, parent, state),
|
|
175
|
+
);
|
|
176
|
+
},
|
|
177
|
+
},
|
|
178
|
+
};
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Accumulates adjacent plain text nodes that share a format so they serialize
|
|
182
|
+
* to a single delimiter pair (e.g. `**ab**` rather than `**a****b**`). Shared
|
|
183
|
+
* by the inline and block export walks.
|
|
184
|
+
*/
|
|
185
|
+
class TextRunAccumulator {
|
|
186
|
+
private format = -1;
|
|
187
|
+
private value = '';
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* Returns `true` when `child` was absorbed. A format change flushes the
|
|
191
|
+
* previous run into `out` first. The caller decides *which* text nodes
|
|
192
|
+
* are eligible (plain text with no export rule of its own); this only
|
|
193
|
+
* guards the node kind.
|
|
194
|
+
*/
|
|
195
|
+
push(child: LexicalNode, out: PhrasingContent[]): boolean {
|
|
196
|
+
if (!$isTextNode(child)) {
|
|
197
|
+
return false;
|
|
198
|
+
}
|
|
199
|
+
const format = child.getFormat() & TEXT_FORMAT_MASK;
|
|
200
|
+
if (format === this.format) {
|
|
201
|
+
this.value += child.getTextContent();
|
|
202
|
+
} else {
|
|
203
|
+
this.flushInto(out);
|
|
204
|
+
this.format = format;
|
|
205
|
+
this.value = child.getTextContent();
|
|
206
|
+
}
|
|
207
|
+
return true;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
flushInto(out: PhrasingContent[]): void {
|
|
211
|
+
if (this.format >= 0) {
|
|
212
|
+
out.push(phrasingFromFormattedText(this.value, this.format));
|
|
213
|
+
}
|
|
214
|
+
this.format = -1;
|
|
215
|
+
this.value = '';
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
function createNodeExporter(
|
|
220
|
+
compiled: CompiledMdast,
|
|
221
|
+
selection: BaseSelection | null = null,
|
|
222
|
+
) {
|
|
223
|
+
const {exportHandlers} = compiled;
|
|
224
|
+
// Incremented whenever a selected leaf contributes output. Parents compare
|
|
225
|
+
// it before/after recursing to keep an element that is not itself selected
|
|
226
|
+
// but hosts selected content (the markdown analogue of extractWithChild).
|
|
227
|
+
let selectionHits = 0;
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* Whether the run accumulator may absorb `child`: a text node whose only
|
|
231
|
+
* export behavior would be the core text fallback. A node whose type has
|
|
232
|
+
* its own registered rule (or the core `'text'` rule, which the
|
|
233
|
+
* accumulator supersedes to merge adjacent runs) must not be swallowed —
|
|
234
|
+
* this keeps replaced/custom text nodes dispatching to their handlers.
|
|
235
|
+
*/
|
|
236
|
+
function mayAccumulate(child: LexicalNode): boolean {
|
|
237
|
+
const handler = exportHandlers.get(child.getType());
|
|
238
|
+
return handler === undefined || handler === exportText;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* Selection filter for one child. With no selection every child passes
|
|
243
|
+
* through unchanged. Leaves (text, line break, decorator) pass only when
|
|
244
|
+
* selected, a partially selected text node as a detached clone sliced to
|
|
245
|
+
* the selected range; `null` means skip. Elements always pass — they are
|
|
246
|
+
* judged after recursion by {@link $dispatchElement}.
|
|
247
|
+
*/
|
|
248
|
+
function $filterChild(child: LexicalNode): LexicalNode | null {
|
|
249
|
+
if (selection === null || $isElementNode(child)) {
|
|
250
|
+
return child;
|
|
251
|
+
}
|
|
252
|
+
if (!child.isSelected(selection)) {
|
|
253
|
+
return null;
|
|
254
|
+
}
|
|
255
|
+
selectionHits++;
|
|
256
|
+
return $isTextNode(child)
|
|
257
|
+
? $sliceSelectedTextNodeContent(selection, child, 'clone')
|
|
258
|
+
: child;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/**
|
|
262
|
+
* Whether `node` or any descendant is selected. See
|
|
263
|
+
* {@link MdastExportContext.isIncluded}.
|
|
264
|
+
*/
|
|
265
|
+
function $isIncluded(node: LexicalNode): boolean {
|
|
266
|
+
if (selection === null || node.isSelected(selection)) {
|
|
267
|
+
return true;
|
|
268
|
+
}
|
|
269
|
+
if ($isElementNode(node)) {
|
|
270
|
+
for (const child of node.getChildren()) {
|
|
271
|
+
if ($isIncluded(child)) {
|
|
272
|
+
return true;
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
return false;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/**
|
|
280
|
+
* Dispatches an element child, appending its output to `out` unless a
|
|
281
|
+
* selection is active and neither the element nor any descendant is
|
|
282
|
+
* selected.
|
|
283
|
+
*/
|
|
284
|
+
function $dispatchElement(child: LexicalNode, out: MdastNode[]): void {
|
|
285
|
+
const selfSelected = selection !== null && child.isSelected(selection);
|
|
286
|
+
if (selfSelected) {
|
|
287
|
+
selectionHits++;
|
|
288
|
+
}
|
|
289
|
+
const before = selectionHits;
|
|
290
|
+
const result = $dispatch(child);
|
|
291
|
+
if (selection === null || selfSelected || selectionHits > before) {
|
|
292
|
+
out.push(...result);
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
const context: MdastExportContext = {
|
|
297
|
+
exportBlocks: node => $exportBlocks(node),
|
|
298
|
+
exportChildren: node => {
|
|
299
|
+
const out: MdastNode[] = [];
|
|
300
|
+
for (const child of node.getChildren()) {
|
|
301
|
+
const target = $filterChild(child);
|
|
302
|
+
if (target === null) {
|
|
303
|
+
continue;
|
|
304
|
+
}
|
|
305
|
+
if ($isElementNode(target)) {
|
|
306
|
+
$dispatchElement(target, out);
|
|
307
|
+
} else {
|
|
308
|
+
out.push(...$dispatch(target));
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
return out;
|
|
312
|
+
},
|
|
313
|
+
exportInline: node => $exportInline(node),
|
|
314
|
+
isIncluded: $isIncluded,
|
|
315
|
+
};
|
|
316
|
+
|
|
317
|
+
function $dispatch(node: LexicalNode): MdastNode[] {
|
|
318
|
+
const handler = exportHandlers.get(node.getType());
|
|
319
|
+
if (handler) {
|
|
320
|
+
const result = handler(node, context);
|
|
321
|
+
if (result != null) {
|
|
322
|
+
return Array.isArray(result) ? result : [result];
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
// Fallbacks keep unknown nodes from disappearing entirely; text and line
|
|
326
|
+
// breaks reuse the core handlers so their behavior can't drift.
|
|
327
|
+
const asText = exportText(node);
|
|
328
|
+
if (asText !== null) {
|
|
329
|
+
return [asText];
|
|
330
|
+
}
|
|
331
|
+
const asBreak = $exportLineBreak(node);
|
|
332
|
+
if (asBreak !== null) {
|
|
333
|
+
return [asBreak];
|
|
334
|
+
}
|
|
335
|
+
if ($isElementNode(node)) {
|
|
336
|
+
return context.exportChildren(node);
|
|
337
|
+
}
|
|
338
|
+
const text = node.getTextContent();
|
|
339
|
+
return text ? [{type: 'text', value: text}] : [];
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
/**
|
|
343
|
+
* Converts the inline children of `node` into phrasing content.
|
|
344
|
+
*/
|
|
345
|
+
function $exportInline(node: ElementNode): PhrasingContent[] {
|
|
346
|
+
const result: PhrasingContent[] = [];
|
|
347
|
+
const runs = new TextRunAccumulator();
|
|
348
|
+
for (const child of node.getChildren()) {
|
|
349
|
+
const target = $filterChild(child);
|
|
350
|
+
if (target === null) {
|
|
351
|
+
continue;
|
|
352
|
+
}
|
|
353
|
+
if (!(mayAccumulate(target) && runs.push(target, result))) {
|
|
354
|
+
runs.flushInto(result);
|
|
355
|
+
if ($isElementNode(target)) {
|
|
356
|
+
// The registry erases types; phrasing output is the dispatch
|
|
357
|
+
// contract for inline children.
|
|
358
|
+
$dispatchElement(target, result as MdastNode[]);
|
|
359
|
+
} else {
|
|
360
|
+
result.push(...($dispatch(target) as PhrasingContent[]));
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
runs.flushInto(result);
|
|
365
|
+
return result;
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
/**
|
|
369
|
+
* Converts a container whose Lexical children are inline (block quote, list
|
|
370
|
+
* item) into mdast block content. A LineBreakNode marked as a paragraph
|
|
371
|
+
* boundary (set by the import handlers when joining sibling paragraphs)
|
|
372
|
+
* splits the content; any other LineBreakNode stays an inline `break`
|
|
373
|
+
* (hard or soft according to its marker). Nested block children pass
|
|
374
|
+
* through directly.
|
|
375
|
+
*/
|
|
376
|
+
function $exportBlocks(node: ElementNode): BlockContent[] {
|
|
377
|
+
const blocks: BlockContent[] = [];
|
|
378
|
+
let inline: PhrasingContent[] = [];
|
|
379
|
+
const runs = new TextRunAccumulator();
|
|
380
|
+
const flushParagraph = () => {
|
|
381
|
+
runs.flushInto(inline);
|
|
382
|
+
if (inline.length > 0) {
|
|
383
|
+
blocks.push({children: inline, type: 'paragraph'});
|
|
384
|
+
inline = [];
|
|
385
|
+
}
|
|
386
|
+
};
|
|
387
|
+
for (const child of node.getChildren()) {
|
|
388
|
+
const target = $filterChild(child);
|
|
389
|
+
if (target === null) {
|
|
390
|
+
continue;
|
|
391
|
+
}
|
|
392
|
+
if ($isLineBreakNode(target)) {
|
|
393
|
+
const asBreak = $exportLineBreak(target);
|
|
394
|
+
if ($getState(target, paragraphBreakState) || asBreak === null) {
|
|
395
|
+
flushParagraph();
|
|
396
|
+
} else {
|
|
397
|
+
runs.flushInto(inline);
|
|
398
|
+
inline.push(asBreak);
|
|
399
|
+
}
|
|
400
|
+
} else if (mayAccumulate(target) && runs.push(target, inline)) {
|
|
401
|
+
continue;
|
|
402
|
+
} else if ($isBlockLevelNode(target)) {
|
|
403
|
+
flushParagraph();
|
|
404
|
+
if ($isElementNode(target)) {
|
|
405
|
+
// The registry erases types; block-level output is the dispatch
|
|
406
|
+
// contract for block children.
|
|
407
|
+
$dispatchElement(target, blocks as MdastNode[]);
|
|
408
|
+
} else {
|
|
409
|
+
blocks.push(...($dispatch(target) as BlockContent[]));
|
|
410
|
+
}
|
|
411
|
+
} else if ($isElementNode(target)) {
|
|
412
|
+
runs.flushInto(inline);
|
|
413
|
+
$dispatchElement(target, inline as MdastNode[]);
|
|
414
|
+
} else {
|
|
415
|
+
runs.flushInto(inline);
|
|
416
|
+
inline.push(...($dispatch(target) as PhrasingContent[]));
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
flushParagraph();
|
|
420
|
+
if (blocks.length === 0) {
|
|
421
|
+
blocks.push({children: [], type: 'paragraph'});
|
|
422
|
+
}
|
|
423
|
+
return blocks;
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
return {exportChildren: context.exportChildren};
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
/**
|
|
430
|
+
* Picks the document-level emphasis and strong delimiters from the first
|
|
431
|
+
* italic / bold text node that recorded a known delimiter on import, scanning
|
|
432
|
+
* the tree rooted at `node`. Mixing delimiters within one document is not
|
|
433
|
+
* supported by to-markdown's escaping, so a single choice is made per
|
|
434
|
+
* document; nodes with no recorded delimiter (created in the editor) are
|
|
435
|
+
* skipped so they cannot mask the document's authored style.
|
|
436
|
+
*/
|
|
437
|
+
function $dominantInlineMarkers(node: ElementNode): {
|
|
438
|
+
emphasis: '*' | '_' | undefined;
|
|
439
|
+
strong: '*' | '_' | undefined;
|
|
440
|
+
} {
|
|
441
|
+
let emphasis: '*' | '_' | undefined;
|
|
442
|
+
let strong: '*' | '_' | undefined;
|
|
443
|
+
const visit = (element: ElementNode): void => {
|
|
444
|
+
for (const child of element.getChildren()) {
|
|
445
|
+
if ($isTextNode(child)) {
|
|
446
|
+
if (emphasis === undefined && child.hasFormat('italic')) {
|
|
447
|
+
const marker = $getState(child, emphasisMarkerState);
|
|
448
|
+
if (marker === '_') {
|
|
449
|
+
emphasis = '_';
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
if (strong === undefined && child.hasFormat('bold')) {
|
|
453
|
+
const marker = $getState(child, strongMarkerState);
|
|
454
|
+
if (marker === '_') {
|
|
455
|
+
strong = '_';
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
} else if ($isElementNode(child)) {
|
|
459
|
+
visit(child);
|
|
460
|
+
}
|
|
461
|
+
if (emphasis !== undefined && strong !== undefined) {
|
|
462
|
+
return;
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
};
|
|
466
|
+
visit(node);
|
|
467
|
+
return {emphasis, strong};
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
/**
|
|
471
|
+
* Creates a reusable exporter that converts the Lexical tree rooted at the
|
|
472
|
+
* supplied element (or the editor root) — or just the selected content —
|
|
473
|
+
* into a Markdown string.
|
|
474
|
+
*/
|
|
475
|
+
export function createMdastExport(compiled: CompiledMdast): {
|
|
476
|
+
$exportToMdast: (node?: ElementNode) => Root;
|
|
477
|
+
$exportToMarkdown: (node?: ElementNode) => string;
|
|
478
|
+
$exportSelectionToMarkdown: (selection?: BaseSelection | null) => string;
|
|
479
|
+
} {
|
|
480
|
+
// The unfiltered exporter has no per-call state and is shared by every
|
|
481
|
+
// whole-document export; selection exports build a fresh one per call.
|
|
482
|
+
const documentExporter = createNodeExporter(compiled);
|
|
483
|
+
|
|
484
|
+
const $toMdast = (
|
|
485
|
+
root: ElementNode,
|
|
486
|
+
exporter: ReturnType<typeof createNodeExporter>,
|
|
487
|
+
): Root => ({
|
|
488
|
+
// The registry erases types; root-level output is the dispatch contract
|
|
489
|
+
// for top-level children.
|
|
490
|
+
children: exporter.exportChildren(root) as RootContent[],
|
|
491
|
+
type: 'root',
|
|
492
|
+
});
|
|
493
|
+
|
|
494
|
+
const $serialize = (tree: Root, root: ElementNode): string => {
|
|
495
|
+
// Emphasis/strong delimiters are document-level; the delimiter recorded on
|
|
496
|
+
// import wins, otherwise contributed toMarkdownExtensions (and the '-'
|
|
497
|
+
// bullet baseline) decide. Defaults ride as the FIRST extension so that
|
|
498
|
+
// contributed extensions can override them; SYNTAX_TO_MARKDOWN runs last
|
|
499
|
+
// so its handlers reproduce the per-node syntax captured on import.
|
|
500
|
+
const {emphasis, strong} = $dominantInlineMarkers(root);
|
|
501
|
+
const defaults: ToMarkdownExtension = {bullet: '-'};
|
|
502
|
+
if (emphasis) {
|
|
503
|
+
defaults.emphasis = emphasis;
|
|
504
|
+
}
|
|
505
|
+
if (strong) {
|
|
506
|
+
defaults.strong = strong;
|
|
507
|
+
}
|
|
508
|
+
const out = toMarkdown(tree, {
|
|
509
|
+
extensions: [
|
|
510
|
+
defaults,
|
|
511
|
+
...compiled.toMarkdownExtensions,
|
|
512
|
+
SYNTAX_TO_MARKDOWN,
|
|
513
|
+
],
|
|
514
|
+
});
|
|
515
|
+
// toMarkdown always appends a trailing newline; drop it so callers get the
|
|
516
|
+
// same shape as `@lexical/markdown`'s `$convertToMarkdownString`.
|
|
517
|
+
return out.replace(/\n$/, '');
|
|
518
|
+
};
|
|
519
|
+
|
|
520
|
+
const $exportToMdast = (node?: ElementNode): Root =>
|
|
521
|
+
$toMdast(node || $getRoot(), documentExporter);
|
|
522
|
+
|
|
523
|
+
const $exportToMarkdown = (node?: ElementNode): string => {
|
|
524
|
+
const root = node || $getRoot();
|
|
525
|
+
return $serialize($toMdast(root, documentExporter), root);
|
|
526
|
+
};
|
|
527
|
+
|
|
528
|
+
const $exportSelectionToMarkdown = (
|
|
529
|
+
selection: BaseSelection | null = $getSelection(),
|
|
530
|
+
): string => {
|
|
531
|
+
if (
|
|
532
|
+
selection === null ||
|
|
533
|
+
($isRangeSelection(selection) && selection.isCollapsed())
|
|
534
|
+
) {
|
|
535
|
+
return '';
|
|
536
|
+
}
|
|
537
|
+
const root = $getRoot();
|
|
538
|
+
return $serialize(
|
|
539
|
+
$toMdast(root, createNodeExporter(compiled, selection)),
|
|
540
|
+
root,
|
|
541
|
+
);
|
|
542
|
+
};
|
|
543
|
+
|
|
544
|
+
return {$exportSelectionToMarkdown, $exportToMarkdown, $exportToMdast};
|
|
545
|
+
}
|
|
@@ -0,0 +1,122 @@
|
|
|
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 {BaseSelection, ElementNode} from 'lexical';
|
|
10
|
+
import type {Root} from 'mdast';
|
|
11
|
+
|
|
12
|
+
import {$getExtensionOutput} from '@lexical/extension';
|
|
13
|
+
import {defineExtension} from 'lexical';
|
|
14
|
+
|
|
15
|
+
import {createMdastExport} from './MdastExport';
|
|
16
|
+
import {MdastImportExtension} from './MdastImportExtension';
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* The runtime API exposed by {@link MdastExportExtension}. Obtain it inside a
|
|
20
|
+
* read/update with `$getExtensionOutput(MdastExportExtension)`, or use the
|
|
21
|
+
* {@link $convertToMarkdownString} shorthand.
|
|
22
|
+
* @experimental
|
|
23
|
+
*/
|
|
24
|
+
export interface MdastExportExtensionOutput {
|
|
25
|
+
/**
|
|
26
|
+
* Serializes the editor root (or `node`) to a Markdown string. Must be
|
|
27
|
+
* called inside an `editor.read()` or `editor.update()`.
|
|
28
|
+
*/
|
|
29
|
+
$convertToMarkdownString(node?: ElementNode): string;
|
|
30
|
+
/**
|
|
31
|
+
* Exports the editor root (or `node`) to an mdast `Root` tree without
|
|
32
|
+
* serializing it, for interop with the unified/remark ecosystem (remark
|
|
33
|
+
* plugins, `remark-rehype`, tree diffing, ...). Must be called inside an
|
|
34
|
+
* `editor.read()` or `editor.update()`. Syntax preserved from import
|
|
35
|
+
* rides along as `data` fields on the nodes, mdast's sanctioned
|
|
36
|
+
* extension point.
|
|
37
|
+
*/
|
|
38
|
+
$convertToMdast(node?: ElementNode): Root;
|
|
39
|
+
/**
|
|
40
|
+
* Serializes only the selected content (defaulting to the current
|
|
41
|
+
* selection) to a Markdown string: leaves outside the selection are
|
|
42
|
+
* skipped, partially selected text nodes are sliced to the selected
|
|
43
|
+
* range, and elements are kept when they or any descendant are selected.
|
|
44
|
+
* Returns `''` for a null or collapsed selection. Must be called inside
|
|
45
|
+
* an `editor.read()` or `editor.update()`.
|
|
46
|
+
*/
|
|
47
|
+
$convertSelectionToMarkdownString(selection?: BaseSelection | null): string;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Markdown serialization for `@lexical/mdast`. Import
|
|
52
|
+
* (`MdastImportExtension` and the feature extensions that contribute to it) and
|
|
53
|
+
* export are separate extensions so that editors which only *parse* Markdown
|
|
54
|
+
* — never serialize back — don't bundle `mdast-util-to-markdown`.
|
|
55
|
+
*
|
|
56
|
+
* The export rules themselves are contributed by the same feature extensions
|
|
57
|
+
* that contribute import rules; this extension compiles the shared registry
|
|
58
|
+
* into a serializer:
|
|
59
|
+
* ```ts
|
|
60
|
+
* dependencies: [MdastCommonMarkExtension, MdastExportExtension]
|
|
61
|
+
* ```
|
|
62
|
+
* @experimental
|
|
63
|
+
*/
|
|
64
|
+
export const MdastExportExtension = /* @__PURE__ */ defineExtension<
|
|
65
|
+
Record<never, never>,
|
|
66
|
+
'@lexical/mdast/Export',
|
|
67
|
+
MdastExportExtensionOutput,
|
|
68
|
+
void
|
|
69
|
+
>({
|
|
70
|
+
build(editor, config, state): MdastExportExtensionOutput {
|
|
71
|
+
const {registry} = state.getDependency(MdastImportExtension).output;
|
|
72
|
+
const {$exportSelectionToMarkdown, $exportToMdast, $exportToMarkdown} =
|
|
73
|
+
createMdastExport(registry);
|
|
74
|
+
return {
|
|
75
|
+
$convertSelectionToMarkdownString: $exportSelectionToMarkdown,
|
|
76
|
+
$convertToMarkdownString: $exportToMarkdown,
|
|
77
|
+
$convertToMdast: $exportToMdast,
|
|
78
|
+
};
|
|
79
|
+
},
|
|
80
|
+
dependencies: [MdastImportExtension],
|
|
81
|
+
name: '@lexical/mdast/Export',
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Shorthand for
|
|
86
|
+
* `$getExtensionOutput(MdastExportExtension).$convertToMarkdownString`.
|
|
87
|
+
* Must be called inside an `editor.read()` or `editor.update()`. Throws if
|
|
88
|
+
* the editor was not built with {@link MdastExportExtension}.
|
|
89
|
+
* @experimental
|
|
90
|
+
*/
|
|
91
|
+
export function $convertToMarkdownString(node?: ElementNode): string {
|
|
92
|
+
return $getExtensionOutput(MdastExportExtension).$convertToMarkdownString(
|
|
93
|
+
node,
|
|
94
|
+
);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Shorthand for `$getExtensionOutput(MdastExportExtension).$convertToMdast`.
|
|
99
|
+
* Must be called inside an `editor.read()` or `editor.update()`. Throws if
|
|
100
|
+
* the editor was not built with {@link MdastExportExtension}.
|
|
101
|
+
* @experimental
|
|
102
|
+
*/
|
|
103
|
+
export function $convertToMdast(node?: ElementNode): Root {
|
|
104
|
+
return $getExtensionOutput(MdastExportExtension).$convertToMdast(node);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Shorthand for
|
|
109
|
+
* `$getExtensionOutput(MdastExportExtension).$convertSelectionToMarkdownString`.
|
|
110
|
+
* Serializes only the selected content (defaulting to the current selection)
|
|
111
|
+
* to a Markdown string; returns `''` for a null or collapsed selection.
|
|
112
|
+
* Must be called inside an `editor.read()` or `editor.update()`. Throws if
|
|
113
|
+
* the editor was not built with {@link MdastExportExtension}.
|
|
114
|
+
* @experimental
|
|
115
|
+
*/
|
|
116
|
+
export function $convertSelectionToMarkdownString(
|
|
117
|
+
selection?: BaseSelection | null,
|
|
118
|
+
): string {
|
|
119
|
+
return $getExtensionOutput(
|
|
120
|
+
MdastExportExtension,
|
|
121
|
+
).$convertSelectionToMarkdownString(selection);
|
|
122
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
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 {defineExtension} from 'lexical';
|
|
10
|
+
|
|
11
|
+
import {MdastExportExtension} from './MdastExportExtension';
|
|
12
|
+
import {MdastImportExtension} from './MdastImportExtension';
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Convenience bundle of {@link MdastImportExtension} and
|
|
16
|
+
* {@link MdastExportExtension}: Markdown parsing *and* serialization.
|
|
17
|
+
*
|
|
18
|
+
* Depend on this when you want both directions without thinking about it:
|
|
19
|
+
* ```ts
|
|
20
|
+
* dependencies: [MdastCommonMarkExtension, MdastExtension]
|
|
21
|
+
* ```
|
|
22
|
+
* Editors that never serialize back to Markdown can skip it (feature
|
|
23
|
+
* extensions already pull in {@link MdastImportExtension}) and avoid
|
|
24
|
+
* bundling the serializer (`mdast-util-to-markdown`).
|
|
25
|
+
* @experimental
|
|
26
|
+
*/
|
|
27
|
+
export const MdastExtension = /* @__PURE__ */ defineExtension({
|
|
28
|
+
dependencies: [MdastImportExtension, MdastExportExtension],
|
|
29
|
+
name: '@lexical/mdast/Mdast',
|
|
30
|
+
});
|