@blue-patterns/richtext 0.0.0-stage → 0.7.18
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/CHANGELOG.md +275 -0
- package/LICENSE +22 -0
- package/README.md +88 -2
- package/astryx.integration.mjs +11 -0
- package/blocks/RichTextEditorShowcase.template.mjs +13 -0
- package/blocks/RichTextEditorShowcase.tsx +19 -0
- package/dist/RichTextEditor.d.ts +282 -0
- package/dist/RichTextEditor.d.ts.map +1 -0
- package/dist/RichTextEditor.js +657 -0
- package/dist/RichTextEditorAutoLinkPlugin.d.ts +83 -0
- package/dist/RichTextEditorAutoLinkPlugin.d.ts.map +1 -0
- package/dist/RichTextEditorAutoLinkPlugin.js +99 -0
- package/dist/RichTextEditorToolbar.d.ts +186 -0
- package/dist/RichTextEditorToolbar.d.ts.map +1 -0
- package/dist/RichTextEditorToolbar.js +688 -0
- package/dist/RichTextView.d.ts +92 -0
- package/dist/RichTextView.d.ts.map +1 -0
- package/dist/RichTextView.js +267 -0
- package/dist/codeLanguages.d.ts +14 -0
- package/dist/codeLanguages.d.ts.map +1 -0
- package/dist/codeLanguages.js +34 -0
- package/dist/codeLineTokens.d.ts +53 -0
- package/dist/codeLineTokens.d.ts.map +1 -0
- package/dist/codeLineTokens.js +188 -0
- package/dist/editorNodes.d.ts +8 -0
- package/dist/editorNodes.d.ts.map +1 -0
- package/dist/editorNodes.js +30 -0
- package/dist/editorTheme.d.ts +7 -0
- package/dist/editorTheme.d.ts.map +1 -0
- package/dist/editorTheme.js +328 -0
- package/dist/index.d.ts +31 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +28 -0
- package/dist/internal/CodeBackspacePlugin.d.ts +2 -0
- package/dist/internal/CodeBackspacePlugin.d.ts.map +1 -0
- package/dist/internal/CodeBackspacePlugin.js +70 -0
- package/dist/internal/CodeBlockHeaderPlugin.d.ts +11 -0
- package/dist/internal/CodeBlockHeaderPlugin.d.ts.map +1 -0
- package/dist/internal/CodeBlockHeaderPlugin.js +204 -0
- package/dist/internal/CodePastePlugin.d.ts +5 -0
- package/dist/internal/CodePastePlugin.d.ts.map +1 -0
- package/dist/internal/CodePastePlugin.js +104 -0
- package/dist/internal/CodeSyntaxPlugin.d.ts +8 -0
- package/dist/internal/CodeSyntaxPlugin.d.ts.map +1 -0
- package/dist/internal/CodeSyntaxPlugin.js +397 -0
- package/dist/internal/CodeTabPlugin.d.ts +2 -0
- package/dist/internal/CodeTabPlugin.d.ts.map +1 -0
- package/dist/internal/CodeTabPlugin.js +256 -0
- package/dist/internal/DisabledLinkGuardPlugin.d.ts +4 -0
- package/dist/internal/DisabledLinkGuardPlugin.d.ts.map +1 -0
- package/dist/internal/DisabledLinkGuardPlugin.js +93 -0
- package/dist/internal/ImageSourceCaretPlugin.d.ts +3 -0
- package/dist/internal/ImageSourceCaretPlugin.d.ts.map +1 -0
- package/dist/internal/ImageSourceCaretPlugin.js +105 -0
- package/dist/internal/ListItemShortcutsPlugin.d.ts +6 -0
- package/dist/internal/ListItemShortcutsPlugin.d.ts.map +1 -0
- package/dist/internal/ListItemShortcutsPlugin.js +314 -0
- package/dist/internal/MarkdownExtensionsPlugin.d.ts +17 -0
- package/dist/internal/MarkdownExtensionsPlugin.d.ts.map +1 -0
- package/dist/internal/MarkdownExtensionsPlugin.js +185 -0
- package/dist/internal/SelectAllScopePlugin.d.ts +2 -0
- package/dist/internal/SelectAllScopePlugin.d.ts.map +1 -0
- package/dist/internal/SelectAllScopePlugin.js +67 -0
- package/dist/internal/TableColumnFloorPlugin.d.ts +12 -0
- package/dist/internal/TableColumnFloorPlugin.d.ts.map +1 -0
- package/dist/internal/TableColumnFloorPlugin.js +84 -0
- package/dist/internal/TablePastePlugin.d.ts +9 -0
- package/dist/internal/TablePastePlugin.d.ts.map +1 -0
- package/dist/internal/TablePastePlugin.js +183 -0
- package/dist/internal/TableScrollRegionPlugin.d.ts +9 -0
- package/dist/internal/TableScrollRegionPlugin.d.ts.map +1 -0
- package/dist/internal/TableScrollRegionPlugin.js +95 -0
- package/dist/internal/TaskCheckboxPlugin.d.ts +7 -0
- package/dist/internal/TaskCheckboxPlugin.d.ts.map +1 -0
- package/dist/internal/TaskCheckboxPlugin.js +193 -0
- package/dist/internal/ToolbarLinkPopover.d.ts +74 -0
- package/dist/internal/ToolbarLinkPopover.d.ts.map +1 -0
- package/dist/internal/ToolbarLinkPopover.js +292 -0
- package/dist/linkEditing.d.ts +6 -0
- package/dist/linkEditing.d.ts.map +1 -0
- package/dist/linkEditing.js +20 -0
- package/dist/linkForm.d.ts +28 -0
- package/dist/linkForm.d.ts.map +1 -0
- package/dist/linkForm.js +137 -0
- package/dist/linkUtils.d.ts +27 -0
- package/dist/linkUtils.d.ts.map +1 -0
- package/dist/linkUtils.js +101 -0
- package/dist/markdown.d.ts +17 -0
- package/dist/markdown.d.ts.map +1 -0
- package/dist/markdown.js +17 -0
- package/dist/markdownCharacterReferences.d.ts +135 -0
- package/dist/markdownCharacterReferences.d.ts.map +1 -0
- package/dist/markdownCharacterReferences.js +1127 -0
- package/dist/markdownCodeFence.d.ts +4 -0
- package/dist/markdownCodeFence.d.ts.map +1 -0
- package/dist/markdownCodeFence.js +167 -0
- package/dist/markdownExtensionNode.d.ts +88 -0
- package/dist/markdownExtensionNode.d.ts.map +1 -0
- package/dist/markdownExtensionNode.js +248 -0
- package/dist/markdownExtensions.d.ts +115 -0
- package/dist/markdownExtensions.d.ts.map +1 -0
- package/dist/markdownExtensions.js +392 -0
- package/dist/markdownHardLineBreak.d.ts +25 -0
- package/dist/markdownHardLineBreak.d.ts.map +1 -0
- package/dist/markdownHardLineBreak.js +59 -0
- package/dist/markdownHeading.d.ts +20 -0
- package/dist/markdownHeading.d.ts.map +1 -0
- package/dist/markdownHeading.js +30 -0
- package/dist/markdownLink.d.ts +5 -0
- package/dist/markdownLink.d.ts.map +1 -0
- package/dist/markdownLink.js +80 -0
- package/dist/markdownListExport.d.ts +3 -0
- package/dist/markdownListExport.d.ts.map +1 -0
- package/dist/markdownListExport.js +68 -0
- package/dist/markdownListIndentation.d.ts +8 -0
- package/dist/markdownListIndentation.d.ts.map +1 -0
- package/dist/markdownListIndentation.js +101 -0
- package/dist/markdownOrderedList.d.ts +3 -0
- package/dist/markdownOrderedList.d.ts.map +1 -0
- package/dist/markdownOrderedList.js +51 -0
- package/dist/markdownQuote.d.ts +22 -0
- package/dist/markdownQuote.d.ts.map +1 -0
- package/dist/markdownQuote.js +32 -0
- package/dist/markdownSerializers.d.ts +49 -0
- package/dist/markdownSerializers.d.ts.map +1 -0
- package/dist/markdownSerializers.js +83 -0
- package/dist/markdownSource.d.ts +71 -0
- package/dist/markdownSource.d.ts.map +1 -0
- package/dist/markdownSource.js +804 -0
- package/dist/markdownSpaceRuns.d.ts +24 -0
- package/dist/markdownSpaceRuns.d.ts.map +1 -0
- package/dist/markdownSpaceRuns.js +147 -0
- package/dist/markdownTable.d.ts +35 -0
- package/dist/markdownTable.d.ts.map +1 -0
- package/dist/markdownTable.js +227 -0
- package/dist/markdownTaskList.d.ts +48 -0
- package/dist/markdownTaskList.d.ts.map +1 -0
- package/dist/markdownTaskList.js +166 -0
- package/dist/markdownThematicBreak.d.ts +3 -0
- package/dist/markdownThematicBreak.d.ts.map +1 -0
- package/dist/markdownThematicBreak.js +55 -0
- package/dist/privateAttributes/layerPortalBoundary.stylex.d.ts +12 -0
- package/dist/privateAttributes/layerPortalBoundary.stylex.d.ts.map +1 -0
- package/dist/privateAttributes/layerPortalBoundary.stylex.js +16 -0
- package/dist/richtext.css +213 -0
- package/dist/textSemantics.d.ts +3 -0
- package/dist/textSemantics.d.ts.map +1 -0
- package/dist/textSemantics.js +87 -0
- package/package.json +158 -3
- package/src/RichTextEditor.doc.mjs +308 -0
- package/src/RichTextEditor.tsx +1109 -0
- package/src/RichTextEditorAutoLinkPlugin.doc.mjs +43 -0
- package/src/RichTextEditorAutoLinkPlugin.tsx +126 -0
- package/src/RichTextEditorToolbar.doc.mjs +92 -0
- package/src/RichTextEditorToolbar.tsx +871 -0
- package/src/RichTextView.doc.mjs +101 -0
- package/src/RichTextView.tsx +351 -0
- package/src/codeLanguages.ts +42 -0
- package/src/codeLineTokens.ts +241 -0
- package/src/doc-mjs.d.ts +23 -0
- package/src/editorNodes.ts +44 -0
- package/src/editorTheme.ts +550 -0
- package/src/index.ts +59 -0
- package/src/internal/CodeBackspacePlugin.tsx +96 -0
- package/src/internal/CodeBlockHeaderPlugin.tsx +282 -0
- package/src/internal/CodePastePlugin.tsx +144 -0
- package/src/internal/CodeSyntaxPlugin.tsx +493 -0
- package/src/internal/CodeTabPlugin.tsx +298 -0
- package/src/internal/DisabledLinkGuardPlugin.tsx +93 -0
- package/src/internal/ImageSourceCaretPlugin.tsx +159 -0
- package/src/internal/ListItemShortcutsPlugin.tsx +404 -0
- package/src/internal/MarkdownExtensionsPlugin.tsx +289 -0
- package/src/internal/SelectAllScopePlugin.tsx +86 -0
- package/src/internal/TableColumnFloorPlugin.tsx +99 -0
- package/src/internal/TablePastePlugin.tsx +223 -0
- package/src/internal/TableScrollRegionPlugin.tsx +107 -0
- package/src/internal/TaskCheckboxPlugin.tsx +246 -0
- package/src/internal/ToolbarLinkPopover.tsx +392 -0
- package/src/linkEditing.ts +20 -0
- package/src/linkForm.tsx +156 -0
- package/src/linkUtils.ts +106 -0
- package/src/markdown.ts +25 -0
- package/src/markdownCharacterReferences.ts +1137 -0
- package/src/markdownCodeFence.ts +216 -0
- package/src/markdownExtensionNode.ts +339 -0
- package/src/markdownExtensions.ts +496 -0
- package/src/markdownHardLineBreak.ts +73 -0
- package/src/markdownHeading.ts +33 -0
- package/src/markdownLink.ts +86 -0
- package/src/markdownListExport.ts +85 -0
- package/src/markdownListIndentation.ts +105 -0
- package/src/markdownOrderedList.ts +60 -0
- package/src/markdownQuote.ts +35 -0
- package/src/markdownSerializers.ts +108 -0
- package/src/markdownSource.ts +1045 -0
- package/src/markdownSpaceRuns.ts +164 -0
- package/src/markdownTable.ts +309 -0
- package/src/markdownTaskList.ts +222 -0
- package/src/markdownThematicBreak.ts +66 -0
- package/src/privateAttributes/layerPortalBoundary.stylex.ts +18 -0
- package/src/textSemantics.ts +104 -0
|
@@ -0,0 +1,496 @@
|
|
|
1
|
+
// Copyright (c) Meta Platforms, Inc. and affiliates.
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* @file markdownExtensions.ts
|
|
5
|
+
* @input Uses core's server-safe plugin protocol (getMarkdownPluginCapabilities)
|
|
6
|
+
* and parser (parseMarkdownAst), lexical, and RichTextExtensionNode.
|
|
7
|
+
* @output Exports createRichTextExtension, RichTextExtensionError, and the
|
|
8
|
+
* RichTextMarkdownExtension type; and, for RichText's own Markdown import and
|
|
9
|
+
* export, the configuration check, the source shielding and restoring that
|
|
10
|
+
* puts core-recognized plugin nodes into the editor, and the transformers
|
|
11
|
+
* that export them.
|
|
12
|
+
* @position Implements spec:AST-064 FR1–FR6 and FR10 for RichText's Markdown
|
|
13
|
+
* import and export. Server-safe: imports no React, DOM, or client module.
|
|
14
|
+
*
|
|
15
|
+
* A plugin is the entry core's createMarkdownPlugin returns; RichText adopts it
|
|
16
|
+
* as it is (FR1), refusing a plugin that declares a transform (FR4, DEC-3).
|
|
17
|
+
* Before Lexical imports a chunk of Markdown, core's parser finds the adopted
|
|
18
|
+
* plugins' nodes in it, and each node's exact source becomes a private-use
|
|
19
|
+
* stand-in that Lexical's transformers read as plain text (FR5, DEC-4). Core
|
|
20
|
+
* reports where a top-level block node sits but not an inline one, so each
|
|
21
|
+
* node is placed at the occurrence of its source that core itself recognizes:
|
|
22
|
+
* replacing that occurrence, and only that one, removes exactly that node from
|
|
23
|
+
* core's parse. After the import, each stand-in becomes an atomic
|
|
24
|
+
* RichTextExtensionNode holding the node's source and data.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
import type {Transformer} from '@lexical/markdown';
|
|
28
|
+
import {
|
|
29
|
+
$createParagraphNode,
|
|
30
|
+
$createTextNode,
|
|
31
|
+
$isLineBreakNode,
|
|
32
|
+
$isParagraphNode,
|
|
33
|
+
TextNode,
|
|
34
|
+
type ElementNode,
|
|
35
|
+
type LexicalNode,
|
|
36
|
+
type ParagraphNode,
|
|
37
|
+
} from 'lexical';
|
|
38
|
+
import {
|
|
39
|
+
getMarkdownPluginCapabilities,
|
|
40
|
+
type MarkdownExtensionNode,
|
|
41
|
+
type MarkdownPluginEntry,
|
|
42
|
+
} from '@blue-patterns/core/Markdown/plugins';
|
|
43
|
+
import {parseMarkdownAst} from '@blue-patterns/core/Markdown/parser';
|
|
44
|
+
import {
|
|
45
|
+
$createRichTextExtensionNode,
|
|
46
|
+
$isRichTextExtensionNode,
|
|
47
|
+
RichTextExtensionNode,
|
|
48
|
+
} from './markdownExtensionNode';
|
|
49
|
+
|
|
50
|
+
const extensionPlugin = Symbol('RichTextMarkdownExtension.plugin');
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* One Markdown plugin adopted by RichText. Opaque: create it with
|
|
54
|
+
* `createRichTextExtension`, and pass it to the surfaces and serializers that
|
|
55
|
+
* should recognize the plugin's syntax.
|
|
56
|
+
*/
|
|
57
|
+
export interface RichTextMarkdownExtension {
|
|
58
|
+
/** The adopted plugin's name. */
|
|
59
|
+
readonly name: string;
|
|
60
|
+
readonly [extensionPlugin]: MarkdownPluginEntry;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Why RichText refused a plugin or a configuration (spec:AST-064 FR4, FR6). */
|
|
64
|
+
export class RichTextExtensionError extends Error {
|
|
65
|
+
/** The plugin RichText refused, or that a configuration claims twice. */
|
|
66
|
+
readonly plugin: string;
|
|
67
|
+
/** What RichText cannot honor: `transform`, or what was claimed twice. */
|
|
68
|
+
readonly capability: string;
|
|
69
|
+
/** The transformer or second extension that claims the same thing. */
|
|
70
|
+
readonly claimant: string | null;
|
|
71
|
+
|
|
72
|
+
constructor(plugin: string, capability: string, claimant: string | null) {
|
|
73
|
+
super(
|
|
74
|
+
claimant == null
|
|
75
|
+
? `RichText cannot adopt Markdown plugin "${plugin}": RichText does not run its ${capability}.`
|
|
76
|
+
: `RichText cannot use Markdown plugin "${plugin}" with ${claimant}: both claim its ${capability}.`,
|
|
77
|
+
);
|
|
78
|
+
this.name = 'RichTextExtensionError';
|
|
79
|
+
this.plugin = plugin;
|
|
80
|
+
this.capability = capability;
|
|
81
|
+
this.claimant = claimant;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Adopts a Markdown plugin for RichText (spec:AST-064 FR1, FR4). RichText
|
|
87
|
+
* recognizes the plugin's syntax with core's parser and keeps each node's
|
|
88
|
+
* source; it never runs the plugin's immutable transform, so a plugin that
|
|
89
|
+
* declares one is refused with a `RichTextExtensionError` here, before any
|
|
90
|
+
* surface uses it.
|
|
91
|
+
*
|
|
92
|
+
* @example
|
|
93
|
+
* ```
|
|
94
|
+
* const mentions = createRichTextExtension(mentionsPlugin);
|
|
95
|
+
* markdownToEditorStateJSON(markdown, {extensions: [mentions]});
|
|
96
|
+
* ```
|
|
97
|
+
*/
|
|
98
|
+
export function createRichTextExtension(
|
|
99
|
+
plugin: MarkdownPluginEntry,
|
|
100
|
+
): RichTextMarkdownExtension {
|
|
101
|
+
const capabilities = getMarkdownPluginCapabilities(plugin);
|
|
102
|
+
if (capabilities.transform) {
|
|
103
|
+
throw new RichTextExtensionError(plugin.name, 'transform', null);
|
|
104
|
+
}
|
|
105
|
+
return Object.freeze({name: plugin.name, [extensionPlugin]: plugin});
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* The plugins behind `extensions`, after checking that the configuration is
|
|
110
|
+
* one RichText can honor (spec:AST-064 FR6): no plugin adopted twice, and no
|
|
111
|
+
* transformer that imports or exports plugin nodes.
|
|
112
|
+
*/
|
|
113
|
+
export function pluginsOf(
|
|
114
|
+
extensions: ReadonlyArray<RichTextMarkdownExtension>,
|
|
115
|
+
transformers: ReadonlyArray<Transformer>,
|
|
116
|
+
): ReadonlyArray<MarkdownPluginEntry> {
|
|
117
|
+
const seen = new Set<string>();
|
|
118
|
+
for (const extension of extensions) {
|
|
119
|
+
if (seen.has(extension.name)) {
|
|
120
|
+
throw new RichTextExtensionError(
|
|
121
|
+
extension.name,
|
|
122
|
+
'syntax',
|
|
123
|
+
`a second extension for "${extension.name}"`,
|
|
124
|
+
);
|
|
125
|
+
}
|
|
126
|
+
seen.add(extension.name);
|
|
127
|
+
}
|
|
128
|
+
const [first] = extensions;
|
|
129
|
+
if (first != null) {
|
|
130
|
+
transformers.forEach((transformer, index) => {
|
|
131
|
+
if (
|
|
132
|
+
'dependencies' in transformer &&
|
|
133
|
+
transformer.dependencies.includes(RichTextExtensionNode)
|
|
134
|
+
) {
|
|
135
|
+
throw new RichTextExtensionError(
|
|
136
|
+
first.name,
|
|
137
|
+
'nodes',
|
|
138
|
+
`transformer ${index} (${transformer.type})`,
|
|
139
|
+
);
|
|
140
|
+
}
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
return extensions.map(extension => extension[extensionPlugin]);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// ---------------------------------------------------------------------------
|
|
147
|
+
// Shielding plugin source from Lexical's import
|
|
148
|
+
// ---------------------------------------------------------------------------
|
|
149
|
+
|
|
150
|
+
/** A plugin node core recognized, and the stand-in that holds its place. */
|
|
151
|
+
export interface ShieldedNode {
|
|
152
|
+
readonly node: MarkdownExtensionNode;
|
|
153
|
+
readonly apiVersion: number;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
export interface ShieldedMarkdown {
|
|
157
|
+
/** The Markdown with a stand-in in place of each recognized node. */
|
|
158
|
+
readonly markdown: string;
|
|
159
|
+
/** What each stand-in holds the place of. */
|
|
160
|
+
readonly standIns: ReadonlyMap<string, ShieldedNode>;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
const PRIVATE_USE_RANGES: ReadonlyArray<readonly [number, number]> = [
|
|
164
|
+
[0xe000, 0xf8ff],
|
|
165
|
+
[0xf0000, 0xffffd],
|
|
166
|
+
[0x100000, 0x10fffd],
|
|
167
|
+
];
|
|
168
|
+
|
|
169
|
+
/** Private-use characters that do not occur in `text`, in order. */
|
|
170
|
+
export function* absentCharacters(text: string): Generator<string> {
|
|
171
|
+
const present = new Set(text);
|
|
172
|
+
for (const [first, last] of PRIVATE_USE_RANGES) {
|
|
173
|
+
for (let codePoint = first; codePoint <= last; codePoint++) {
|
|
174
|
+
const character = String.fromCodePoint(codePoint);
|
|
175
|
+
if (!present.has(character)) {
|
|
176
|
+
yield character;
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* The extension nodes core recognizes in `markdown`, in document order: inline
|
|
184
|
+
* nodes anywhere, and block nodes at the top level only (block syntax nested
|
|
185
|
+
* in a list item, quote, or table cell stays text in RichText).
|
|
186
|
+
*/
|
|
187
|
+
function recognizedNodes(
|
|
188
|
+
markdown: string,
|
|
189
|
+
plugins: ReadonlyArray<MarkdownPluginEntry>,
|
|
190
|
+
): {
|
|
191
|
+
readonly adoptable: Array<MarkdownExtensionNode>;
|
|
192
|
+
readonly total: number;
|
|
193
|
+
} {
|
|
194
|
+
const adoptable: Array<MarkdownExtensionNode> = [];
|
|
195
|
+
let total = 0;
|
|
196
|
+
const visit = (node: unknown, isTopLevel: boolean) => {
|
|
197
|
+
if (node == null || typeof node !== 'object') {
|
|
198
|
+
return;
|
|
199
|
+
}
|
|
200
|
+
const candidate = node as {
|
|
201
|
+
readonly type?: unknown;
|
|
202
|
+
readonly children?: unknown;
|
|
203
|
+
};
|
|
204
|
+
if (candidate.type === 'extension') {
|
|
205
|
+
total++;
|
|
206
|
+
const extension = node as MarkdownExtensionNode;
|
|
207
|
+
if (
|
|
208
|
+
typeof extension.source === 'string' &&
|
|
209
|
+
(extension.display === 'inline' || isTopLevel)
|
|
210
|
+
) {
|
|
211
|
+
adoptable.push(extension);
|
|
212
|
+
}
|
|
213
|
+
return;
|
|
214
|
+
}
|
|
215
|
+
if (Array.isArray(candidate.children)) {
|
|
216
|
+
for (const child of candidate.children) {
|
|
217
|
+
visit(child, false);
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
};
|
|
221
|
+
const root = parseMarkdownAst(markdown, {plugins: [...plugins]});
|
|
222
|
+
for (const block of root.children) {
|
|
223
|
+
visit(block, true);
|
|
224
|
+
}
|
|
225
|
+
return {adoptable, total};
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* Replaces the source of every plugin node core recognizes in `markdown` with
|
|
230
|
+
* a stand-in, so Lexical's transformers never read plugin source as base
|
|
231
|
+
* Markdown (spec:AST-064 FR5). A node whose source core does not recognize at
|
|
232
|
+
* any one occurrence — a span split across a container's line markers — stays
|
|
233
|
+
* text.
|
|
234
|
+
*/
|
|
235
|
+
export function shieldExtensionSources(
|
|
236
|
+
markdown: string,
|
|
237
|
+
plugins: ReadonlyArray<MarkdownPluginEntry>,
|
|
238
|
+
): ShieldedMarkdown {
|
|
239
|
+
const standIns = new Map<string, ShieldedNode>();
|
|
240
|
+
if (plugins.length === 0) {
|
|
241
|
+
return {markdown, standIns};
|
|
242
|
+
}
|
|
243
|
+
const {adoptable, total} = recognizedNodes(markdown, plugins);
|
|
244
|
+
if (adoptable.length === 0) {
|
|
245
|
+
return {markdown, standIns};
|
|
246
|
+
}
|
|
247
|
+
const versions = new Map(
|
|
248
|
+
plugins.map(plugin => [plugin.name, plugin.apiVersion]),
|
|
249
|
+
);
|
|
250
|
+
const tokens = absentCharacters(markdown);
|
|
251
|
+
// How many recognized nodes with each source are still to be placed.
|
|
252
|
+
const remaining = new Map<string, number>();
|
|
253
|
+
for (const node of adoptable) {
|
|
254
|
+
const source = node.source ?? '';
|
|
255
|
+
remaining.set(source, (remaining.get(source) ?? 0) + 1);
|
|
256
|
+
}
|
|
257
|
+
let text = markdown;
|
|
258
|
+
let shielded = 0;
|
|
259
|
+
let searchFrom = 0;
|
|
260
|
+
for (const node of adoptable) {
|
|
261
|
+
const source = node.source ?? '';
|
|
262
|
+
const token = tokens.next().value;
|
|
263
|
+
if (token == null || source === '') {
|
|
264
|
+
break;
|
|
265
|
+
}
|
|
266
|
+
const first = text.indexOf(source, searchFrom);
|
|
267
|
+
// When every remaining occurrence of the source is a node core
|
|
268
|
+
// recognized, the first occurrence is this node: no need to ask core.
|
|
269
|
+
const isEveryOccurrence =
|
|
270
|
+
occurrences(text, source, searchFrom) === (remaining.get(source) ?? 0);
|
|
271
|
+
remaining.set(source, (remaining.get(source) ?? 1) - 1);
|
|
272
|
+
for (let at = first; at !== -1; at = text.indexOf(source, at + 1)) {
|
|
273
|
+
const candidate =
|
|
274
|
+
text.slice(0, at) + token + text.slice(at + source.length);
|
|
275
|
+
// The right occurrence is the one whose replacement removes exactly
|
|
276
|
+
// this node from core's parse and leaves every other node in place.
|
|
277
|
+
if (
|
|
278
|
+
isEveryOccurrence ||
|
|
279
|
+
recognizedNodes(candidate, plugins).total === total - shielded - 1
|
|
280
|
+
) {
|
|
281
|
+
text = candidate;
|
|
282
|
+
shielded++;
|
|
283
|
+
searchFrom = at + token.length;
|
|
284
|
+
standIns.set(token, {
|
|
285
|
+
node,
|
|
286
|
+
apiVersion: versions.get(node.plugin) ?? 1,
|
|
287
|
+
});
|
|
288
|
+
break;
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
return {markdown: text, standIns};
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
/** How many times `source` occurs in `text` from `from`, overlaps included. */
|
|
296
|
+
function occurrences(text: string, source: string, from: number): number {
|
|
297
|
+
let count = 0;
|
|
298
|
+
for (
|
|
299
|
+
let at = text.indexOf(source, from);
|
|
300
|
+
at !== -1;
|
|
301
|
+
at = text.indexOf(source, at + 1)
|
|
302
|
+
) {
|
|
303
|
+
count++;
|
|
304
|
+
}
|
|
305
|
+
return count;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
function $nodeFor(
|
|
309
|
+
{node, apiVersion}: ShieldedNode,
|
|
310
|
+
format = 0,
|
|
311
|
+
): RichTextExtensionNode {
|
|
312
|
+
return $createRichTextExtensionNode(
|
|
313
|
+
{
|
|
314
|
+
plugin: node.plugin,
|
|
315
|
+
apiVersion,
|
|
316
|
+
name: node.name,
|
|
317
|
+
display: node.display,
|
|
318
|
+
data: node.data,
|
|
319
|
+
source: node.source ?? '',
|
|
320
|
+
},
|
|
321
|
+
format,
|
|
322
|
+
);
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
/**
|
|
326
|
+
* Puts a RichTextExtensionNode in place of every stand-in Lexical imported
|
|
327
|
+
* into `holder`: inline nodes where their stand-in is, and a block node in
|
|
328
|
+
* place of the paragraph that holds only its stand-in.
|
|
329
|
+
*/
|
|
330
|
+
export function $restoreExtensionSources(
|
|
331
|
+
holder: ElementNode,
|
|
332
|
+
standIns: ReadonlyMap<string, ShieldedNode>,
|
|
333
|
+
): void {
|
|
334
|
+
if (standIns.size === 0) {
|
|
335
|
+
return;
|
|
336
|
+
}
|
|
337
|
+
for (const textNode of holder.getAllTextNodes()) {
|
|
338
|
+
$restoreInText(textNode, holder, standIns);
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
function $restoreInText(
|
|
343
|
+
textNode: TextNode,
|
|
344
|
+
holder: ElementNode,
|
|
345
|
+
standIns: ReadonlyMap<string, ShieldedNode>,
|
|
346
|
+
): void {
|
|
347
|
+
const text = textNode.getTextContent();
|
|
348
|
+
const offsets = new Set<number>();
|
|
349
|
+
let offset = 0;
|
|
350
|
+
for (const character of text) {
|
|
351
|
+
if (standIns.has(character)) {
|
|
352
|
+
offsets.add(offset);
|
|
353
|
+
offsets.add(offset + character.length);
|
|
354
|
+
}
|
|
355
|
+
offset += character.length;
|
|
356
|
+
}
|
|
357
|
+
offsets.delete(0);
|
|
358
|
+
offsets.delete(text.length);
|
|
359
|
+
if (offsets.size === 0 && !standIns.has(text)) {
|
|
360
|
+
return;
|
|
361
|
+
}
|
|
362
|
+
const pieces =
|
|
363
|
+
offsets.size === 0
|
|
364
|
+
? [textNode]
|
|
365
|
+
: textNode.splitText(...[...offsets].sort((a, b) => a - b));
|
|
366
|
+
for (const piece of pieces) {
|
|
367
|
+
const shielded = standIns.get(piece.getTextContent());
|
|
368
|
+
if (shielded == null) {
|
|
369
|
+
continue;
|
|
370
|
+
}
|
|
371
|
+
if (shielded.node.display === 'inline') {
|
|
372
|
+
// The node keeps the text formats its stand-in had: a node inside
|
|
373
|
+
// emphasis, strong, or strikethrough stays inside it.
|
|
374
|
+
piece.replace($nodeFor(shielded, piece.getFormat()));
|
|
375
|
+
continue;
|
|
376
|
+
}
|
|
377
|
+
const paragraph = piece.getParent();
|
|
378
|
+
if (
|
|
379
|
+
$isParagraphNode(paragraph) &&
|
|
380
|
+
paragraph.getParent()?.is(holder) === true
|
|
381
|
+
) {
|
|
382
|
+
$placeBlockNode(paragraph, piece, $nodeFor(shielded));
|
|
383
|
+
} else {
|
|
384
|
+
// A block stand-in that did not land in a top-level paragraph keeps
|
|
385
|
+
// the node's source as text.
|
|
386
|
+
piece.replace($createTextNode(shielded.node.source ?? ''));
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
/**
|
|
392
|
+
* Puts a block node where its stand-in sits in `paragraph`, splitting the
|
|
393
|
+
* paragraph around it: lines before the stand-in stay a paragraph, the node
|
|
394
|
+
* follows as its own top-level block, and lines after it become a new
|
|
395
|
+
* paragraph — as core reads a block that starts on the line after a paragraph
|
|
396
|
+
* line.
|
|
397
|
+
*/
|
|
398
|
+
function $placeBlockNode(
|
|
399
|
+
paragraph: ParagraphNode,
|
|
400
|
+
standIn: TextNode,
|
|
401
|
+
block: RichTextExtensionNode,
|
|
402
|
+
): void {
|
|
403
|
+
const after = standIn.getNextSiblings();
|
|
404
|
+
const before = standIn.getPreviousSiblings();
|
|
405
|
+
// The line breaks around the stand-in end and start the neighbouring lines.
|
|
406
|
+
if ($isLineBreakNode(before[before.length - 1])) {
|
|
407
|
+
before[before.length - 1]?.remove();
|
|
408
|
+
}
|
|
409
|
+
if ($isLineBreakNode(after[0])) {
|
|
410
|
+
after[0]?.remove();
|
|
411
|
+
after.shift();
|
|
412
|
+
}
|
|
413
|
+
standIn.remove();
|
|
414
|
+
if (after.length > 0) {
|
|
415
|
+
const rest = $createParagraphNode();
|
|
416
|
+
rest.append(...after);
|
|
417
|
+
paragraph.insertAfter(rest);
|
|
418
|
+
}
|
|
419
|
+
paragraph.insertAfter(block);
|
|
420
|
+
if (paragraph.getChildrenSize() === 0) {
|
|
421
|
+
paragraph.remove();
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
// ---------------------------------------------------------------------------
|
|
426
|
+
// Export
|
|
427
|
+
// ---------------------------------------------------------------------------
|
|
428
|
+
|
|
429
|
+
const NEVER = /(?!)/;
|
|
430
|
+
|
|
431
|
+
/**
|
|
432
|
+
* Exports every plugin node as its exact source (spec:AST-064 FR10). Always
|
|
433
|
+
* part of RichText's export, whatever transformers the caller passes, so no
|
|
434
|
+
* transformer set can drop a plugin node's source.
|
|
435
|
+
*/
|
|
436
|
+
export const EXTENSION_EXPORT_TRANSFORMERS: ReadonlyArray<Transformer> = [
|
|
437
|
+
{
|
|
438
|
+
dependencies: [RichTextExtensionNode],
|
|
439
|
+
export: (node: LexicalNode) =>
|
|
440
|
+
$isRichTextExtensionNode(node) && !node.isInline()
|
|
441
|
+
? node.getSource()
|
|
442
|
+
: null,
|
|
443
|
+
regExp: NEVER,
|
|
444
|
+
replace: () => false,
|
|
445
|
+
type: 'element',
|
|
446
|
+
},
|
|
447
|
+
{
|
|
448
|
+
dependencies: [RichTextExtensionNode],
|
|
449
|
+
export: (node: LexicalNode) =>
|
|
450
|
+
$isRichTextExtensionNode(node) && node.isInline()
|
|
451
|
+
? node.getSource()
|
|
452
|
+
: null,
|
|
453
|
+
importRegExp: NEVER,
|
|
454
|
+
regExp: NEVER,
|
|
455
|
+
replace: () => {},
|
|
456
|
+
type: 'text-match',
|
|
457
|
+
},
|
|
458
|
+
];
|
|
459
|
+
|
|
460
|
+
/**
|
|
461
|
+
* A text view of an inline plugin node for Lexical's exporter: it reads as
|
|
462
|
+
* text holding `placeholder`, with the node's formats, so emphasis, strong, or
|
|
463
|
+
* strikethrough around the node wraps it instead of closing before it. The
|
|
464
|
+
* caller writes the node's source in place of the placeholder.
|
|
465
|
+
*/
|
|
466
|
+
export function extensionTextView(
|
|
467
|
+
node: RichTextExtensionNode,
|
|
468
|
+
placeholder: string,
|
|
469
|
+
): TextNode {
|
|
470
|
+
const view = Object.create(TextNode.prototype) as TextNode;
|
|
471
|
+
// Reads of the view's format go to the node itself through its key.
|
|
472
|
+
Object.assign(view, {__key: node.getKey()});
|
|
473
|
+
view.getTextContent = () => placeholder;
|
|
474
|
+
view.getParent = <T extends ElementNode>() => node.getParent<T>();
|
|
475
|
+
return view;
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
const withExport = new WeakMap<
|
|
479
|
+
ReadonlyArray<Transformer>,
|
|
480
|
+
Array<Transformer>
|
|
481
|
+
>();
|
|
482
|
+
|
|
483
|
+
/**
|
|
484
|
+
* `transformers` with the plugin node export first, the same array for the
|
|
485
|
+
* same transformers so callers that cache by transformer list keep hitting.
|
|
486
|
+
*/
|
|
487
|
+
export function withExtensionExport(
|
|
488
|
+
transformers: ReadonlyArray<Transformer>,
|
|
489
|
+
): Array<Transformer> {
|
|
490
|
+
let combined = withExport.get(transformers);
|
|
491
|
+
if (combined == null) {
|
|
492
|
+
combined = [...EXTENSION_EXPORT_TRANSFORMERS, ...transformers];
|
|
493
|
+
withExport.set(transformers, combined);
|
|
494
|
+
}
|
|
495
|
+
return combined;
|
|
496
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
// Copyright (c) Meta Platforms, Inc. and affiliates.
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* @file markdownHardLineBreak.ts
|
|
5
|
+
* @input Uses lexical (LineBreakNode) and @lexical/markdown
|
|
6
|
+
* (TextMatchTransformer).
|
|
7
|
+
* @output Exports HARD_LINE_BREAK, the transformer that writes a line break
|
|
8
|
+
* added in the editor (Shift+Enter) as a Markdown hard break, `\` and a
|
|
9
|
+
* line ending, and drops a line break that ends its block.
|
|
10
|
+
* @position Part of DEFAULT_TRANSFORMERS (markdownTable.ts). Import joins
|
|
11
|
+
* soft line breaks into their block (markdownSource.ts), so every line
|
|
12
|
+
* break left in a paragraph, list item, or quote is a hard one. Lexical
|
|
13
|
+
* writes the breaks it imported as hard with their own marker (two spaces
|
|
14
|
+
* or a backslash); a break typed in the editor has none and would be
|
|
15
|
+
* written as a plain line ending, which reads back as a soft break and
|
|
16
|
+
* loses the line (spec:AST-062 FR3). Headings are one line in Markdown, so
|
|
17
|
+
* a break typed in one keeps Lexical's plain line ending. A hard break has
|
|
18
|
+
* no form at the end of a block: CommonMark reads its backslash as a
|
|
19
|
+
* literal character there, and shows nothing for two trailing spaces. So a
|
|
20
|
+
* line break that only other breaks or spaces follow is written as nothing.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import type {TextMatchTransformer} from '@lexical/markdown';
|
|
24
|
+
import {$isHeadingNode} from '@lexical/rich-text';
|
|
25
|
+
import {$findMatchingParent} from '@lexical/utils';
|
|
26
|
+
import {$isLineBreakNode, $isTextNode, LineBreakNode} from 'lexical';
|
|
27
|
+
import type {LexicalNode} from 'lexical';
|
|
28
|
+
|
|
29
|
+
/** Whether Lexical's Markdown import marked this line break as a hard one. */
|
|
30
|
+
export function isMarkedHardLineBreak(node: LineBreakNode): boolean {
|
|
31
|
+
const serialized = node.exportJSON() as {$?: {mdHardLineBreak?: unknown}};
|
|
32
|
+
return (
|
|
33
|
+
typeof serialized.$?.mdHardLineBreak === 'string' &&
|
|
34
|
+
serialized.$.mdHardLineBreak !== ''
|
|
35
|
+
);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Whether only line breaks and blank text follow `node` in its block. */
|
|
39
|
+
function $endsItsBlock(node: LexicalNode): boolean {
|
|
40
|
+
for (
|
|
41
|
+
let next = node.getNextSibling();
|
|
42
|
+
next != null;
|
|
43
|
+
next = next.getNextSibling()
|
|
44
|
+
) {
|
|
45
|
+
const isBlankText =
|
|
46
|
+
$isTextNode(next) && next.getTextContent().trim() === '';
|
|
47
|
+
if (!$isLineBreakNode(next) && !isBlankText) {
|
|
48
|
+
return false;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
return true;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export const HARD_LINE_BREAK: TextMatchTransformer = {
|
|
55
|
+
dependencies: [LineBreakNode],
|
|
56
|
+
export: node => {
|
|
57
|
+
if (
|
|
58
|
+
!$isLineBreakNode(node) ||
|
|
59
|
+
// A heading is one line in Markdown; a break inside one has no
|
|
60
|
+
// hard-break form, so it keeps Lexical's plain line ending.
|
|
61
|
+
$findMatchingParent(node, $isHeadingNode) != null
|
|
62
|
+
) {
|
|
63
|
+
return null;
|
|
64
|
+
}
|
|
65
|
+
if ($endsItsBlock(node)) {
|
|
66
|
+
return '';
|
|
67
|
+
}
|
|
68
|
+
return isMarkedHardLineBreak(node) ? null : '\\\n';
|
|
69
|
+
},
|
|
70
|
+
// Export only: Lexical's own import recognizes hard breaks.
|
|
71
|
+
regExp: /(?!)/,
|
|
72
|
+
type: 'text-match',
|
|
73
|
+
};
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
// Copyright (c) Meta Platforms, Inc. and affiliates.
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* @file markdownHeading.ts
|
|
5
|
+
* @input Uses @lexical/markdown (Lexical's HEADING element transformer).
|
|
6
|
+
* @output Exports HEADING_MARKERS, Lexical's heading transformer reading an
|
|
7
|
+
* ATX heading marker as CommonMark does.
|
|
8
|
+
* @position Part of DEFAULT_TRANSFORMERS (markdownTable.ts) in place of
|
|
9
|
+
* Lexical's HEADING, whose pattern allows no indentation. Matches core
|
|
10
|
+
* Markdown's ATX headings (CommonMark 0.31 §4.2). Typing shortcuts keep
|
|
11
|
+
* Lexical's own trigger: `# ` makes a heading. Text that starts like a
|
|
12
|
+
* marker is escaped on export by markdownSource.ts.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import {HEADING, type ElementTransformer} from '@lexical/markdown';
|
|
16
|
+
|
|
17
|
+
/** Lexical's own heading marker, which the typing shortcut keeps using. */
|
|
18
|
+
const SHORTCUT_MARKER = /^(#{1,6})\s/;
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* An ATX heading marker: up to three spaces of indentation, then one to six
|
|
22
|
+
* `#` and a space, so ` # h` imports as a heading, as in core Markdown. A
|
|
23
|
+
* typed line becomes a heading only at Lexical's own `# `, so typing
|
|
24
|
+
* ` # ` changes nothing new.
|
|
25
|
+
*/
|
|
26
|
+
export const HEADING_MARKERS: ElementTransformer = {
|
|
27
|
+
...HEADING,
|
|
28
|
+
regExp: /^ {0,3}(#{1,6})\s/,
|
|
29
|
+
replace: (parentNode, children, match, isImport) =>
|
|
30
|
+
isImport || SHORTCUT_MARKER.test(match[0])
|
|
31
|
+
? HEADING.replace(parentNode, children, match, isImport)
|
|
32
|
+
: false,
|
|
33
|
+
};
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
// Copyright (c) Meta Platforms, Inc. and affiliates.
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* @file markdownLink.ts
|
|
5
|
+
* @input Uses @lexical/link and @lexical/markdown (LINK).
|
|
6
|
+
* @output Exports LINK_KEEPING_DESTINATIONS, Lexical's link transformer with
|
|
7
|
+
* one change on export. Import is Lexical's own, which reads every
|
|
8
|
+
* CommonMark destination and title shape, an angle-bracket destination
|
|
9
|
+
* (`<https://e.com/a b>`) included, and decodes backslash escapes and
|
|
10
|
+
* character references in both. A destination that needs no escape is
|
|
11
|
+
* written by Lexical's export too, which escapes parentheses and an `&`
|
|
12
|
+
* that starts a character reference (`https://e.com/a_(b)` as
|
|
13
|
+
* `https://e.com/a_\(b\)`). One that holds whitespace or opens with `<`
|
|
14
|
+
* is written here in angle brackets, and one with a backslash or
|
|
15
|
+
* unbalanced parentheses with those escaped; both escape a
|
|
16
|
+
* reference-starting `&` as Lexical does, so every link reads back as
|
|
17
|
+
* the same link, whether it was imported or made in the editor
|
|
18
|
+
* (spec:AST-062 FR3).
|
|
19
|
+
* @position Replaces Lexical's LINK in DEFAULT_TRANSFORMERS and in table
|
|
20
|
+
* cells (markdownTable.ts).
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import {$isAutoLinkNode, $isLinkNode} from '@lexical/link';
|
|
24
|
+
import {LINK, type TextMatchTransformer} from '@lexical/markdown';
|
|
25
|
+
|
|
26
|
+
/** Whether every `)` in `url` closes an earlier `(` and every `(` closes. */
|
|
27
|
+
function isBalanced(url: string): boolean {
|
|
28
|
+
let depth = 0;
|
|
29
|
+
for (const character of url) {
|
|
30
|
+
if (character === '(') {
|
|
31
|
+
depth++;
|
|
32
|
+
} else if (character === ')') {
|
|
33
|
+
depth--;
|
|
34
|
+
if (depth < 0) {
|
|
35
|
+
return false;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
return depth === 0;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* `value` with every `&` that would start a character reference written as
|
|
44
|
+
* `&`, since import decodes references in a destination and a title.
|
|
45
|
+
*/
|
|
46
|
+
function escapeCharacterReferences(value: string): string {
|
|
47
|
+
return value.replace(
|
|
48
|
+
/&(?=#\d+;|#[Xx][\dA-Fa-f]+;|[A-Za-z][\dA-Za-z]*;)/g,
|
|
49
|
+
'&',
|
|
50
|
+
);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** `url` as a Markdown destination that reads back as `url`. */
|
|
54
|
+
export function markdownDestination(url: string): string {
|
|
55
|
+
if (/\s/.test(url) || url.startsWith('<')) {
|
|
56
|
+
// Only an angle-bracket destination may hold a space or open with `<`
|
|
57
|
+
// (CommonMark §6.3).
|
|
58
|
+
return `<${url.replace(/[\\<>]/g, '\\$&')}>`;
|
|
59
|
+
}
|
|
60
|
+
// A backslash before punctuation would read back as an escape, so every
|
|
61
|
+
// backslash is written escaped; so is every parenthesis that does not
|
|
62
|
+
// balance.
|
|
63
|
+
return url.replace(isBalanced(url) ? /\\/g : /[\\()]/g, '\\$&');
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export const LINK_KEEPING_DESTINATIONS: TextMatchTransformer = {
|
|
67
|
+
// Lexical's own import reads every CommonMark destination and title shape,
|
|
68
|
+
// an angle-bracket destination included.
|
|
69
|
+
...LINK,
|
|
70
|
+
export: (node, exportChildren, exportFormat) => {
|
|
71
|
+
if (!$isLinkNode(node) || $isAutoLinkNode(node)) {
|
|
72
|
+
return null;
|
|
73
|
+
}
|
|
74
|
+
const url = node.getURL();
|
|
75
|
+
const destination = markdownDestination(url);
|
|
76
|
+
if (destination === url) {
|
|
77
|
+
return LINK.export?.(node, exportChildren, exportFormat) ?? null;
|
|
78
|
+
}
|
|
79
|
+
const text = exportChildren(node);
|
|
80
|
+
const written = escapeCharacterReferences(destination);
|
|
81
|
+
const title = node.getTitle();
|
|
82
|
+
return title == null || title === ''
|
|
83
|
+
? `[${text}](${written})`
|
|
84
|
+
: `[${text}](${written} "${escapeCharacterReferences(title).replace(/([\\"])/g, '\\$1')}")`;
|
|
85
|
+
},
|
|
86
|
+
};
|